feat: xavis ai_platform 기능 이전 및 ncue 환경 전환
xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
40
.env.example
@@ -4,7 +4,7 @@ OPS_STATE=PROD # DEV: 개발 | PROD: 운영(임직원 이메일 로그인) | S
|
||||
PORT=8030
|
||||
# HTTP 수신 주소 (기본 0.0.0.0 = 모든 인터페이스, 로컬만이면 127.0.0.1)
|
||||
HOST=0.0.0.0
|
||||
ADMIN_TOKEN=xavis-admin
|
||||
ADMIN_TOKEN=ncue-admin
|
||||
PAGE_SIZE=9
|
||||
# 1=PostgreSQL 단일 소스, 0=data/lectures.json 사용
|
||||
ENABLE_POSTGRES=1
|
||||
@@ -14,9 +14,19 @@ DB_DATABASE=ai_web_platform
|
||||
DB_USERNAME=xavis
|
||||
DB_PASSWORD=wkqltm@@00492
|
||||
|
||||
# PostgreSQL 백업 (scripts/pg-backup.sh · README 「PostgreSQL 백업 및 복원」)
|
||||
PG_BACKUP_DIR=/home/xavis/workspace/backup/ai_platform
|
||||
PG_BACKUP_RETENTION_DAYS=30
|
||||
PG_BACKUP_SCOPE=all
|
||||
PG_BACKUP_GLOBALS=1
|
||||
PG_BACKUP_SUPERUSER=postgres
|
||||
PG_BACKUP_SUPERUSER_PASSWORD=
|
||||
|
||||
# DB 연결이 없거나 실패하면 회의록 AI는 data/meeting-ai.json에 저장됩니다(로컬 개발에 유용).
|
||||
# 회의 음성 업로드 최대 크기(MB, 기본 300). OpenAI 전사 API는 요청당 약 25MB이므로 초과분은 서버에서 ffmpeg로 분할 후 전사합니다.
|
||||
MEETING_AUDIO_MAX_MB=300
|
||||
# prepare-audio → stream-audio JOB 메타(data/meeting-audio-job-meta) TTL(ms). 서버 코드에서 최소·최대 클램프.
|
||||
# MEETING_AUDIO_JOB_TTL_MS=3600000
|
||||
|
||||
ENABLE_PPT_THUMBNAIL=1
|
||||
ENABLE_VIDEO_THUMBNAIL=1
|
||||
@@ -30,14 +40,18 @@ THUMBNAIL_EVENT_PAGE_SIZE=50
|
||||
|
||||
[인증 메일 정보]
|
||||
BASE_URL=https://ai.xavis.co.kr # 메일 속 링크에 사용
|
||||
AUTH_SECRET=xavis-admin # 세션 서명 (ADMIN_TOKEN 폴백 가능)
|
||||
AUTH_SECRET=ncue-admin # 세션 서명 (ADMIN_TOKEN 폴백 가능)
|
||||
# OPS(@ncue.net) 이메일 로그인 세션: 기본 0 = 만기 없음(서명상). 브라우저 쿠키는 약 10년마다 갱신 필요할 수 있음
|
||||
# OPS_SESSION_TTL_DAYS=0
|
||||
# N일 고정 만료(Asia/Seoul 달력)로 바꾸려면: OPS_SESSION_TTL_DAYS=15
|
||||
# OPS_SESSION_TZ=Asia/Seoul
|
||||
# 메일 발송 (선택, 없으면 콘솔에 링크만)
|
||||
SMTP_HOST=gw.xavis.co.kr
|
||||
SMTP_PORT=25
|
||||
SMTP_SECURE=0
|
||||
SMTP_USER=dsyoon
|
||||
SMTP_PASS=!xavis5004
|
||||
SMTP_FROM=dsyoon@xavis.co.kr
|
||||
SMTP_USER=spark_ai
|
||||
SMTP_PASS=!xavis2026
|
||||
SMTP_FROM=spark_ai@ncue.net
|
||||
|
||||
|
||||
[채팅 기능 정보]
|
||||
@@ -69,11 +83,15 @@ GENAI_API_KEY=
|
||||
# MEETING_EMPLOYEE_NAMES_FILE=./data/meeting-employee-names.txt
|
||||
# 0 이면 전사→명단 퍼지 매칭 블록 비활성화
|
||||
# MEETING_NAME_NORMALIZATION=1
|
||||
# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@xavis.co.kr)
|
||||
EETING_DEV_EMAIL=dsyoon@xavis.co.kr
|
||||
# 선택: Whisper 전사 모델 (기본 whisper-1)
|
||||
OPENAI_WHISPER_MODEL=gpt-4o-mini-transcribe
|
||||
MEETING_DEV_EMAIL=dsyoon@xavis.co.kr
|
||||
# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@ncue.net)
|
||||
EETING_DEV_EMAIL=spark_ai@ncue.net
|
||||
# 선택: OpenAI 회의록 음성 전사 모델 (미설정 시 앱 기본 gpt-4o-transcribe)
|
||||
OPENAI_WHISPER_MODEL=gpt-4o-transcribe
|
||||
MEETING_DEV_EMAIL=spark_ai@ncue.net
|
||||
|
||||
[경영성과 대시보드]
|
||||
DASHBOARD_MENU_ALLOWED_EMAILS=hmjin@xavis.co.kr,dsyoon@xavis.co.kr
|
||||
DASHBOARD_MENU_ALLOWED_EMAILS=hmjin@ncue.net,dsyoon@ncue.net
|
||||
# 가이드봇·WM 메뉴 허용 OPS 이메일(쉼표 구분). 관리자(adminMode)는 항상 허용.
|
||||
# GUIDE_BOT_MENU_ALLOWED_EMAILS=dsyoon@ncue.net
|
||||
# 업무 체크리스트 AI 카드·API 허용 OPS 이메일(기본 dsyoon@ncue.net, 관리자 예외 없음).
|
||||
# TASK_CHECKLIST_ALLOWED_EMAILS=dsyoon@ncue.net
|
||||
|
||||
@@ -4,7 +4,7 @@ OPS_STATE=DEV # DEV: 개발 | PROD: 운영(임직원 이메일 로그인) | SU
|
||||
PORT=8030
|
||||
# HTTP 수신 주소 (기본 0.0.0.0 = 모든 인터페이스, 로컬만이면 127.0.0.1)
|
||||
HOST=0.0.0.0
|
||||
ADMIN_TOKEN=xavis-admin
|
||||
ADMIN_TOKEN=ncue-admin
|
||||
PAGE_SIZE=9
|
||||
# 1=PostgreSQL 단일 소스, 0=data/lectures.json 사용
|
||||
ENABLE_POSTGRES=1
|
||||
@@ -63,8 +63,8 @@ GENAI_API_KEY=
|
||||
|
||||
|
||||
[회의록 기능 정보]
|
||||
# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@xavis.co.kr)
|
||||
# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@ncue.net)
|
||||
EETING_DEV_EMAIL=dsyoon@ncue.net
|
||||
# 선택: Whisper 전사 모델 (기본 whisper-1)
|
||||
OPENAI_WHISPER_MODEL=gpt-4o-mini-transcribe
|
||||
# 선택: OpenAI 회의록 음성 전사 모델 (미설정 시 앱 기본 gpt-4o-transcribe와 동일 권장)
|
||||
OPENAI_WHISPER_MODEL=gpt-4o-transcribe
|
||||
MEETING_DEV_EMAIL=dsyoon@ncue.net
|
||||
|
||||
22
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.cursor/
|
||||
|
||||
# 환경 비밀값 — 저장소에는 .env.example 만 공유
|
||||
*.log
|
||||
@@ -7,14 +8,13 @@ uploads/
|
||||
.tmp-pdftest/
|
||||
package-lock.json
|
||||
|
||||
# data: 임시 작업 디렉터리만 제외 (JSON·마크다운 등 주요 데이터는 버전 관리)
|
||||
data/tmp/
|
||||
# 매직 링크 토큰(OPS PROD)
|
||||
data/ops-magic-links.json
|
||||
# 회의록 AI 로컬 폴백(사용자별 프롬프트·저장 회의)
|
||||
data/meeting-ai.json
|
||||
data/meeting-ai-checklist.json
|
||||
# PPT 썸네일 이벤트 로그(PG 미사용 시 폴백·마이그레이션 백업)
|
||||
data/thumbnail-events.json
|
||||
data/thumbnail-events.json.migrated.bak
|
||||
data/mgmt-perf-last-state.json
|
||||
# 런타임 데이터 (로컬·서버 전용, Git 제외) — 기본 템플릿은 config/ 참고
|
||||
data/
|
||||
!data/.gitkeep
|
||||
|
||||
# AI 활용 사례 등 업로드된 정적 리소스
|
||||
public/resources/ai-success/
|
||||
public/files/
|
||||
|
||||
# PostgreSQL 논리 백업 산출물 (scripts/pg-backup.sh)
|
||||
backups/
|
||||
|
||||
368
README.md
@@ -2,6 +2,8 @@
|
||||
|
||||
유튜브 링크와 PowerPoint(`.pptx`) 강의를 등록하고, 목록에서 클릭해 바로 시청할 수 있는 사내 학습센터 예제입니다.
|
||||
|
||||
**소스 저장소(Git):** [https://git.xavis.co.kr/AI_Innovation_Team/ai_platform](https://git.xavis.co.kr/AI_Innovation_Team/ai_platform)
|
||||
|
||||
---
|
||||
|
||||
## 접속 방법
|
||||
@@ -19,7 +21,7 @@
|
||||
- **학습센터 뷰어** (`/learning`): 강의 검색/필터 + 카드 목록 (일반 사용자)
|
||||
- **관리자** (`/admin`): 강의 등록(YouTube/PPT/웹 링크 등), 썸네일·AI 추가 등 통합 관리
|
||||
- **강의 상세**: 카드 클릭 시 유튜브 재생 또는 PPT 뷰어
|
||||
- **기타 메뉴**: 채팅(`/chat`), AI(`/ai-explore`), **프롬프트 라이브러리**(`/ai-explore/prompts`), **AI 성공 사례**(`/ai-cases`), **대시보드**(`/dashboard`, 성공 사례 아래 메뉴), AX 과제 신청(`/ax-apply`)
|
||||
- **기타 메뉴**: 회사규정(`/chat` 리다이렉트), WM(`/wm` 리다이렉트), AI(`/ai-explore`), **프롬프트**(`/ai-explore/prompts`, 좌측 전역 메뉴), **AI 활용 사례**(`/ai-cases`), **대시보드**(`/dashboard`, AI 활용 사례 아래 메뉴), AX 과제 신청(`/ax-apply`)
|
||||
- **관리자 이벤트 로그**: `http://localhost:8030/admin/thumbnail-events?token={ADMIN_TOKEN}`
|
||||
|
||||
### 접속이 안 될 때 (트러블슈팅)
|
||||
@@ -35,19 +37,26 @@
|
||||
5. **바인드 주소**
|
||||
기본은 모든 네트워크 인터페이스(`0.0.0.0`)에서 수신합니다. 로컬 루프백만 쓰려면 `HOST=127.0.0.1 npm start`를 사용할 수 있습니다.
|
||||
|
||||
### 배포 서버: `git pull`이 `data/` 런타임 파일 때문에 막힐 때
|
||||
|
||||
원격이 `data/`(예: `data/ai-success-stories.json`)를 Git 추적에서 제외하는 쪽으로 바뀌는 동안, 서버에 **수정해 둔** 같은 경로가 있으면 *Your local changes to the following files would be overwritten by merge* 메시지와 함께 `git pull`이 중단될 수 있습니다.
|
||||
|
||||
- **권장:** 프로젝트 루트에서 `bash scripts/safe-git-pull.sh`를 실행합니다. 백업 → 로컬 변경만 Git 관점에서 정리 → `git pull` → 백업 복원 순서로, 서비스에 쓰는 JSON 내용을 유지합니다. `main`이 반영된 뒤에는 해당 파일이 **무시(ignore)** 대상이 되어, 이후에는 보통 `git pull`만으로 갱신됩니다.
|
||||
- **수동:** 파일을 임시로 복사한 뒤 `git restore data/ai-success-stories.json`(구버전은 `git checkout -- data/ai-success-stories.json`) → `git pull` → 필요 시 다시 복사합니다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
- 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃)
|
||||
- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동
|
||||
- **회의록 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`입니다.
|
||||
- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(회의록·체크리스트 등; **프롬프트 라이브러리**는 좌측 **프롬프트** 메뉴에서 진입)
|
||||
- **회의록 AI** (`/ai-explore/meeting-minutes`): 텍스트 입력 탭에서 **회의 원문** 옆 **`복사` 버튼**(생성 결과의 회의록·전사와 동일한 연한 녹색 스타일)으로 원문을 클립보드에 복사할 수 있습니다. 회의록 생성 시스템 프롬프트는 `lib/meeting-minutes.js`의 `buildMeetingMinutesSystemPrompt`에서 구성하며, 기본 구조 중 「2) 참석·언급 인원」은 **표 없이 한 줄 쉼표 목록**(원문 근거 인명·직함·조직명)으로 안내합니다. 저장된 사용자 **추가 지시**(비어 두면 해당 블록 없음)만 시스템 프롬프트 우선 순위 블록에 합류합니다. 화면 **음성 업로드** 생성 시 처리 상태를 **1 업로드 / 2 전사 / 3 번역**(전사 결과를 회의록 형식으로 LLM이 정리하는 단계)으로 나누어 진행 표시 및 SSE 패딩·하트비트로 장시간 작업 안내를 제공합니다. 음성 UI는 브라우저에서 두 단계로 동작합니다. **`POST /api/meeting-minutes/prepare-audio`**(multipart 필드: `audio`, `title`, `meetingDate`, `model`, `whisperModel` · `whisperModel` 생략 시 서버 기본 전사는 `gpt-4o-transcribe`, 필요 시 **`OPENAI_WHISPER_MODEL`**) 요청 시 **XHR `upload.onprogress`**로 업로드 바이트를 표시하고, 수신 즉시 **`{ jobId }`** JSON을 돌려줍니다. 이어 같은 세션으로 **`GET /api/meeting-minutes/stream-audio/:jobId`**를 `fetch`(ReadableStream)로 열어 `text/event-stream` 전사·회의록 SSE를 **증분 수신**합니다. **업로드와 SSE가 한 HTTP 요청으로 묶이면**(레거시 `POST …/generate-audio`) 브라우저·역프록시가 응답 본문을 버퍼링해 진행이 멈춘 것처럼 보일 수 있어, 위 분리 플로를 권장합니다. 작업 메타는 `data/meeting-audio-job-meta/`(실행 시 생성, 저장소에서는 `data/`가 무시)에 두며 TTL은 **`MEETING_AUDIO_JOB_TTL_MS`**(미설정 시 약 1시간, 상한·하한은 코드 참고). **로드밸런스 뒤에 Node 인스턴스가 여러 대**이면 이 `data/` 디렉터리(또는 job 메타 저장소)와 업로드 디렉터리가 **인스턴스 간 공유**되어야 같은 `jobId`에 대해 prepare와 stream 요청이 같은 파일·메타를 읽을 수 있습니다. 클라이언트에서는 `consumeSseFromFetch`가 이벤트마다 **`mmAudioStreamHandlers.onSseEvent`**를 호출합니다(`views/meeting-minutes.ejs`). 디버깅 시 같은 페이지 콘솔에서 `getMeetingMinutesAudioDiag()`를 호출하면 `uploadPhase`, `lastSseEvent`, 세그먼트 진행 등을 확인할 수 있습니다. 회의 결과 저장 시 `lib/parse-checklist-from-minutes.js`가 **액션 아이템 마크다운 표**(GFM 권장)와 번호 목록을 규칙으로 읽은 뒤 `extractChecklistStructured`(LLM) 결과와 서버에서 병합하여 업무 체크리스트로 반영합니다(`MEETING_AUTO_CHECKLIST=0` 등으로 끌 수 있음). 동일 처리 경로에서 `prepareMeetingMinutesForApi`가 **헤더·본문 열 수 불일치**(헤더 3열 대 데이터 4열 등)로 깨진 액션 표를 교정하고, 「담당」열에 들어 간 **발언자·저희·우리 팀** 등 통칭·역할 명칭 단독은 규칙 후처리 시 **미정**으로 바꿀 수 있습니다. DB `meeting_ai_prompts.include_checklist`가 `true`일 때만 회의 체크리스트 강제 블록을 넣고, 기본값은 `false`입니다. `include_checklist`가 `false`이면 생성 직후 `prepareMeetingMinutesForApi`에서 `## 회의 체크리스트` 등 블록을 **후처리로 제거**합니다(모델이 습관적으로 넣은 경우 대비). 기존 DB에 `include_checklist = true`가 남아 있으면 `UPDATE meeting_ai_prompts SET include_checklist = false`로 끄거나, 화면에서 **프롬프트 저장**으로 덮어씁니다.
|
||||
- **임직원 인명 정규화**: `data/meeting-employee-names.txt`(또는 `MEETING_EMPLOYEE_NAMES_FILE`)에 성명을 두고, `lib/meeting-employee-names.js`가 전사·원문에서 **이름으로 보이는 토큰만** 명단과 퍼지 매칭해, 회의록 LLM 요청 **사용자 메시지 상단**에 짧은「이번 원문/전사 한정 · 임직원 표기 통일」블록만 붙입니다. 전 직원 명단을 시스템 프롬프트에 넣지 않습니다. 끄려면 `MEETING_NAME_NORMALIZATION=0`.
|
||||
- **대시보드** (`/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행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다.
|
||||
- **경영성과 대시보드** (`/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`으로 동기화합니다. 리버스 프록시로 **Apache2**를 쓰는 경우 업로드 실패·413이면 VirtualHost **`LimitRequestBody`**(예: 바이트 단위로 `deploy/apache-ai.ncue.net-ssl.conf.example`와 맞출 것)와 **`/api/`·`/mgmt-perf/` → Node** `ProxyPass` 전달 여부를 확인. (Nginx를 쓰는 환경이면 **`client_max_body_size`** 등을 해당 서버 설정에 맞춥니다.) 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작.
|
||||
- **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`config/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다.
|
||||
- **대시보드 메뉴 접근**: `.env`의 `DASHBOARD_MENU_ALLOWED_EMAILS`에 **쉼표로 구분한 OPS 로그인 이메일**만 좌측 **대시보드** 메뉴·`/dashboard`·경영성과 API가 보입니다. 목록이 비어 있으면 누구에게도 표시되지 않습니다. 로컬(DEV)에서 관리자 토큰만 쓰는 경우 `DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1`과 `MEETING_DEV_EMAIL`을 허용 목록과 맞추면 대조됩니다.
|
||||
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
|
||||
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 좌측 **프롬프트** 메뉴로 진입 · 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`config/company-prompts.json`) · **공유하기** 탭의 본문은 **원문 보기** / **마크다운 보기** 토글(클라이언트 `marked` + `DOMPurify`) · **워크플로** 탭(①~④ 입력·초안 합치기·AI로 다듬기). **좌측 메뉴(채팅·AI·프롬프트·AI 활용 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
|
||||
- 검색/필터/페이지네이션
|
||||
- 검색어(`q`) 기반 제목/설명/태그 필터
|
||||
- 타입(`YouTube`, `PPT`) 필터
|
||||
@@ -75,7 +84,7 @@
|
||||
- **관리자 인증·바로가기(UI)**
|
||||
- **좌측 메뉴 하단 `관리자` / `관리자 off`**: 운영(OPS)에서 **이메일 인증**으로 로그인한 임직원에게도 표시됩니다. **`관리자`**는 모달에서 `ADMIN_TOKEN`을 입력해 검증한 뒤 `/admin`으로 이동합니다(`POST /api/admin/validate-token`). **`관리자 off`**는 관리자 쿠키를 지우고 학습센터 목록(`/learning`)으로 돌아갑니다(`GET /admin/logout`). 이메일 미로그인 환경에서도 동일합니다. **이미 관리자 세션**이면 중복이므로 `관리자` 항목은 숨기고, **사용자 현황관리** → 구분선 → **관리자 off** →(OPS일 때 구분선)→ **로그아웃** 순으로 표시됩니다.
|
||||
- **학습센터** (`/learning`): 관리자 쿠키가 있을 때 상단 오른쪽 **학습 등록**으로 통합 관리 화면(`/admin`)에 들어갈 수 있습니다. 이메일(OPS) 로그인과 동시에 있어도 버튼이 숨겨지지 않습니다.
|
||||
- **AI 성공 사례** (`/ai-cases`): 관리자일 때 상단 **사례 등록·관리**로 편집 화면(`/ai-cases/write`)에 진입합니다(동일하게 OPS 로그인 중에도 표시).
|
||||
- **AI 활용 사례** (`/ai-cases`): 관리자일 때 상단 **사례 등록·관리**로 편집 화면(`/ai-cases/write`)에 진입합니다(동일하게 OPS 로그인 중에도 표시).
|
||||
|
||||
---
|
||||
|
||||
@@ -88,36 +97,44 @@ ai_platform/
|
||||
├─ .env # 환경 변수 (실제 값, .gitignore 대상)
|
||||
├─ .env.example # 환경 변수 예시 템플릿
|
||||
├─ db/
|
||||
│ └─ schema.sql # PostgreSQL 스키마 (강의·회의록·경영성과 업로드 등, 기동 시 자동 적용)
|
||||
│ └─ schema.sql # PostgreSQL 스키마 (강의·회의록·경영성과·프롬프트 라이브러리 좋아요/공유 등, 기동 시 자동 적용)
|
||||
├─ scripts/
|
||||
│ └─ apply-schema.js # 수동 스키마 적용 스크립트 (npm run db:schema)
|
||||
│ ├─ apply-schema.js # 수동 스키마 적용 (npm run db:schema)
|
||||
│ ├─ pg-backup.sh # PostgreSQL 논리 백업 (cron·npm run db:backup)
|
||||
│ ├─ pg-restore.sh # 백업 복원 (npm run db:restore -- …)
|
||||
│ └─ lib/load-env.sh # 셸 스크립트용 .env 로더
|
||||
├─ public/
|
||||
│ └─ styles.css # 전역 스타일
|
||||
│ └─ styles.css # 전역 스타일(회의록 마크다운 뷰 `.mm-minutes-rendered` 표 셀 테두리 등)
|
||||
├─ views/
|
||||
│ ├─ partials/
|
||||
│ │ ├─ nav.ejs # 좌측 공통 네비게이션(하단 관리자·토큰 모달 연동)
|
||||
│ │ └─ admin-token-modal.ejs # 관리자 토큰 입력 모달(`openAdminTokenModal`)
|
||||
│ ├─ learning-viewer.ejs # 학습센터 뷰어 (일반 사용자)
|
||||
│ ├─ learning-admin.ejs # 학습센터 관리 (업로드·삭제·썸네일)
|
||||
│ ├─ chat.ejs # 채팅
|
||||
│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 프롬프트 카드·검색)
|
||||
│ ├─ chat.ejs # OpenAI 기반 인앱 채팅 UI
|
||||
│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 회의록 등 카드·검색; 프롬프트는 좌측 메뉴)
|
||||
│ ├─ dashboard.ejs # 대시보드 목록(카드·검색)
|
||||
│ ├─ dashboard-business-performance.ejs # 경영성과(업로드 + 인라인 Chart.js 조회)
|
||||
│ ├─ mgmt_perf_embed.ejs # 경영성과 차트 단독 페이지(직접 열람·임베드용)
|
||||
│ ├─ partials/mgmt_perf_dashboard_container.ejs
|
||||
│ ├─ ai-prompts.ejs # 프롬프트 라이브러리 (카드·미리보기·복사)
|
||||
│ ├─ ai-cases.ejs # AI 성공 사례 목록(카드)
|
||||
│ ├─ ai-case-detail.ejs # AI 성공 사례 상세(마크다운 또는 PDF·페이지 이미지, 1·2·3단 보기)
|
||||
│ ├─ ai-cases-write.ejs # AI 성공 사례 관리자 등록·편집
|
||||
│ ├─ ai-cases.ejs # AI 활용 사례 목록(카드·`data/ai-success-stories.json` + PG `ai_use_case_submissions` 병합)
|
||||
│ ├─ ai-use-case-submission-detail.ejs # 일반 제출 상세(`GET /ai-cases/submit/:uuid`)
|
||||
│ ├─ ai-case-detail.ejs # AI 활용 사례 상세(마크다운 또는 PDF·페이지 이미지, 1·2·3단 보기)
|
||||
│ ├─ ai-cases-compose.ejs # AI 활용 사례 일반 글쓰기(TOAST UI WYSIWYG 한 칸, 1~4번 STAR는 편집기 안 템플릿, 제출 시 4필드로 분리·`sanitize-use-case-body` 정제)
|
||||
│ ├─ ai-cases-write.ejs # AI 활용 사례 관리자 등록·편집
|
||||
│ ├─ ax-apply.ejs # AX 과제 신청
|
||||
│ ├─ lecture-youtube.ejs # 유튜브 강의 상세 (iframe 임베드)
|
||||
│ ├─ lecture-ppt.ejs # PPT 강의 상세 (슬라이드 이미지)
|
||||
│ └─ admin-thumbnail-events.ejs # 썸네일 이벤트 로그 관리자 페이지
|
||||
├─ data/
|
||||
│ ├─ lectures.json # 강의 메타데이터 (ENABLE_POSTGRES=0 또는 DB 폴백 시)
|
||||
│ ├─ company-prompts.json # AI 프롬프트 라이브러리 템플릿 (제목·설명·본문)
|
||||
│ ├─ thumbnail-jobs.json # 썸네일 큐 스냅샷 (재시작 후 복구용)
|
||||
│ └─ thumbnail-events.json # 썸네일 작업 이벤트 로그
|
||||
├─ config/
|
||||
│ ├─ company-prompts.json # AI 프롬프트 라이브러리 템플릿 (Git 추적)
|
||||
│ └─ mgmt-perf-default-payload.json # 경영성과 대시보드 기본 차트 시드 (Git 추적)
|
||||
├─ data/ # 런타임 JSON·업로드 메타 (Git 제외, `.gitkeep`만 추적)
|
||||
│ ├─ lectures.json # 강의 DB (PG 미사용 시)
|
||||
│ ├─ ai-success-stories.json / ai-success-stories/
|
||||
│ ├─ thumbnail-jobs.json # 썸네일 큐 스냅샷
|
||||
│ └─ thumbnail-events.json # 썸네일 이벤트 로그
|
||||
├─ uploads/ # 업로드된 .pptx 파일 저장
|
||||
│ └─ thumbnails/ # 생성된 썸네일 이미지
|
||||
└─ resources/
|
||||
@@ -130,6 +147,7 @@ ai_platform/
|
||||
|------|------|
|
||||
| **서버** | `server.js`가 Express 앱, 라우트, Multer 업로드, 썸네일 백그라운드 워커, PostgreSQL 연동을 모두 담당 |
|
||||
| **데이터 저장소** | `ENABLE_POSTGRES=1`이면 PostgreSQL `lectures` 테이블이 단일 소스, `0`이면 `data/lectures.json` 사용 |
|
||||
| **Git과 데이터** | `data/`, `public/resources/ai-success/`, `public/files/`는 **.gitignore**로 제외(런타임·업로드 전용). `config/`의 `company-prompts.json`·`mgmt-perf-default-payload.json`만 기본 템플릿으로 추적 |
|
||||
| **썸네일** | 비동기 큐 처리, `data/thumbnail-jobs.json`으로 영속화, `data/thumbnail-events.json`에 이벤트 기록 |
|
||||
| **뷰** | EJS 템플릿으로 메인/유튜브/PPT/관리자 페이지 렌더링 |
|
||||
|
||||
@@ -235,8 +253,8 @@ PPT 썸네일·슬라이드 이미지는 **macOS**에서는 `qlmanage`가 우선
|
||||
```
|
||||
|
||||
6. **방화벽·리버스 프록시**
|
||||
- 외부에 직접 `8030`을 열지 않고 **Nginx/Apache**로 TLS·프록시하는 구성이 일반적입니다.
|
||||
- **Apache2** 예시(모듈·VirtualHost)는 [docs/DEPLOYMENT-xavis.ncue.net.md](docs/DEPLOYMENT-xavis.ncue.net.md)를 참고하세요.
|
||||
- 외부에 직접 `8030`을 열지 않고 **Apache2**(또는 동급 리버스 프록시)로 TLS·역방향 프록시하는 구성이 일반적입니다.
|
||||
- **Apache2** 예시(모듈·VirtualHost·`LimitRequestBody`·`ProxyTimeout`)는 [docs/DEPLOYMENT-xavis.ncue.net.md](docs/DEPLOYMENT-xavis.ncue.net.md) 및 `deploy/apache-ai.ncue.net-ssl.conf.example`를 참고하세요.
|
||||
- `ufw` 사용 시: `sudo ufw allow 80,443/tcp` 후 앱은 로컬에서만 듣게 하거나, 프록시 뒤에 둡니다.
|
||||
|
||||
7. **데이터 디렉터리 권한**
|
||||
@@ -248,7 +266,7 @@ PPT 썸네일·슬라이드 이미지는 **macOS**에서는 `qlmanage`가 우선
|
||||
|
||||
- 터미널 또는 `pm2 logs ai_platform`에서 **에러 없이** `Server started` 로그 확인
|
||||
- 브라우저로 메인·`/learning`·관리자(토큰)까지 동작 확인
|
||||
- PostgreSQL 사용 시 `npm run db:schema`는 **최초 1회**(또는 스키마 변경 시); 백업·마이그레이션 정책은 운영 환경에 맞게 별도 수립
|
||||
- PostgreSQL 사용 시 `npm run db:schema`는 **최초 1회**(또는 스키마 변경 시); **일일 DB 백업**은 아래 「PostgreSQL 백업 및 복원」의 cron 절을 따릅니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -394,7 +412,7 @@ Windows에서는 작업 관리자에서 `Node.js` 프로세스를 종료하거
|
||||
ADMIN_TOKEN=my-secret PAGE_SIZE=12 npm start
|
||||
```
|
||||
|
||||
- `ADMIN_TOKEN` 미지정 시 기본값: `xavis-admin`
|
||||
- `ADMIN_TOKEN` 미지정 시 기본값: `ncue-admin`
|
||||
- `PAGE_SIZE` 미지정 시 기본값: `8`
|
||||
- `.env` 파일이 있으면 `dotenv`로 자동 로드
|
||||
- 브라우저에서는 좌측 메뉴 하단 **관리자** → 모달에 위 토큰을 입력해 세션 쿠키를 발급받는 방식으로 `/admin`에 진입할 수 있습니다(임직원 이메일 로그인 여부와 동일한 흐름).
|
||||
@@ -406,16 +424,256 @@ ADMIN_TOKEN=my-secret PAGE_SIZE=12 npm start
|
||||
- DB 연결 실패 시 자동으로 `data/lectures.json` 기반 파일 저장소로 폴백합니다.
|
||||
- 필수 변수: `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`
|
||||
|
||||
### 채팅 기능 (OpenAI API)
|
||||
---
|
||||
|
||||
- `.env`에 `OPENAI_API_KEY`를 넣은 뒤 **서버를 재시작**하면 채팅 메뉴(`/chat`)에서 OpenAI와 실제 대화가 이루어집니다.
|
||||
- API 키 발급: [OpenAI Platform](https://platform.openai.com/api-keys)
|
||||
- 키 값 앞뒤 공백은 자동으로 제거합니다.
|
||||
- 화면의 모델 선택(`gpt-5.4`, `gpt-5-mini`)은 내부적으로 OpenAI에 전달할 **실제 모델 ID**로 매핑됩니다. 기본값은 각각 `gpt-4o`, `gpt-4o-mini`이며, 필요하면 `.env`에서 `OPENAI_MODEL_DEFAULT`, `OPENAI_MODEL_MINI`로 바꿀 수 있습니다.
|
||||
- **웹 검색(기본 활성):** `OPENAI_WEB_SEARCH`가 `0`이 아니면 OpenAI **Responses API**와 내장 **`web_search`** 도구로 질의에 맞게 웹을 검색한 뒤 답변합니다. 끄려면 `OPENAI_WEB_SEARCH=0`으로 두면 이전과 같이 **Chat Completions**만 사용합니다. 선택적으로 `OPENAI_WEB_SEARCH_COUNTRY`(기본 `KR`), `OPENAI_WEB_SEARCH_CITY`, `OPENAI_WEB_SEARCH_REGION`, `OPENAI_WEB_SEARCH_TIMEZONE`(기본 `Asia/Seoul`)로 검색 맥락을 줄 수 있습니다.
|
||||
- `GET /api/chat/config`는 `{ configured, webSearch }`를 반환합니다(`webSearch`는 위 플래그와 동일).
|
||||
- 스트리밍(`POST /api/chat/stream`)은 SSE로 `data: {JSON}` 줄을 보냅니다. 타입 예: `delta`(본문 조각), `status`(예: `phase: "web_search"` — UI에서 «웹 검색 중…» 표시), `sources`(인용 URL·제목 목록), `done`, `error`. 비스트리밍 `POST /api/chat`은 필요 시 응답 JSON에 `sources` 배열을 포함할 수 있습니다.
|
||||
- 키가 비어 있으면 `POST /api/chat`·`POST /api/chat/stream`은 503을 반환하고, 채팅 화면 상단에 안내 배너가 표시됩니다.
|
||||
## PostgreSQL 백업 및 복원
|
||||
|
||||
운영 서버에서 **매일 cron**으로 PostgreSQL **서버의 연결 가능 DB 전체**를 백업하고, 장애·실수 삭제 시 복원하는 절차입니다.
|
||||
기본(`PG_BACKUP_SCOPE=all`)은 템플릿 DB를 제외한 **모든 데이터베이스**를 DB마다 `{dbname}.dump` 파일로 저장합니다. 역할(계정)은 `PG_BACKUP_GLOBALS=1`과 슈퍼유저 비밀번호가 있으면 `00_globals.sql`로 함께 백업합니다.
|
||||
|
||||
### 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 백업 범위(기본) | PostgreSQL 서버의 **모든 DB** (`template0`/`template1` 제외) |
|
||||
| 백업 방식 | DB별 `pg_dump -Fc` + (선택) `pg_dumpall --globals-only` |
|
||||
| 스크립트 | `scripts/pg-backup.sh`, `scripts/pg-restore.sh` |
|
||||
| npm | `npm run db:backup`, `npm run db:restore -- <옵션> <파일 또는 디렉터리>` |
|
||||
| 설정 | 프로젝트 루트 `.env`의 `DB_*`, `PG_BACKUP_*` |
|
||||
| 저장 위치(기본) | `/home/xavis/workspace/backup/ai_platform/YYYYMMDD/` |
|
||||
| 최신 심볼릭 링크 | `/home/xavis/workspace/backup/ai_platform/latest` → 가장 최근 백업 디렉터리 |
|
||||
|
||||
백업 파일에는 **각 DB의 테이블·데이터·인덱스·시퀀스**와(옵션) **역할·권한**이 포함됩니다. Git·공개 저장소에 올리지 마세요.
|
||||
|
||||
### 사전 요구사항
|
||||
|
||||
1. **PostgreSQL 클라이언트 도구** (`pg_dump`, `pg_restore`, `psql`)가 서버 PATH에 있어야 합니다.
|
||||
- Ubuntu/Debian: `sudo apt install -y postgresql-client`
|
||||
- macOS: `brew install libpq` 후 PATH에 `pg_dump` 추가
|
||||
2. **`.env`**에 `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`가 앱과 동일하게 설정되어 있어야 합니다.
|
||||
3. **전체 DB 백업**을 위해서는 `.env`에 `PG_BACKUP_SUPERUSER_PASSWORD`(postgres 등 슈퍼유저)를 설정하는 것을 권장합니다. 없으면 `DB_USERNAME`이 접근 가능한 DB만 백업됩니다.
|
||||
4. **디스크 여유**: (모든 DB 크기 합) × 보관 일수(30) × 1.2 이상 권장.
|
||||
|
||||
### 환경 변수 (`.env`)
|
||||
|
||||
| 변수 | 필수 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `DB_HOST` | O | — | PostgreSQL 호스트 |
|
||||
| `DB_PORT` | — | `5432` | 포트 |
|
||||
| `DB_DATABASE` | O | — | 앱 DB 이름(복원·단일 백업 시 참고). `PG_BACKUP_SCOPE=single`일 때만 백업 대상 |
|
||||
| `DB_USERNAME` | O | — | 앱 DB 계정 |
|
||||
| `DB_PASSWORD` | O | — | 앱 DB 비밀번호 |
|
||||
| `PG_BACKUP_DIR` | — | `/home/xavis/workspace/backup/ai_platform` | 백업 루트 디렉터리 |
|
||||
| `PG_BACKUP_RETENTION_DAYS` | — | `30` | 이 일수보다 **오래된 날짜 폴더**(YYYYMMDD) 삭제. `0`이면 삭제 안 함 |
|
||||
| `PG_BACKUP_SCOPE` | — | `all` | `all`=서버의 모든 DB, `single`=`DB_DATABASE`만 |
|
||||
| `PG_BACKUP_GLOBALS` | — | `1` | `1`이면 역할(계정)·권한 SQL(`00_globals.sql`)도 함께 백업 |
|
||||
| `PG_BACKUP_SUPERUSER` | — | `postgres` | globals 백업·전체 DB dump 시 사용 |
|
||||
| `PG_BACKUP_SUPERUSER_PASSWORD` | 전체 백업 권장 | — | 슈퍼유저 비밀번호 |
|
||||
|
||||
**운영 서버 `.env` 예시 (백업 관련만):**
|
||||
|
||||
```env
|
||||
PG_BACKUP_DIR=/home/xavis/workspace/backup/ai_platform
|
||||
PG_BACKUP_RETENTION_DAYS=30
|
||||
PG_BACKUP_SCOPE=all
|
||||
PG_BACKUP_GLOBALS=1
|
||||
PG_BACKUP_SUPERUSER=postgres
|
||||
PG_BACKUP_SUPERUSER_PASSWORD=실제_슈퍼유저_비밀번호
|
||||
```
|
||||
|
||||
`PG_BACKUP_SCOPE=single`로 바꾸면 예전처럼 `DB_DATABASE` 하나만 백업합니다.
|
||||
|
||||
### 수동 백업
|
||||
|
||||
프로젝트 루트에서:
|
||||
|
||||
```bash
|
||||
npm run db:backup
|
||||
# 또는
|
||||
bash scripts/pg-backup.sh
|
||||
```
|
||||
|
||||
성공 시 예시 출력:
|
||||
|
||||
```text
|
||||
[2026-05-25T02:00:01+09:00] pg-backup start → /home/xavis/workspace/backup/ai_platform/20260525 (scope=all, retention 30 days)
|
||||
[2026-05-25T02:00:02+09:00] globals saved: .../20260525/00_globals.sql
|
||||
[2026-05-25T02:00:03+09:00] dumping database: ai_web_platform
|
||||
[2026-05-25T02:00:15+09:00] dump saved: .../20260525/ai_web_platform.dump (12345678 bytes)
|
||||
[2026-05-25T02:00:16+09:00] dumping database: postgres
|
||||
[2026-05-25T02:00:17+09:00] dump saved: .../20260525/postgres.dump (456789 bytes)
|
||||
[2026-05-25T02:00:17+09:00] retention: pruned 1 dir(s); keeping backups from 20260425 onward (30 days)
|
||||
[2026-05-25T02:00:17+09:00] pg-backup done: 2 database(s), latest → .../latest
|
||||
```
|
||||
|
||||
생성 파일:
|
||||
|
||||
```text
|
||||
/home/xavis/workspace/backup/ai_platform/
|
||||
├─ 20260525/
|
||||
│ ├─ 00_globals.sql # 역할·권한 (PG_BACKUP_GLOBALS=1)
|
||||
│ ├─ 00_manifest.txt # 백업 DB 목록
|
||||
│ ├─ ai_web_platform.dump
|
||||
│ ├─ postgres.dump
|
||||
│ └─ … # 서버의 다른 DB마다 *.dump
|
||||
└─ latest -> 20260525/ # 심볼릭 링크 (최대 30일치 폴더 유지)
|
||||
```
|
||||
|
||||
### 매일 cron 등록 (운영 서버)
|
||||
|
||||
1. 백업 디렉터리 생성 및 권한:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /home/xavis/workspace/backup/ai_platform
|
||||
sudo chown "$(whoami):$(whoami)" /home/xavis/workspace/backup/ai_platform
|
||||
# PM2/앱과 같은 사용자로 cron을 돌릴 경우 그 사용자로 chown
|
||||
```
|
||||
|
||||
2. `.env`에 `PG_BACKUP_DIR=/home/xavis/workspace/backup/ai_platform` 설정.
|
||||
|
||||
3. crontab 편집:
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
4. **매일 새벽 2시** (프로젝트 경로는 배포 위치에 맞게 수정):
|
||||
|
||||
```cron
|
||||
0 2 * * * cd /home/xavis/workspace/ai_platform && /usr/bin/bash scripts/pg-backup.sh >> /var/log/pg-backup.log 2>&1
|
||||
```
|
||||
|
||||
5. 로그 로테이션(선택): `/var/log/pg-backup.log`가 커지지 않도록 `logrotate` 또는 주기적 truncate.
|
||||
|
||||
**cron 점검**
|
||||
|
||||
```bash
|
||||
# 다음 실행 예약 확인
|
||||
crontab -l
|
||||
|
||||
# 수동 1회 실행 후 로그 확인
|
||||
cd /home/xavis/workspace/ai_platform && bash scripts/pg-backup.sh
|
||||
tail -20 /var/log/pg-backup.log
|
||||
|
||||
# 최신 dump 목록 확인
|
||||
ls -lh /home/xavis/workspace/backup/ai_platform/latest/
|
||||
cat /home/xavis/workspace/backup/ai_platform/latest/00_manifest.txt
|
||||
```
|
||||
|
||||
### 복원
|
||||
|
||||
`.dump` 파일은 **custom format** 전용입니다. plain SQL(`.sql`)이 아니면 `psql -f`가 아니라 `pg_restore`를 사용합니다.
|
||||
|
||||
#### 1) 검증용 복원 — DB 하나
|
||||
|
||||
```bash
|
||||
npm run db:restore -- --test --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
|
||||
```
|
||||
|
||||
#### 1-2) 검증용 복원 — 서버 DB 전체
|
||||
|
||||
```bash
|
||||
npm run db:restore -- --all --globals --test --confirm /home/xavis/workspace/backup/ai_platform/latest/
|
||||
```
|
||||
|
||||
각 DB는 `{dbname}_restore_test` 이름으로 복원됩니다(예: `ai_web_platform_restore_test`).
|
||||
|
||||
확인:
|
||||
|
||||
```bash
|
||||
psql -h "$DB_HOST" -U "$DB_USERNAME" -d ai_web_platform_restore_test -c "\dt"
|
||||
psql -h "$DB_HOST" -U "$DB_USERNAME" -d ai_web_platform_restore_test \
|
||||
-c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
검증 후 테스트 DB 삭제:
|
||||
|
||||
```bash
|
||||
psql -h "$DB_HOST" -U postgres -c 'DROP DATABASE ai_web_platform_restore_test;'
|
||||
```
|
||||
|
||||
(`DROP`은 슈퍼유저 또는 DB owner 권한 필요)
|
||||
|
||||
#### 2) 운영 DB 복원 — DB 하나 (주의)
|
||||
|
||||
```bash
|
||||
pm2 stop webplatform # 앱 이름에 맞게
|
||||
|
||||
bash scripts/pg-restore.sh --clean --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
|
||||
|
||||
pm2 start webplatform
|
||||
```
|
||||
|
||||
#### 2-2) 운영 DB 복원 — 서버 DB 전체 (주의)
|
||||
|
||||
```bash
|
||||
pm2 stop webplatform
|
||||
|
||||
bash scripts/pg-restore.sh --all --globals --clean --confirm /home/xavis/workspace/backup/ai_platform/latest/
|
||||
|
||||
pm2 start webplatform
|
||||
```
|
||||
|
||||
- `--clean`: 기존 객체를 삭제한 뒤 dump 내용으로 재생성 (`--if-exists` 포함).
|
||||
- 운영 DB에 `--clean` 없이 복원하면 중복 객체 오류가 날 수 있습니다.
|
||||
- `--confirm` 없이 실행하면 대화형 확인 프롬프트가 뜹니다.
|
||||
|
||||
역할(계정)까지 함께 백업해 둔 경우(`00_globals.sql`):
|
||||
|
||||
```bash
|
||||
bash scripts/pg-restore.sh --globals --clean --confirm /home/xavis/workspace/backup/ai_platform/.../ai_web_platform.dump
|
||||
```
|
||||
|
||||
`.env`에 `PG_BACKUP_SUPERUSER`, `PG_BACKUP_SUPERUSER_PASSWORD` 필요.
|
||||
|
||||
#### 3) 완전히 새 DB로 복원 (드물게)
|
||||
|
||||
DB 자체를 drop/create한 뒤 복원할 때는 DBA·슈퍼유저로:
|
||||
|
||||
```bash
|
||||
psql -h "$DB_HOST" -U postgres -c "DROP DATABASE IF EXISTS ai_web_platform;"
|
||||
psql -h "$DB_HOST" -U postgres -c "CREATE DATABASE ai_web_platform OWNER xavis;"
|
||||
bash scripts/pg-restore.sh --confirm /home/xavis/workspace/backup/ai_platform/.../ai_web_platform.dump
|
||||
```
|
||||
|
||||
### 백업·복원 체크리스트 (월 1회 권장)
|
||||
|
||||
| Check | 방법 |
|
||||
|-------|------|
|
||||
| cron 정상 실행 | `/var/log/pg-backup.log`에 전일 `pg-backup done` 기록 |
|
||||
| dump 파일 크기 | `00_manifest.txt`·각 `.dump`가 0 byte가 아닌지 확인 |
|
||||
| 복원 테스트 | `--all --test` 또는 단일 `--test`로 `_restore_test` DB 확인 |
|
||||
| 디스크 | `df -h $PG_BACKUP_DIR` |
|
||||
| 보관 정책 | `PG_BACKUP_RETENTION_DAYS`에 맞게 오래된 폴더 삭제되는지 |
|
||||
|
||||
### 보안
|
||||
|
||||
- dump 파일은 **개인정보·업무 데이터 전체**를 담습니다. 권한 `600`/`700` 디렉터리, Git·Slack·메일 첨부 금지.
|
||||
- 가능하면 백업 저장소를 **앱 서버와 분리**(NAS, 객체 스토리지)하고 주기적으로 오프사이트 복사.
|
||||
- DB는 공인 IP 직접 노출 대신 **방화벽·VPN·SSH 터널** 사용(별도 보안 가이드 참고).
|
||||
|
||||
### 문제 해결
|
||||
|
||||
| 증상 | 조치 |
|
||||
|------|------|
|
||||
| `pg_dump: command not found` | `postgresql-client` 설치 |
|
||||
| `password authentication failed` | `.env`의 `DB_USERNAME`/`DB_PASSWORD` 확인 |
|
||||
| `could not connect to server` | `DB_HOST`·방화벽·PostgreSQL `listen_addresses` |
|
||||
| cron은 도는데 파일 없음 | cron의 `cd` 경로, `.env` 존재, 로그 파일 stderr 확인 |
|
||||
| `pg_restore: error: role "xxx" does not exist` | `--no-owner --role=DB_USERNAME`(스크립트 기본) 또는 `--globals`로 역할 먼저 복원 |
|
||||
| 복원 후 앱 오류 | `pm2 restart`, `npm run db:schema`는 **복원 dump에 스키마 포함** 시 보통 불필요 |
|
||||
|
||||
### `data/` JSON과의 관계
|
||||
|
||||
`ENABLE_POSTGRES=1`이면 **강의·회의록·경영성과 등 핵심 데이터는 PostgreSQL**이 단일 소스입니다.
|
||||
`data/ai-success-stories.json`, 업로드 파일(`uploads/`, `public/resources/`) 등 **파일 기반 데이터는 DB 백업에 포함되지 않습니다**. 운영 백업 정책에 파일 디렉터리 rsync/tar 백업을 **별도** 두는 것을 권장합니다.
|
||||
|
||||
---
|
||||
|
||||
- 인앱 채팅 화면은 `/ai-explore/chat`에서 제공하며, 백엔드는 `POST /api/chat/stream` SSE로 응답을 생성합니다.
|
||||
- 기본 제공 모델 별칭은 `gpt-5.4`, `gpt-5-mini`이며, 실제 API 모델 ID는 `.env`의 `OPENAI_MODEL_DEFAULT`, `OPENAI_MODEL_MINI`로 매핑합니다.
|
||||
- `OPENAI_WEB_SEARCH=1`(기본)일 때 OpenAI Responses API의 웹 검색 도구를 사용해 출처 링크를 함께 내려줍니다.
|
||||
- 필수 환경 변수는 `OPENAI_API_KEY`입니다.
|
||||
|
||||
### PPT 썸네일 및 슬라이드 이미지 생성 제어(선택)
|
||||
|
||||
@@ -445,7 +703,7 @@ ENABLE_PPT_THUMBNAIL=1 npm start
|
||||
```env
|
||||
PORT=8030
|
||||
HOST=0.0.0.0
|
||||
ADMIN_TOKEN=xavis-admin
|
||||
ADMIN_TOKEN=ncue-admin
|
||||
PAGE_SIZE=8
|
||||
ENABLE_POSTGRES=1
|
||||
DB_HOST=your-db-host
|
||||
@@ -466,6 +724,8 @@ THUMBNAIL_EVENT_PAGE_SIZE=50
|
||||
OPENAI_API_KEY=
|
||||
# OPENAI_MODEL_DEFAULT=gpt-4o
|
||||
# OPENAI_MODEL_MINI=gpt-4o-mini
|
||||
# OPS 이메일(@ncue.net) 로그인 세션: `OPS_SESSION_TTL_DAYS` 미설정·0·never = 만기 없음(이전 기본 15일). N(양의 정수)이면 로그인일+N일(서울 달력) 후 만료.
|
||||
# OPS_SESSION_TTL_DAYS=0
|
||||
```
|
||||
|
||||
---
|
||||
@@ -511,18 +771,38 @@ OPENAI_API_KEY=
|
||||
| `GET /` | 학습센터 뷰어 (강의 목록) |
|
||||
| `GET /learning` | 학습센터 뷰어 (검색/필터/페이지네이션) |
|
||||
| `GET /admin` | 관리자 대시보드 (강의·썸네일·AI 등) |
|
||||
| `GET /chat` | 채팅 |
|
||||
| `GET /ai-explore` | AI 탐색 (프롬프트·회의록 등 카드, 전체 너비 레이아웃) |
|
||||
| `GET /ai-explore/prompts` | 프롬프트 라이브러리 (업무 시나리오별 기본 프롬프트) |
|
||||
| `GET /ai-cases` | AI 성공 사례 목록(검색·태그 필터, 카드) |
|
||||
| `GET /ai-cases/:slug` | AI 성공 사례 상세 |
|
||||
| `GET /ai-cases/write` | AI 성공 사례 관리(관리자 토큰 필요) |
|
||||
| `GET /chat` | 회사규정 NotebookLM 페이지로 리다이렉트 |
|
||||
| `GET /wm` | WM NotebookLM 페이지로 리다이렉트 |
|
||||
| `GET /ai-explore/chat` | OpenAI 연동 채팅 화면 |
|
||||
| `GET /ai-explore/fscan` | AI 플랫폼 내 FSCAN 조사각 선정도우미 화면(내재화 iframe) |
|
||||
| `GET /ai-explore` | AI 탐색 (회의록·체크리스트 등 카드; 프롬프트 라이브러리는 `/ai-explore/prompts` 또는 좌측 **프롬프트** 메뉴) |
|
||||
| `GET /ai-explore/prompts` | 프롬프트 라이브러리 — 공식 템플릿(`config/company-prompts.json`)+팀 공유·좋아요(PostgreSQL `prompt_community_entries`·`prompt_likes`) · `?id=` 딥링크 |
|
||||
| `GET /ai-cases` | AI 활용 사례 목록(검색·태그 필터) — **관리자 등록**(`ai-success-stories` 메타) + **일반 제출**(`ai_use_case_submissions`) 병합 |
|
||||
| `GET /ai-cases/submit/:id` | 일반 제출 사례 상세(UUID) — **본인·관리자**에게 상단 **수정하기** 링크(`GET /ai-cases/compose?edit=<uuid>`) |
|
||||
| `GET /ai-cases/:slug` | AI 활용 사례 상세(메타·슬러그) |
|
||||
| `GET /ai-cases/write` | AI 활용 사례 관리(관리자 토큰 필요) |
|
||||
| `POST/PUT/DELETE /api/ai-success-stories` | 사례 CRUD(관리자) · 본문은 `data/ai-success-stories/*.md` |
|
||||
| `GET /ai-cases/compose?edit=:uuid` | 일반 제출 **수정** 화면(제목·4섹션·태그·첨부·썸네일) — 권한 없으면 403 |
|
||||
| `POST /api/ai-use-case-submissions` | AI 활용 사례 **일반 제출(글쓰기)** — OPS 이메일 로그인·PostgreSQL, `ai_use_case_submissions` — 본문 4필드는 WYSIWYG HTML이며 `sanitize-html`로 안전한 태그만 저장, 글자 수는 **보이는 텍스트(태그 제외)** 합산 |
|
||||
| `PUT /api/ai-use-case-submissions/:id` | 일반 제출 **갱신**(제출자 이메일과 로그인 이메일 일치 또는 관리자 모드). **썸네일**은 새 파일이면 교체, `removeThumbnail=1`이면 삭제(새 파일·삭제 둘 다 없으면 유지). **첨부**는 기존을 `removeAttachmentPaths`(JSON 경로 배열)로 뺀 뒤, 새로 업로드한 파일이 뒤에 **추가**(합계 최대 10개) |
|
||||
| `GET /ax-apply` | AX 과제 신청 |
|
||||
| `GET /lectures/:id` | 강의 상세 뷰어 (유튜브/PPT) |
|
||||
|
||||
**AI 활용 사례 목록이 줄어든 경우:** 카드 목록은 **`data/ai-success-stories.json`**만 본다. `git pull` 등으로 JSON만 예전 버전이 되면 `.md`·PDF는 서버에 남아 있어도 **1건만 보일 수 있다.** 서버에서 `data/ai-success-stories/*.md`는 있는데 JSON에 없으면 `node scripts/merge-orphan-ai-success-stories.js`로 고아 `.md`를 메타에 다시 붙인 뒤, 관리자 화면에서 **부서·저자·PDF 경로** 등을 점검한다. JSON·본문·`public/resources/ai-success/` PDF는 **배포 서버에서 백업**하거나 저장소에 포함하는 정책을 권장한다.
|
||||
|
||||
### API
|
||||
|
||||
- `POST /api/prompts/likes/toggle` (OPS·DEV 이메일)
|
||||
JSON `{ "kind": "official" | "community", "id": "<json id or uuid>" }` — 좋아요 토글. `{ ok, liked, likeCount }`
|
||||
|
||||
- `POST /api/prompts/community` (OPS·DEV 이메일)
|
||||
`multipart/form-data` — `title`, `body`, `description?`, `tag?`, `promptFiles`(0~5), `resultFiles`(0~5) · **작성자**는 요청한 OPS 로그인 이메일이 `prompt_community_entries.author_email`에 저장됨 · DB `prompt_attachments` / `result_sample_attachments` JSON · 파일당 20MB → `{ ok, id, createdAt, promptFiles, resultFiles }` · 라이브러리 카드에는 이메일의 `@` 앞부분(예: `spark_ai@ncue.net` → `spark_ai`)이 표시됨
|
||||
|
||||
- `DELETE /api/prompts/community/:id` (OPS·DEV 이메일) — 본인 글만 소프트 삭제
|
||||
|
||||
- `POST /api/prompts/polish-workflow` (OPS·DEV 이메일)
|
||||
JSON `{ draft }` — `OPENAI_API_KEY`로 워크플로 초안을 다듬은 프롬프트 문자열 `{ text }` 반환
|
||||
|
||||
- `GET /api/chat/config`
|
||||
채팅용 OpenAI 키 설정 여부 `{ configured: boolean }` (키 문자열은 노출하지 않음)
|
||||
|
||||
@@ -563,8 +843,12 @@ OPENAI_API_KEY=
|
||||
|
||||
## 최근 업데이트 (요약)
|
||||
|
||||
- **AI 탐색** (`/ai-explore`): 메인 콘텐츠를 뷰포트 전체 너비로 사용, **프롬프트** 서비스 카드를 첫 번째에 배치, 검색어에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동. **좌측 전체 메뉴는 관리자 여부와 관계없이 동일하게 표시·접근 가능**
|
||||
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 시나리오 카드·미리보기·복사 UI, 템플릿 데이터는 `data/company-prompts.json`에서 로드
|
||||
- **PostgreSQL 백업·복원**: `scripts/pg-backup.sh` / `scripts/pg-restore.sh`, `npm run db:backup` · README 「PostgreSQL 백업 및 복원」에 cron·복원 절차 문서화
|
||||
- **채팅** (`/ai-explore/chat`): OpenAI SSE 기반 인앱 채팅으로 응답/출처를 표시
|
||||
- **AI 탐색 필터** (`/ai-explore`): 검색창 아래 타입 라디오 필터(일반/XScan/FScan)로 카드 목록 필터링
|
||||
- **FSCAN 도구 내재화**: `public/resources/fscan/fscan-selector-v1.html` 정적 페이지를 `/ai-explore/fscan`에서 iframe으로 감싸 AI 플랫폼 레이아웃(좌측 메뉴/뒤로 이동) 안에서 사용
|
||||
- **AI 탐색** (`/ai-explore`): 메인 콘텐츠를 뷰포트 전체 너비로 사용(회의록·체크리스트 등). **프롬프트 라이브러리**는 AI 목록이 아닌 좌측 **프롬프트** 메뉴(`/ai-explore/prompts`)로 진입. **좌측 전체 메뉴는 관리자 여부와 관계없이 동일하게 표시·접근 가능**
|
||||
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 시나리오 카드·미리보기·복사 UI, 템플릿 데이터는 `config/company-prompts.json`에서 로드
|
||||
- **서버 수신 주소**: 기본 `HOST=0.0.0.0`(로컬 `localhost` 접속에 사용). 필요 시 `HOST=127.0.0.1`로 제한 가능
|
||||
- **문서**: `localhost` 접속 불가 시 확인할 항목을 아래 「접속이 안 될 때 (트러블슈팅)」 절에 정리
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "story-1774520941288",
|
||||
"slug": "jojung-sook-hr-claude-cowork",
|
||||
"title": "인사총무팀 AI 적용 사례",
|
||||
"excerpt": "인사총무팀 조정숙 과장의 AI 업무 혁신 이야기. Claude Cowork 도입, 법인세 인사자료 8시간→4시간, 데이터 오류율 감소.",
|
||||
"author": "조정숙",
|
||||
"department": "인사총무팀",
|
||||
"publishedAt": "2026-03-25",
|
||||
"tags": [
|
||||
"인사총무",
|
||||
"Claude Cowork",
|
||||
"법인세",
|
||||
"OT",
|
||||
"엑셀"
|
||||
],
|
||||
"contentFile": "jojung-sook-hr-claude-cowork.md",
|
||||
"pdfUrl": "/public/resources/ai-success/1774520936441-c8e17615-dad1-4a08-9a26-f6303b666058.pdf",
|
||||
"createdAt": "2026-03-26T10:29:01.288Z",
|
||||
"updatedAt": "2026-03-26T10:29:01.288Z"
|
||||
}
|
||||
]
|
||||
@@ -1,86 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "2371e4d4-ac7b-4ae1-bde2-1f9ff67e9d15",
|
||||
"department": "테스트부서",
|
||||
"name": "홍길동",
|
||||
"employeeId": "",
|
||||
"position": "",
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"workProcessDescription": "테스트",
|
||||
"painPoint": "테스트",
|
||||
"currentTimeSpent": "30분",
|
||||
"errorRateBefore": "5%",
|
||||
"collaborationDepts": "",
|
||||
"reasonToSolve": "테스트",
|
||||
"aiExpectation": "테스트",
|
||||
"outputType": "테스트",
|
||||
"automationLevel": "",
|
||||
"dataReadiness": "",
|
||||
"dataLocation": "",
|
||||
"personalInfo": "",
|
||||
"dataQuality": "",
|
||||
"dataCount": "",
|
||||
"dataTypes": null,
|
||||
"timeReduction": "",
|
||||
"errorReduction": "",
|
||||
"volumeIncrease": "",
|
||||
"costReduction": "",
|
||||
"responseTime": "",
|
||||
"otherMetrics": "",
|
||||
"annualSavings": "",
|
||||
"laborReplacement": "",
|
||||
"revenueIncrease": "",
|
||||
"otherEffects": "",
|
||||
"qualitativeEffects": null,
|
||||
"techStack": null,
|
||||
"risks": null,
|
||||
"riskDetail": "",
|
||||
"participationPledge": true,
|
||||
"status": "신청",
|
||||
"createdAt": "2026-03-18T05:11:13.793Z",
|
||||
"updatedAt": "2026-03-18T05:11:13.793Z"
|
||||
},
|
||||
{
|
||||
"id": "a9d59801-1f84-4c0d-ad7e-e8db28fcd003",
|
||||
"department": "테스트부서",
|
||||
"name": "홍길동",
|
||||
"employeeId": "",
|
||||
"position": "",
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"workProcessDescription": "테스트",
|
||||
"painPoint": "테스트",
|
||||
"currentTimeSpent": "30분",
|
||||
"errorRateBefore": "5%",
|
||||
"collaborationDepts": "",
|
||||
"reasonToSolve": "테스트",
|
||||
"aiExpectation": "테스트",
|
||||
"outputType": "테스트",
|
||||
"automationLevel": "",
|
||||
"dataReadiness": "",
|
||||
"dataLocation": "",
|
||||
"personalInfo": "",
|
||||
"dataQuality": "",
|
||||
"dataCount": "",
|
||||
"dataTypes": null,
|
||||
"timeReduction": "",
|
||||
"errorReduction": "",
|
||||
"volumeIncrease": "",
|
||||
"costReduction": "",
|
||||
"responseTime": "",
|
||||
"otherMetrics": "",
|
||||
"annualSavings": "",
|
||||
"laborReplacement": "",
|
||||
"revenueIncrease": "",
|
||||
"otherEffects": "",
|
||||
"qualitativeEffects": null,
|
||||
"techStack": null,
|
||||
"risks": null,
|
||||
"riskDetail": "",
|
||||
"participationPledge": true,
|
||||
"status": "신청",
|
||||
"createdAt": "2026-03-18T05:05:04.467Z",
|
||||
"updatedAt": "2026-03-18T05:05:04.467Z"
|
||||
}
|
||||
]
|
||||
@@ -1,255 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>학습센터</title>
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="left-nav">
|
||||
<div class="logo">DW</div>
|
||||
<a href="#" class="nav-item">채팅</a>
|
||||
<a href="#" class="nav-item">AI</a>
|
||||
<a href="/" class="nav-item active">학습센터</a>
|
||||
<a href="#" class="nav-item">과제신청</a>
|
||||
<a href="#" class="nav-item">성공사례</a>
|
||||
</aside>
|
||||
|
||||
<div class="content-area">
|
||||
<header class="topbar">
|
||||
<h1>학습센터</h1>
|
||||
<a class="top-action-link" href="#register">강의 등록하기</a>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="hero panel">
|
||||
<h2>최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.</h2>
|
||||
<p>유튜브 링크 또는 PPT를 등록한 뒤, 목록에서 클릭하여 강의를 시청할 수 있습니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel filter-panel">
|
||||
<h2>강의 검색/필터</h2>
|
||||
<form action="/" method="get" class="filter-grid">
|
||||
<label>
|
||||
검색어
|
||||
<input type="text" name="q" value="" placeholder="제목" />
|
||||
</label>
|
||||
<label>
|
||||
타입
|
||||
<select name="type">
|
||||
<option value="all" selected>전체</option>
|
||||
<option value="youtube" >YouTube</option>
|
||||
<option value="ppt" >PPT</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
태그
|
||||
<select name="tag">
|
||||
<option value="">전체</option>
|
||||
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<input type="hidden" name="admin" value="1" />
|
||||
<input type="hidden" name="token" value="test-token" />
|
||||
|
||||
<div class="filter-actions">
|
||||
<button type="submit">필터 적용</button>
|
||||
<a class="link-muted" href="/">초기화</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel admin-panel">
|
||||
<h2>관리자 모드</h2>
|
||||
|
||||
|
||||
<p class="admin-ok">관리자 모드 활성화됨</p>
|
||||
<div class="queue-status">
|
||||
<span>큐: <b>1</b></span>
|
||||
<span>워커: <b>작동중</b></span>
|
||||
<span>실패 재시도 최대: <b>2</b></span>
|
||||
</div>
|
||||
<div class="queue-status">
|
||||
<span>PPT 썸네일 - 준비완료 <b>0</b></span>
|
||||
<span>처리중 <b>0</b></span>
|
||||
<span>대기 <b>0</b></span>
|
||||
<span>실패 <b>2</b></span>
|
||||
</div>
|
||||
|
||||
<form action="/" method="get" class="admin-inline">
|
||||
<input type="hidden" name="q" value="" />
|
||||
<input type="hidden" name="type" value="all" />
|
||||
<input type="hidden" name="tag" value="" />
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="hidden" name="admin" value="1" />
|
||||
<input type="password" name="token" placeholder="관리자 토큰" />
|
||||
<button type="submit">관리자 활성화</button>
|
||||
</form>
|
||||
|
||||
<form action="/thumbnails/retry-failed" method="post" class="admin-inline">
|
||||
<input type="hidden" name="token" value="test-token" />
|
||||
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||
<button type="submit" class="ghost">실패 썸네일 일괄 재시도</button>
|
||||
</form>
|
||||
|
||||
</section>
|
||||
|
||||
<section id="register" class="panel">
|
||||
<h2>유튜브 강의 등록</h2>
|
||||
<form action="/lectures/youtube" method="post" class="form-grid">
|
||||
<label>
|
||||
제목
|
||||
<input type="text" name="title" required />
|
||||
</label>
|
||||
<label>
|
||||
유튜브 링크
|
||||
<input type="url" name="youtubeUrl" placeholder="https://www.youtube.com/watch?v=..." required />
|
||||
</label>
|
||||
<label class="full">
|
||||
설명
|
||||
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
|
||||
</label>
|
||||
<label class="full">
|
||||
태그 (쉼표 구분)
|
||||
<input type="text" name="tags" placeholder="예: AI에이전트, 바이브코딩" />
|
||||
</label>
|
||||
<button type="submit">유튜브 강의 등록</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>PowerPoint 강의 등록</h2>
|
||||
<form action="/lectures/ppt" method="post" enctype="multipart/form-data" class="form-grid">
|
||||
<label>
|
||||
제목
|
||||
<input type="text" name="title" required />
|
||||
</label>
|
||||
<label>
|
||||
PPT 파일(.pptx)
|
||||
<input type="file" name="pptFile" accept=".pptx" required />
|
||||
</label>
|
||||
<label class="full">
|
||||
설명
|
||||
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
|
||||
</label>
|
||||
<label class="full">
|
||||
태그 (쉼표 구분)
|
||||
<input type="text" name="tags" placeholder="예: 프롬프트, 생성형AI" />
|
||||
</label>
|
||||
<button type="submit">강의 등록</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>등록된 강의</h2>
|
||||
<span class="count-chip">총 2건</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="lecture-grid">
|
||||
|
||||
<article class="lecture-card">
|
||||
<a class="lecture-link" href="/lectures/51ee515c-fff0-4156-97ac-09ec0b412345">
|
||||
<div class="thumb ppt">
|
||||
|
||||
|
||||
<span class="thumb-fallback">썸네일 failed</span>
|
||||
|
||||
<span class="thumb-kicker">PPT 프리뷰</span>
|
||||
<strong>제목 없음</strong>
|
||||
<small>1장</small>
|
||||
|
||||
</div>
|
||||
<div class="badge ppt">
|
||||
PPT
|
||||
</div>
|
||||
<h3>생성형AI, LLM, 에이전틱 AI 이해하기</h3>
|
||||
<p>초기 샘플 PPT 강의</p>
|
||||
<div class="tag-row">
|
||||
|
||||
</div>
|
||||
<small>2026. 3. 14. AM 11:23:13</small>
|
||||
</a>
|
||||
|
||||
<form action="/lectures/51ee515c-fff0-4156-97ac-09ec0b412345/delete" method="post" class="delete-form">
|
||||
<input type="hidden" name="token" value="test-token" />
|
||||
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||
<button type="submit" class="danger">삭제</button>
|
||||
</form>
|
||||
|
||||
<div class="thumb-state-row">
|
||||
<span class="state-chip failed">
|
||||
failed
|
||||
</span>
|
||||
|
||||
<small class="error-text">썸네일 생성 도구 실행 실패 또는 미설치</small>
|
||||
|
||||
</div>
|
||||
<form action="/lectures/51ee515c-fff0-4156-97ac-09ec0b412345/thumbnail/regenerate" method="post" class="delete-form">
|
||||
<input type="hidden" name="token" value="test-token" />
|
||||
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||
<button type="submit" class="ghost">썸네일 재생성</button>
|
||||
</form>
|
||||
|
||||
|
||||
</article>
|
||||
|
||||
<article class="lecture-card">
|
||||
<a class="lecture-link" href="/lectures/4a937c1e-98cb-402f-abd4-497120212974">
|
||||
<div class="thumb ppt">
|
||||
|
||||
|
||||
<span class="thumb-fallback">썸네일 failed</span>
|
||||
|
||||
<span class="thumb-kicker">PPT 프리뷰</span>
|
||||
<strong>제목 없음</strong>
|
||||
<small>12장</small>
|
||||
|
||||
</div>
|
||||
<div class="badge ppt">
|
||||
PPT
|
||||
</div>
|
||||
<h3>claude cowork 가이드</h3>
|
||||
<p>초기 샘플 PPT 강의</p>
|
||||
<div class="tag-row">
|
||||
|
||||
</div>
|
||||
<small>2026. 3. 14. AM 11:23:13</small>
|
||||
</a>
|
||||
|
||||
<form action="/lectures/4a937c1e-98cb-402f-abd4-497120212974/delete" method="post" class="delete-form">
|
||||
<input type="hidden" name="token" value="test-token" />
|
||||
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||
<button type="submit" class="danger">삭제</button>
|
||||
</form>
|
||||
|
||||
<div class="thumb-state-row">
|
||||
<span class="state-chip failed">
|
||||
failed
|
||||
</span>
|
||||
|
||||
<small class="error-text">썸네일 생성 도구 실행 실패 또는 미설치</small>
|
||||
|
||||
</div>
|
||||
<form action="/lectures/4a937c1e-98cb-402f-abd4-497120212974/thumbnail/regenerate" method="post" class="delete-form">
|
||||
<input type="hidden" name="token" value="test-token" />
|
||||
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||
<button type="submit" class="ghost">썸네일 재생성</button>
|
||||
</form>
|
||||
|
||||
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,36 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "4a937c1e-98cb-402f-abd4-497120212974",
|
||||
"type": "ppt",
|
||||
"title": "claude cowork 가이드",
|
||||
"description": "초기 샘플 PPT 강의",
|
||||
"fileName": "1773454993461-578d68bd-9486-4ee2-bcea-46d038571a2e.pptx",
|
||||
"originalName": "claude cowork 가이드.pptx",
|
||||
"createdAt": "2026-03-14T02:23:13.463Z",
|
||||
"tags": [],
|
||||
"previewTitle": "제목 없음",
|
||||
"slideCount": 12,
|
||||
"thumbnailUrl": null,
|
||||
"thumbnailStatus": "failed",
|
||||
"thumbnailRetryCount": 3,
|
||||
"thumbnailError": "썸네일 생성 도구 실행 실패 또는 미설치",
|
||||
"thumbnailUpdatedAt": "2026-03-14T02:55:55.126Z"
|
||||
},
|
||||
{
|
||||
"id": "51ee515c-fff0-4156-97ac-09ec0b412345",
|
||||
"type": "ppt",
|
||||
"title": "생성형AI, LLM, 에이전틱 AI 이해하기",
|
||||
"description": "초기 샘플 PPT 강의",
|
||||
"fileName": "1773454993463-3f6f09f5-0d48-4f0f-9a15-2b61adce1ba9.pptx",
|
||||
"originalName": "생성형AI, LLM, 에이전틱 AI 이해하기.pptx",
|
||||
"createdAt": "2026-03-14T02:23:13.466Z",
|
||||
"tags": [],
|
||||
"previewTitle": "제목 없음",
|
||||
"slideCount": 1,
|
||||
"thumbnailUrl": null,
|
||||
"thumbnailStatus": "failed",
|
||||
"thumbnailRetryCount": 3,
|
||||
"thumbnailError": "썸네일 생성 도구 실행 실패 또는 미설치",
|
||||
"thumbnailUpdatedAt": "2026-03-14T02:55:55.141Z"
|
||||
}
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
# 임직원 성명 (한 줄에 한 이름, 또는 한 줄에 쉼표로 여러 이름)
|
||||
# 스프레드시트에서 붙여 넣어도 됩니다. # 으로 시작하는 줄은 무시됩니다.
|
||||
# 회의록 생성 시 전사에 등장한 토큰만 이 목록과 대조해「표기 통일」블록이 LLM에 전달됩니다.
|
||||
|
||||
김창열
|
||||
이소은
|
||||
현아
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
134
db/schema.sql
@@ -106,11 +106,11 @@ BEFORE UPDATE ON ax_assignments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_ax_assignments_updated_at();
|
||||
|
||||
-- OPS 이메일(@xavis.co.kr) 매직 링크 인증 — 이벤트 감사 로그
|
||||
-- OPS 이메일(@ncue.net) 매직 링크 인증 — 이벤트 감사 로그
|
||||
CREATE TABLE IF NOT EXISTS ops_email_auth_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(320) NOT NULL,
|
||||
event_type VARCHAR(40) NOT NULL CHECK (event_type IN ('magic_link_requested', 'login_success', 'logout')),
|
||||
event_type VARCHAR(40) NOT NULL CHECK (event_type IN ('magic_link_requested', 'login_success', 'logout', 'sessions_revoked')),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
return_to TEXT,
|
||||
@@ -126,11 +126,18 @@ CREATE TABLE IF NOT EXISTS ops_email_users (
|
||||
email VARCHAR(320) PRIMARY KEY,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
login_count INTEGER NOT NULL DEFAULT 0
|
||||
login_count INTEGER NOT NULL DEFAULT 0,
|
||||
sessions_revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ops_email_users_last_login ON ops_email_users (last_login_at DESC);
|
||||
|
||||
ALTER TABLE ops_email_users ADD COLUMN IF NOT EXISTS sessions_revoked_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE ops_email_auth_events DROP CONSTRAINT IF EXISTS ops_email_auth_events_event_type_check;
|
||||
ALTER TABLE ops_email_auth_events ADD CONSTRAINT ops_email_auth_events_event_type_check
|
||||
CHECK (event_type IN ('magic_link_requested', 'login_success', 'logout', 'sessions_revoked'));
|
||||
|
||||
-- 회의록 AI: 이메일(OPS 세션) 기반 사용자·프롬프트·회의 저장
|
||||
CREATE TABLE IF NOT EXISTS meeting_ai_users (
|
||||
email VARCHAR(320) PRIMARY KEY,
|
||||
@@ -295,3 +302,124 @@ CREATE TABLE IF NOT EXISTS mgmt_perf_snapshots (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mgmt_perf_snapshots_upload ON mgmt_perf_snapshots (upload_id);
|
||||
|
||||
-- AI 활용 사례: 일반 사용자 제출(글쓰기) — 본문 4섹션·썸네일·첨부, 제출자 이메일·시각
|
||||
CREATE TABLE IF NOT EXISTS ai_use_case_submissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submitter_email VARCHAR(320) NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
situation TEXT NOT NULL DEFAULT '',
|
||||
task_goal TEXT NOT NULL DEFAULT '',
|
||||
action_taken TEXT NOT NULL DEFAULT '',
|
||||
result_outcome TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
thumbnail_relative_path TEXT,
|
||||
thumbnail_files JSONB NOT NULL DEFAULT '[]',
|
||||
attachment_files JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_use_case_submissions_created ON ai_use_case_submissions (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_use_case_submissions_submitter ON ai_use_case_submissions (submitter_email, created_at DESC);
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_ai_use_case_submissions_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_ai_use_case_submissions_updated_at ON ai_use_case_submissions;
|
||||
CREATE TRIGGER trg_ai_use_case_submissions_updated_at
|
||||
BEFORE UPDATE ON ai_use_case_submissions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_ai_use_case_submissions_updated_at();
|
||||
|
||||
-- 썸네일 다중 업로드(최대 5): 기존 DB 마이그레이션
|
||||
ALTER TABLE ai_use_case_submissions
|
||||
ADD COLUMN IF NOT EXISTS thumbnail_files JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
UPDATE ai_use_case_submissions
|
||||
SET thumbnail_files = jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'originalName', COALESCE(NULLIF(regexp_replace(thumbnail_relative_path, '^.+/', ''), ''), 'thumbnail'),
|
||||
'relativePath', thumbnail_relative_path
|
||||
)
|
||||
)
|
||||
WHERE thumbnail_relative_path IS NOT NULL
|
||||
AND btrim(thumbnail_relative_path) <> ''
|
||||
AND (thumbnail_files IS NULL OR thumbnail_files = '[]'::jsonb);
|
||||
|
||||
-- AI 활용 사례 제출: 페이지뷰 조회수(제출자 본인 로그인 조회 제외) · 좋아요
|
||||
ALTER TABLE ai_use_case_submissions
|
||||
ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- (legacy) 초기 unique 조회 추적용 — 현재 집계는 view_count 페이지뷰만 사용
|
||||
CREATE TABLE IF NOT EXISTS ai_use_case_views (
|
||||
submission_id UUID NOT NULL REFERENCES ai_use_case_submissions(id) ON DELETE CASCADE,
|
||||
viewer_email VARCHAR(320) NOT NULL,
|
||||
viewed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (submission_id, viewer_email)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_use_case_views_submission ON ai_use_case_views (submission_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_use_case_submission_likes (
|
||||
submission_id UUID NOT NULL REFERENCES ai_use_case_submissions(id) ON DELETE CASCADE,
|
||||
user_email VARCHAR(320) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (submission_id, user_email)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_use_case_submission_likes_submission
|
||||
ON ai_use_case_submission_likes (submission_id);
|
||||
|
||||
-- 프롬프트 라이브러리: 공식 템플릿(company-prompts.json id) + 커뮤니티 공유 글에 대한 좋아요
|
||||
CREATE TABLE IF NOT EXISTS prompt_likes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_email VARCHAR(320) NOT NULL,
|
||||
target_kind VARCHAR(20) NOT NULL CHECK (target_kind IN ('official', 'community')),
|
||||
target_id VARCHAR(200) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_prompt_likes_user_target UNIQUE (user_email, target_kind, target_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_prompt_likes_target ON prompt_likes (target_kind, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_prompt_likes_user ON prompt_likes (user_email, created_at DESC);
|
||||
|
||||
-- 임직원이 공유한 프롬프트(라이브러리 커뮤니티) — author_email: POST 시 세션(OPS) 이메일, UI는 @ 앞 로컬부로 표시
|
||||
CREATE TABLE IF NOT EXISTS prompt_community_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
author_email VARCHAR(320) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL,
|
||||
tag VARCHAR(100) NOT NULL DEFAULT '기타',
|
||||
is_published BOOLEAN NOT NULL DEFAULT true,
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_prompt_community_author ON prompt_community_entries (author_email, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_prompt_community_created ON prompt_community_entries (created_at DESC) WHERE is_deleted = false AND is_published = true;
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_prompt_community_entries_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_prompt_community_entries_updated_at ON prompt_community_entries;
|
||||
CREATE TRIGGER trg_prompt_community_entries_updated_at
|
||||
BEFORE UPDATE ON prompt_community_entries
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_prompt_community_entries_updated_at();
|
||||
|
||||
-- 팀 공유 프롬프트: 프롬프트 관련·결과(샘플) 첨부(JSON 배열, 경로는 /uploads/…)
|
||||
ALTER TABLE prompt_community_entries ADD COLUMN IF NOT EXISTS prompt_attachments JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE prompt_community_entries ADD COLUMN IF NOT EXISTS result_sample_attachments JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
# =============================================================================
|
||||
# ai.ncue.net — Apache 2.4+ SSL → Node webplatform (단일 백엔드)
|
||||
# =============================================================================
|
||||
# 필요 모듈: ssl, proxy, proxy_http, headers
|
||||
# sudo a2enmod ssl proxy proxy_http headers
|
||||
#
|
||||
# Node listen 주소·포트는 .env 의 HOST / PORT 와 맞출 것 (기본 127.0.0.1:8030).
|
||||
# 아래 127.0.0.1:8030 은 예시이며, 실제 프로세스 포트로 바꾸면 됩니다.
|
||||
#
|
||||
# 왜 경로별 ProxyPass 를 나누지 않나?
|
||||
# - 이 저장소의 server.js 가 /chat, /learning, /admin, /ai-explore 등 모든 경로를 한 프로세스에서 처리합니다.
|
||||
# - 동일 포트(8030)로 여러 줄 나누면 유지보수만 불필요하게 복잡해지고, 8018/8030 혼선만 생깁니다.
|
||||
# - 한 줄 ProxyPass / 로 충분합니다.
|
||||
#
|
||||
# Apache 2.4.31+ : ProxyAddHeaders On 이 X-Forwarded-* 를 백엔드에 넘깁니다(클라이언트 IP 추적 등).
|
||||
# 그보다 오래된 Apache 면 ProxyAddHeaders 줄을 제거하고, 필요 시 X-Forwarded-For 수동 설정을 검토하세요.
|
||||
# =============================================================================
|
||||
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost *:443>
|
||||
ServerName ai.ncue.net
|
||||
ServerName ai.xavis.co.kr
|
||||
|
||||
ProxyPreserveHost On
|
||||
|
||||
# 대용량 업로드(회의 음성 등, 앱 MEETING_AUDIO_MAX_MB 와 맞출 것)
|
||||
LimitRequestBody 314572800
|
||||
|
||||
# 전사·LLM 등 장시간 응답. 회의록 음성 SSE는 수 분 이상 걸릴 수 있음.
|
||||
TimeOut 600
|
||||
ProxyTimeout 600
|
||||
|
||||
# 앱 응답의 X-Accel-Buffering: no 는 Nginx 전용. Apache mod_proxy_http 는 무시하므로,
|
||||
# 스트림이 끊기면 TimeOut/ProxyTimeout 과 VirtualHost 레벨 mod_deflate(전역 압축 시 text/event-stream) 여부를 점검.
|
||||
|
||||
# HTTPS 프록시임을 Node/앱에 알림 (쿠키 Secure, 리다이렉트 URL 등)
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
|
||||
@@ -36,14 +22,15 @@
|
||||
ProxyAddHeaders On
|
||||
</IfModule>
|
||||
|
||||
# 나머지 모든 요청 — Node.js Express (매직링크 인증 포함)
|
||||
ProxyPass / http://127.0.0.1:8030/
|
||||
ProxyPassReverse / http://127.0.0.1:8030/
|
||||
|
||||
SSLEngine on
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/ai.ncue.net_ssl_error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/ai.ncue.net_ssl_access.log combined
|
||||
ErrorLog ${APACHE_LOG_DIR}/ai.xavis.co.kr_ssl_error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/ai.xavis.co.kr_ssl_access.log combined
|
||||
|
||||
<Location />
|
||||
<RequireAll>
|
||||
@@ -52,7 +39,7 @@
|
||||
</RequireAll>
|
||||
</Location>
|
||||
|
||||
SSLCertificateFile /etc/letsencrypt/live/ncue.net-0001/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ncue.net-0001/privkey.pem
|
||||
SSLCertificateFile /etc/letsencrypt/live/ai.xavis.co.kr/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ai.xavis.co.kr/privkey.pem
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
|
||||
@@ -44,7 +44,18 @@ cd /var/www/webplatform
|
||||
npm install --production
|
||||
```
|
||||
|
||||
### 2.4 환경 변수 설정
|
||||
### 2.4 코드 갱신(`git pull`) 직후 (503·앱 기동 실패 시)
|
||||
|
||||
`package.json`에 새 npm 패키지가 추가되면, **pull만 하고 `npm install`을 하지 않은 상태로 PM2(또는 node)를 재시작**하면 `Cannot find module`로 프로세스가 바로 종료됩니다. Apache는 업스트림이 없다고 **503 Service Unavailable**을 반환할 수 있습니다.
|
||||
|
||||
```bash
|
||||
cd /var/www/webplatform # 실제 배포 경로
|
||||
git pull
|
||||
npm install --production
|
||||
pm2 restart webplatform # PM2로 실행 중인 앱 이름에 맞게
|
||||
```
|
||||
|
||||
### 2.5 환경 변수 설정
|
||||
|
||||
```bash
|
||||
# .env 파일이 없다면 .env.example 복사
|
||||
@@ -237,3 +248,11 @@ sudo ufw reload
|
||||
### 썸네일/슬라이드 이미지 미생성
|
||||
- LibreOffice, poppler-utils 설치 여부 확인
|
||||
- PM2 로그 확인: `pm2 logs webplatform`
|
||||
|
||||
### 업로드 413 Request Entity Too Large (Apache)
|
||||
- `VirtualHost`에 **`LimitRequestBody`**가 작게 잡혀 있지 않은지 확인합니다. 회의 음성·엑셀 등은 바이트 단위로 크게 필요합니다 (`deploy/apache-ai.ncue.net-ssl.conf.example`의 예시 참고).
|
||||
|
||||
### 회의록 음성(SSE)·진행 막대가 한동안 안 움직임
|
||||
- 노드 처리 전체가 길므로 같은 VirtualHost에서 **`TimeOut`** / **`ProxyTimeout`** 이 충분한지 확인합니다.
|
||||
- 브라우저로 직접 `http://127.0.0.1:8030`(또는 앱 바인 포트)에 붙였을 때는 정상인데 Apache 경유만 이상하면, 프록시 앞단 **`mod_deflate`** 등 출력 필터가 `text/event-stream` 응답을 버퍼링하지 않는지 점검합니다.
|
||||
- 앱이 붙이는 **`X-Accel-Buffering: no`** 는 **Nginx 전용**이며, **Apache에서는 동작하지 않습니다**(무시됨).
|
||||
|
||||
BIN
docs/XAVIS-AI-Platform-메뉴안내-일반사용자.pptx
Normal file
424
docs/ai-platform-menu-guide-for-ppt.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# XAVIS AI Platform — 메뉴별 PPT 안내 자료
|
||||
|
||||
> **용도**: https://ai.xavis.co.kr/ 정식 오픈 교육·안내 PPT 제작용
|
||||
> **PPT 파일**: [`XAVIS-AI-Platform-메뉴안내.pptx`](./XAVIS-AI-Platform-메뉴안내.pptx) (화면 캡처 포함)
|
||||
> **캡처 원본**: [`ppt-screenshots/`](./ppt-screenshots/)
|
||||
> **재생성**: `OPS_STATE=PROD npm start` 실행 후 `node scripts/capture-menu-screenshots.mjs` → `python scripts/build-menu-guide-ppt.py`
|
||||
> **구성**: 메뉴 1개 = 슬라이드 1~2장 권장 (제목 슬라이드 + 상세 슬라이드)
|
||||
> **접속**: `@ncue.net` 이메일 매직 링크 인증 후 이용
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 0. 표지 / 서비스 개요
|
||||
|
||||
**제목**: XAVIS AI Platform 메뉴 안내
|
||||
|
||||
**한 줄 소개**
|
||||
- 사내 AI 도구·학습·과제·활용 사례를 한곳에서 이용하는 임직원 전용 포털
|
||||
|
||||
**접속**
|
||||
- URL: https://ai.xavis.co.kr/
|
||||
- 최초 1회: 회사 메일 입력 → 인증 메일 링크 클릭 (15분 유효)
|
||||
|
||||
**좌측 메뉴 구성**
|
||||
| 순서 | 메뉴 | 한 줄 설명 |
|
||||
|------|------|------------|
|
||||
| 1 | 회사규정 | 사내 규정·지침 AI 검색(NotebookLM) |
|
||||
| 2 | WM | WM 관련 자료 AI 검색(NotebookLM) |
|
||||
| 3 | AI | 회의록·채팅·FScan 등 AI 서비스 허브 |
|
||||
| 4 | 프롬프트 | 업무용 프롬프트 템플릿·팀 공유 |
|
||||
| 5 | 학습센터 | YouTube·PPT·동영상 강의 시청 |
|
||||
| 6 | 과제신청 | AX(AI Transformation) 과제 신청 |
|
||||
| 7 | AI 활용 사례 | 현장 AI 도입 사례 열람·작성 |
|
||||
| 8 | 대시보드 | 허용 계정 전용 경영 지표(선택) |
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 1. 서비스 접속 (로그인)
|
||||
|
||||
**경로**: `/login` (미인증 시 자동 이동)
|
||||
|
||||
**화면 설명**
|
||||
- XAVIS·AI Platform 로고와 「서비스 접속」 카드형 로그인 화면
|
||||
- `@ncue.net` 이메일만 허용
|
||||
|
||||
**이용 방법**
|
||||
1. 회사 이메일 입력
|
||||
2. **[검증]** 클릭 → 메일함 확인
|
||||
3. **[인증 완료하기]** 링크 클릭 → 학습센터 등 원하는 화면으로 이동
|
||||
|
||||
**활용 팁**
|
||||
- 인증 링크가 만료되면 같은 화면에서 다시 [검증]을 누르면 됩니다.
|
||||
- 다른 PC·브라우저에서는 다시 인증이 필요할 수 있습니다.
|
||||
|
||||
**유의사항**
|
||||
- 개인 메일·외부 도메인은 사용할 수 없습니다.
|
||||
- 로그아웃: 좌측 하단 **로그아웃**
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 2. 회사규정
|
||||
|
||||
**경로**: 좌측 **회사규정** → Google NotebookLM (새 탭)
|
||||
|
||||
**화면 설명**
|
||||
- AI Platform 레이아웃을 벗어나 **NotebookLM** 회사규정 노트북으로 이동
|
||||
- 업로드된 사내 규정·지침 자료를 AI가 검색·요약·질의응답
|
||||
|
||||
**주요 기능**
|
||||
- 규정 문구 검색, 요약, 관련 조항 질문
|
||||
- 회의·업무 전 규정 확인용 Q&A
|
||||
|
||||
**활용 팁**
|
||||
- 「○○ 절차가 뭐야?」「휴가 신청 규정 알려줘」처럼 **구체적 질문**이 정확도가 높습니다.
|
||||
- AI 답변은 **원문 규정·다우오피스 공식 문서**와 반드시 대조하세요.
|
||||
|
||||
**대안**
|
||||
- NotebookLM 접속이 어려우면 다우오피스 전자결재·규정 게시판을 우선 확인
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 3. WM
|
||||
|
||||
**경로**: 좌측 **WM** → Google NotebookLM WM 노트북 (새 탭)
|
||||
|
||||
**화면 설명**
|
||||
- WM(Work Management 등 사내 WM 체계) 관련 자료를 AI로 검색·질의
|
||||
|
||||
**주요 기능**
|
||||
- WM 프로세스·용어·가이드 질의
|
||||
- 업무 방법·템플릿 관련 Q&A
|
||||
|
||||
**활용 팁**
|
||||
- WM 업무를 처음 맡았을 때 용어·절차를 빠르게 파악하는 용도로 적합합니다.
|
||||
|
||||
**유의사항**
|
||||
- 회사규정과 동일하게, AI 답변은 공식 매뉴얼·담당 부서 확인을 병행하세요.
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 4. AI (메인 허브)
|
||||
|
||||
**경로**: `/ai-explore`
|
||||
|
||||
**화면 설명**
|
||||
- 「지식 시험이나 지식 보강… 맞춤형 AI를 탐색」 안내 문구
|
||||
- 검색창 + 타입 필터(전체 / 일반 / XScan / FScan)
|
||||
- AI 서비스 **카드 그리드** — 클릭하면 각 도구로 이동
|
||||
|
||||
**포함 서비스 (카드)**
|
||||
| 카드 | 이동 경로 | 분류 |
|
||||
|------|-----------|------|
|
||||
| 회의록 AI | `/ai-explore/meeting-minutes` | 일반 |
|
||||
| (회의록 기반) 업무 체크리스트 AI | `/ai-explore/task-checklist` | 일반 |
|
||||
| 일반 채팅 | `/ai-explore/chat` | 일반 |
|
||||
| FSCAN 조사각 선정도우미 | `/ai-explore/fscan` | FScan |
|
||||
|
||||
**활용 팁**
|
||||
- 검색창에 「회의」「체크」「채팅」「FSCAN」 등 키워드로 카드 필터링
|
||||
- AI 관련 업무는 **이 화면을 시작점**으로 두면 메뉴를 빠르게 찾을 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 5. 회의록 AI
|
||||
|
||||
**경로**: AI → **회의록 AI** (`/ai-explore/meeting-minutes`)
|
||||
|
||||
**화면 구성**
|
||||
- **좌측**: 내 회의록 목록(저장·불러오기)
|
||||
- **상단(접기 가능)**: 출력 형식(프롬프트) — 추가 지시 입력·저장
|
||||
- **탭 ① 텍스트 입력**: 회의 원문 붙여넣기 → 회의록 생성
|
||||
- **탭 ② 음성 파일**: mp3·m4a·wav 등 업로드 → 전사 → 회의록 생성
|
||||
- **하단**: 생성 결과(회의록·전사 기록) 편집·저장
|
||||
|
||||
**음성 처리 3단계**
|
||||
1. **업로드** — 최대 300MB
|
||||
2. **전사** — OpenAI 음성→텍스트 (gpt-4o-transcribe 등)
|
||||
3. **번역(정리)** — 전사문을 사내 회의록 형식으로 LLM 작성
|
||||
|
||||
**텍스트 입력 흐름**
|
||||
1. 제목·날짜 입력
|
||||
2. 회의 원문(메모·채팅 로그·타 도구 전사문) 붙여넣기
|
||||
3. 모델 선택(gpt-5-mini / gpt-5.4) → **회의록 생성**
|
||||
4. 결과 수정 후 **저장** → 업무 체크리스트 AI와 연동
|
||||
|
||||
**회의록 기본 구조(시스템 프롬프트)**
|
||||
- 회의 개요, 참석·언급 인원, 논의 요약, 결정 사항, 액션 아이템(표), 후속 체크리스트 등
|
||||
|
||||
**활용 팁 · 대안 (실무 시나리오)**
|
||||
|
||||
| 상황 | 추천 |
|
||||
|------|------|
|
||||
| 회의 **음성 파일**이 있을 때 | **AI Platform 회의록 AI** 음성 탭 — 사내 계정·저장·체크리스트 연동 |
|
||||
| Claude·ChatGPT에 음성 올려 회의록 작성 | 개인 구독·용량에 따라 가능. **저장·팀 공유·체크리스트 연동**은 AI Platform이 유리 |
|
||||
| 전사만 필요할 때 | 회의록 AI 음성 탭 사용, 또는 **클로버노트(Clova Note)** 등 전사 앱 → 전사문을 **텍스트 입력** 탭에 붙여넣기 |
|
||||
| 짧은 메모·채팅 로그만 있을 때 | **텍스트 입력** 탭이 가장 빠름 |
|
||||
| 회의록 형식을 팀마다 맞추고 싶을 때 | 「출력 형식(프롬프트)」의 **추가 지시**에 팀 규칙 입력 후 저장 |
|
||||
|
||||
**유의사항**
|
||||
- 생성 결과는 반드시 **검토·수정** 후 저장·배포
|
||||
- 음성·전사에 **개인정보·대외비**가 포함되면 보안 규정 준수
|
||||
- 긴 회의는 처리에 **수 분** 걸릴 수 있음(진행 표시 확인)
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 6. (회의록 기반) 업무 체크리스트 AI
|
||||
|
||||
**경로**: AI → **업무 체크리스트 AI** (`/ai-explore/task-checklist`)
|
||||
|
||||
**화면 설명**
|
||||
- 회의록 AI에서 **저장한 회의**의 액션 아이템·체크리스트를 한 화면에서 관리
|
||||
- 진행상황(전체/진행 중/완료), 회의록별 필터, 정렬(날짜·글자·완료여부)
|
||||
|
||||
**주요 기능**
|
||||
- 회의록에서 자동 추출된 **할 일** 목록 표시
|
||||
- 항목 수정·완료 처리(처리 내용 메모)
|
||||
- 회의 제목·일자·요약 툴팁으로 출처 확인
|
||||
|
||||
**이용 방법**
|
||||
1. 먼저 **회의록 AI**에서 회의록 생성·**저장**
|
||||
2. 체크리스트 AI에서 회의록 범위 선택 → 항목 자동 반영
|
||||
3. 완료 시 체크 + 처리 내용 기록
|
||||
|
||||
**활용 팁**
|
||||
- 다우오피스·엑셀 할 일 목록과 병행할 때, **회의에서 나온 액션**은 이 화면에서 추적
|
||||
- 회의록을 수정·재저장하면 체크리스트 연동 내용이 갱신될 수 있음
|
||||
|
||||
**대안**
|
||||
- 개인 To-do는 Outlook·다우오피스 일정, Notion 등과 병행 가능
|
||||
- **회의 연계 업무**는 AI Platform 체크리스트가 출처 추적에 유리
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 7. 일반 채팅
|
||||
|
||||
**경로**: AI → **일반 채팅** (`/ai-explore/chat`)
|
||||
|
||||
**화면 설명**
|
||||
- ChatGPT 기반 **인앱 채팅** (스트리밍 응답, 마크다운 표시)
|
||||
- 「안녕하세요, 오늘 무엇을 도와드릴까요?」 웰come 화면
|
||||
|
||||
**주요 기능**
|
||||
- 업무 질의, 문서 초안, 아이디어 브레인스토밍
|
||||
- (서버 설정 시) **웹 검색** 연동으로 최신 정보 보강
|
||||
- 대화 맥락 유지(세션 내)
|
||||
|
||||
**이용 조건**
|
||||
- **회사 이메일 인증(OPS 로그인)** 후 이용
|
||||
|
||||
**활용 팁 · 대안**
|
||||
|
||||
| 용도 | 추천 |
|
||||
|------|------|
|
||||
| 빠른 업무 질의·초안 | **AI Platform 일반 채팅** — 별도 ChatGPT 구독 없이 사내 경로 |
|
||||
| Claude·Gemini 선호 | 개인/팀 구독 도구 병행. **사내 기록·규정 연동**은 Platform 채팅·프롬프트 라이브러리 |
|
||||
| 회사규정 확인 | **회사규정(NotebookLM)** 또는 채팅 + 반드시 원문 대조 |
|
||||
| 반복 업무 프롬프트 | **프롬프트** 메뉴에서 템플릿 복사 후 채팅에 붙여넣기 |
|
||||
|
||||
**유의사항**
|
||||
- 하단 안내: 「AI 답변은 실수할 수 있습니다. 중요한 정보는 원문 규정과 함께 확인」
|
||||
- 기밀·개인정보 입력 자제
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 8. FSCAN 조사각 선정도우미
|
||||
|
||||
**경로**: AI → **FSCAN 조사각 선정도우미** (`/ai-explore/fscan`)
|
||||
|
||||
**화면 설명**
|
||||
- 검사 대상물 **치수(H/W)** 입력 기반 FSCAN 시리즈 **모델 선정** 도구
|
||||
- AI Platform 레이아웃 안에 도구 화면(iframe) 임베드
|
||||
|
||||
**주요 기능**
|
||||
- H(높이)·W(폭) 등 조건 입력 → 적합 FSCAN 모델 추천
|
||||
- 영업·기술 검토 시 **빠른 1차 선정**용
|
||||
|
||||
**활용 팁**
|
||||
- 최종 스펙·계약 조건은 **공식 카탈로그·기술팀** 확인 필수
|
||||
- XScan 관련 다른 도구는 AI 허브 **타입 필터: XScan** 으로 추후 확장 카드 확인
|
||||
|
||||
**대상 사용자**
|
||||
- FSCAN 제품·검사 설계 관련 영업, 기술, PM
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 9. 프롬프트 (프롬프트 라이브러리)
|
||||
|
||||
**경로**: `/ai-explore/prompts`
|
||||
|
||||
**화면 설명**
|
||||
- 「공식 템플릿을 복사하거나, 팀이 공유한 프롬프트를 고르고…」
|
||||
- 4개 탭: **라이브러리** / **워크플로→프롬프트** / **공유하기** / **내가 올린 것**
|
||||
|
||||
**라이브러리 탭**
|
||||
- 좌측: 시나리오 카드(회의 요약, 이메일 초안, 보고서 목차, OKR, 코드 리뷰, CS 응대, JD, 리스크 메모 등)
|
||||
- 우측: 미리보기 + **클립보드에 복사**
|
||||
- 필터: 공식/팀 공유, 인기·최신, 검색
|
||||
|
||||
**워크플로 탭**
|
||||
- ①~④ 단계 입력 → 초안 합치기 → (OpenAI 연동 시) AI로 다듬기
|
||||
- 본인 업무에 맞는 **맞춤 지시문** 작성
|
||||
|
||||
**공유하기 탭**
|
||||
- 팀에 프롬프트·참고 파일 공개(로그인 필요)
|
||||
- 민감·개인정보·기밀 미포함 주의
|
||||
|
||||
**이용 방법**
|
||||
1. 카드 선택 → 미리보기 확인
|
||||
2. `[ ]` 플레이스홀더를 상황에 맞게 수정
|
||||
3. ChatGPT·Claude·회의록 AI 등 원하는 도구에 붙여넣기
|
||||
|
||||
**활용 팁**
|
||||
- 매번 같은 형식의 메일·보고·회의 요약 → **공식 템플릿**부터 시작
|
||||
- 팀에서 검증된 프롬프트 → **공유하기**로 축적
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 10. 학습센터
|
||||
|
||||
**경로**: `/learning` (루트 `/` 도 동일 목적지)
|
||||
|
||||
**화면 설명**
|
||||
- 「최신 컨텐츠로 학습하고, 바로 업무에 적용하세요」
|
||||
- 강의 **검색·필터** + **카드 목록** + 페이지 이동
|
||||
|
||||
**콘텐츠 유형**
|
||||
| 타입 | 시청 방식 |
|
||||
|------|-----------|
|
||||
| YouTube | 페이지 내 영상 재생 |
|
||||
| PPT/PDF | 슬라이드 이미지 뷰어(1·2·3단 보기) |
|
||||
| 동영상 파일 | 업로드 mp4 등 재생 |
|
||||
| 웹 링크 | 외부 URL 연결 |
|
||||
| 뉴스 URL | 뉴스 링크 |
|
||||
|
||||
**카테고리 예**
|
||||
- AX 사고 전환, AI 툴 활용, AI Agent, 바이브 코딩 등
|
||||
|
||||
**이용 방법**
|
||||
1. 검색어·타입·태그·카테고리로 필터
|
||||
2. 카드 클릭 → 상세에서 학습
|
||||
|
||||
**활용 팁**
|
||||
- Claude·ChatGPT·Cursor 등 **도구별 학습**은 검색창에 도구명 입력(예: claude, 클로드)
|
||||
- 신규 입사·AX 교육 시 **AX 사고 전환** 카테고리부터 순차 학습
|
||||
|
||||
**관리자(선택)**
|
||||
- 관리자 토큰 인증 시 **학습 등록**(`/admin`)으로 강의 추가 가능
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 11. 과제신청 (AX 과제 신청)
|
||||
|
||||
**경로**: `/ax-apply`
|
||||
|
||||
**화면 설명**
|
||||
- **신청된 과제 목록**(부서·이름·이메일로 조회)
|
||||
- **AX 과제 신청서** 온라인 작성 + Word 양식 다운로드
|
||||
|
||||
**신청서 주요 섹션**
|
||||
1. 기본 정보(부서, 이름, 연락처 등)
|
||||
2. 업무·Pain Point·AI 기대 효과
|
||||
3. 데이터·자동화 수준·보안 관련
|
||||
4. 정량·정성 기대 효과
|
||||
5. 신청서 파일 첨부(선택)
|
||||
|
||||
**이용 방법**
|
||||
1. `(신청서 다운로드)`로 Word 양식 참고
|
||||
2. 웹 폼 작성 → 제출
|
||||
3. **조회** 버튼으로 본인 신청 내역 확인·수정
|
||||
|
||||
**활용 팁**
|
||||
- 「AI로 무엇을 자동화하고 싶은지」를 **구체적 업무 프로세스**로 적을수록 검토에 유리
|
||||
- 유사 사례는 **AI 활용 사례** 메뉴에서 참고
|
||||
|
||||
**유의사항**
|
||||
- 모든 항목 **성실 작성** (화면 안내 문구)
|
||||
- 개인정보·고객 데이터 처리 계획은 보안·Compliance 관점에서 명시
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 12. AI 활용 사례
|
||||
|
||||
**경로**: `/ai-cases`
|
||||
|
||||
**화면 설명**
|
||||
- 「현장에서 검증된 AI 업무 혁신 이야기」
|
||||
- 카드 그리드 + 검색·태그 필터
|
||||
- 카드 클릭 → 상세 본문
|
||||
|
||||
**주요 기능**
|
||||
- 부서별 **도입 과정·성과** 사례 열람
|
||||
- 로그인 사용자 **글쓰기**(`/ai-cases/compose`) — STAR 형식(1.Situation ~ 4.Result)
|
||||
- 활용 AI 툴 태그(Claude, Slack 등) 입력
|
||||
|
||||
**이용 방법 (열람)**
|
||||
1. 검색·태그로 관심 사례 찾기
|
||||
2. 카드 클릭 → 상세 읽기
|
||||
|
||||
**이용 방법 (작성)**
|
||||
1. 상단 **글쓰기** → STAR 본문·썸네일·활용 AI 입력
|
||||
2. 제출 → 목록에 반영(승인·노출 정책은 운영 기준 따름)
|
||||
|
||||
**활용 팁**
|
||||
- AX 과제 기획 전 **유사 부서 사례** 검색
|
||||
- 본인 성공 사례 → 팀 공유·과제신청 근거 자료로 활용
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 13. 대시보드 (허용 계정)
|
||||
|
||||
**경로**: `/dashboard` (메뉴는 **허용 이메일**에게만 표시)
|
||||
|
||||
**화면 설명**
|
||||
- 경영·업무 지표 대시보드 **허브**(검색 + 카드)
|
||||
- 현재 카드: **경영성과 대시보드**
|
||||
|
||||
**경영성과 대시보드** (`/dashboard/business-performance`)
|
||||
- **상단**: 연도·분기 선택 → Chart.js 기반 KPI·차트 조회
|
||||
- **하단**: 매출일보 등 **엑셀(.xlsx) 업로드** → 스냅샷 저장·조회
|
||||
- (해당 기간 데이터 없으면 샘플 데이터 + 안내 문구)
|
||||
|
||||
**대상**
|
||||
- `.env`에 등록된 `DASHBOARD_MENU_ALLOWED_EMAILS` 등 **사전 허용 계정**
|
||||
|
||||
**활용 팁**
|
||||
- 일반 임직원에게는 메뉴가 보이지 않을 수 있음 — **경영·관리 목적** 전용
|
||||
- 엑셀 업로드 후 차트가 기대와 다르면 AI혁신팀·담당 부서에 문의
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 14. (부록) 관리자·로그아웃
|
||||
|
||||
**관리자 모드** (일반 사용자 참고용)
|
||||
- 좌측 하단 **관리자** → 관리자 토큰 입력
|
||||
- 강의 등록·삭제, AI 활용 사례 관리, 사용자 현황 등
|
||||
|
||||
**로그아웃**
|
||||
- 좌측 하단 **로그아웃** → 이메일 세션 종료 → 로그인 화면
|
||||
|
||||
---
|
||||
|
||||
## 슬라이드 15. (마무리) 업무별 추천 메뉴 Quick Reference
|
||||
|
||||
| 업무 | 먼저 열 메뉴 |
|
||||
|------|----------------|
|
||||
| 회의록(음성) | AI → **회의록 AI** (또는 클로버노트 전사 → 텍스트 입력) |
|
||||
| 회의록(메모만) | AI → **회의록 AI** 텍스트 입력 |
|
||||
| 할 일 추적 | AI → **업무 체크리스트 AI** |
|
||||
| 빠른 AI 질문 | AI → **일반 채팅** |
|
||||
| 메일·보고 초안 | **프롬프트** → 템플릿 복사 |
|
||||
| 사내 규정 | **회사규정**(NotebookLM) |
|
||||
| AI 학습 | **학습센터** |
|
||||
| AI 도입 과제 | **과제신청** + **AI 활용 사례** 참고 |
|
||||
| FSCAN 모델 선정 | AI → **FSCAN 조사각 선정도우미** |
|
||||
|
||||
**문의**: AI혁신팀
|
||||
|
||||
---
|
||||
|
||||
## PPT 제작 시 권장 사항
|
||||
|
||||
1. **메뉴당 2장**: ① 화면 캡처 + 한 줄 설명 ② 이용 방법·팁·대안
|
||||
2. **실제 스크린샷**: https://ai.xavis.co.kr/ 에서 로그인 후 각 경로 캡처 삽입
|
||||
3. **강조 색**: 인증(파랑), 주의(회색 박스), 대안(별도 불릿)
|
||||
4. **발표 스크립트**: 각 슬라이드 노트에 「이 메뉴는 ○○할 때 쓴다」 한 문장 추가
|
||||
BIN
docs/ppt-screenshots-user/ai-cases-compose.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
docs/ppt-screenshots-user/ai-cases.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/ppt-screenshots-user/ai-explore.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/ppt-screenshots-user/ax-apply.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/ppt-screenshots-user/chat.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
docs/ppt-screenshots-user/company-policy.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/ppt-screenshots-user/dashboard-business-performance.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/ppt-screenshots-user/dashboard.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/ppt-screenshots-user/fscan.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/ppt-screenshots-user/learning.png
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
docs/ppt-screenshots-user/login.png
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
docs/ppt-screenshots-user/meeting-minutes.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/ppt-screenshots-user/menu-overview.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/ppt-screenshots-user/prompts.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
docs/ppt-screenshots-user/task-checklist.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/ppt-screenshots-user/wm.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/ppt-screenshots/ai-cases.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/ppt-screenshots/ai-explore.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/ppt-screenshots/ax-apply.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
docs/ppt-screenshots/chat.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
docs/ppt-screenshots/company-policy.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/ppt-screenshots/dashboard-business-performance.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/ppt-screenshots/dashboard.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/ppt-screenshots/fscan.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/ppt-screenshots/learning.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/ppt-screenshots/login.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/ppt-screenshots/meeting-minutes.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/ppt-screenshots/prompts.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
docs/ppt-screenshots/task-checklist.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/ppt-screenshots/wm.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
496
lib/ai-use-case-submissions.js
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* AI 활용 사례 — 사용자 제출(글쓰기) PostgreSQL 저장
|
||||
*/
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { stripForCount } = require("./strip-for-count");
|
||||
|
||||
const PLACEHOLDERS = {
|
||||
situation:
|
||||
"고객센터에는 반복적인 문의(배송 일정, 환불 절차, 계정 비밀번호 초기화 등)가 하루 수백 건씩 접수되어 상담사의 업무가 과중되고, 응답 지연으로 고객 만족도가 낮아지고 있었습니다.",
|
||||
task:
|
||||
"반복 문의를 AI로 자동 처리하여 상담사의 업무 부담을 줄이고, 고객 응답 속도와 만족도를 높이는 것이 목표였습니다.",
|
||||
action:
|
||||
"FAQ 데이터와 기존 상담 이력을 기반으로 AI 챗봇을 구축했습니다.\n주요 질문 유형을 분류하고, 자연어 기반 답변이 가능하도록 학습시켰으며, 복잡한 문의는 상담사에게 자동 이관되도록 설계했습니다.",
|
||||
result:
|
||||
"전체 문의의 65%를 AI가 자동 처리하게 되었고, 평균 응답 시간이 10분에서 30초 이내로 단축되었습니다. 상담사들은 고난도 고객 대응에 집중할 수 있게 되었고, 고객 만족도는 20% 이상 향상되었습니다.",
|
||||
};
|
||||
|
||||
const MAX_BODY_TOTAL = 10000;
|
||||
const MAX_THUMB_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_THUMB_COUNT = 5;
|
||||
const MAX_ATTACH_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_ATTACH_COUNT = 1;
|
||||
|
||||
/**
|
||||
* @param {object} f
|
||||
* @returns {{ originalName: string, relativePath: string, size?: number } | null}
|
||||
*/
|
||||
function normalizeThumbFileEntry(f) {
|
||||
if (!f || typeof f !== "object") return null;
|
||||
const rp = String(f.relativePath || f.relative_path || "").trim();
|
||||
if (!rp) return null;
|
||||
const orig =
|
||||
String(f.originalName || f.original_name || rp.split("/").pop() || "thumbnail").trim() ||
|
||||
"thumbnail";
|
||||
const out = { originalName: orig, relativePath: rp };
|
||||
if (typeof f.size === "number" && f.size >= 0) out.size = f.size;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* DB row → 썸네일 파일 목록(legacy 단일 경로 포함)
|
||||
* @param {object} row
|
||||
* @returns {Array<{ originalName: string, relativePath: string, size?: number }>}
|
||||
*/
|
||||
function parseThumbnailFiles(row) {
|
||||
let files = [];
|
||||
try {
|
||||
const raw = row && row.thumbnail_files;
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (Array.isArray(parsed)) {
|
||||
files = parsed.map(normalizeThumbFileEntry).filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
if (!files.length && row && row.thumbnail_relative_path) {
|
||||
const rp = String(row.thumbnail_relative_path).trim();
|
||||
if (rp) {
|
||||
files = [
|
||||
{
|
||||
originalName: rp.split("/").pop() || "thumbnail",
|
||||
relativePath: rp,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ relativePath?: string }>} files
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function primaryThumbnailPath(files) {
|
||||
if (!Array.isArray(files) || !files.length) return null;
|
||||
const rp = String(files[0].relativePath || "").trim();
|
||||
return rp || null;
|
||||
}
|
||||
|
||||
function countBodyTotal(row) {
|
||||
return (
|
||||
stripForCount(row.situation || "") +
|
||||
stripForCount(row.task_goal || "") +
|
||||
stripForCount(row.action_taken || "") +
|
||||
stripForCount(row.result_outcome || "")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pgPool
|
||||
* @param {object} p
|
||||
* @param {string} [p.id] - 업로드 디렉터리명과 동일한 UUID(미입력 시 생성)
|
||||
* @param {string} p.submitterEmail
|
||||
* @param {string} p.title
|
||||
* @param {string} p.situation
|
||||
* @param {string} p.taskGoal
|
||||
* @param {string} p.actionTaken
|
||||
* @param {string} p.resultOutcome
|
||||
* @param {string[]} p.tags
|
||||
* @param {string | null} p.thumbnailRelativePath - 목록 카드용 대표(첫 번째) 경로
|
||||
* @param {Array<{ originalName: string, relativePath: string, size?: number }>} p.thumbnailFiles
|
||||
* @param {Array<{ originalName: string, relativePath: string }>} p.attachmentFiles
|
||||
*/
|
||||
async function insertSubmission(pgPool, p) {
|
||||
if (!pgPool) throw new Error("PostgreSQL이 필요합니다.");
|
||||
const total = countBodyTotal({
|
||||
situation: p.situation,
|
||||
task_goal: p.taskGoal,
|
||||
action_taken: p.actionTaken,
|
||||
result_outcome: p.resultOutcome,
|
||||
});
|
||||
if (total > MAX_BODY_TOTAL) {
|
||||
throw new Error(`본문(4개 합계)은 ${MAX_BODY_TOTAL}자 이하여야 합니다. (현재 ${total}자)`);
|
||||
}
|
||||
const thumbFiles = Array.isArray(p.thumbnailFiles) ? p.thumbnailFiles.map(normalizeThumbFileEntry).filter(Boolean) : [];
|
||||
const primaryThumb = p.thumbnailRelativePath || primaryThumbnailPath(thumbFiles);
|
||||
const id = p.id || uuidv4();
|
||||
const q = `INSERT INTO ai_use_case_submissions (
|
||||
id, submitter_email, title, situation, task_goal, action_taken, result_outcome,
|
||||
tags, thumbnail_relative_path, thumbnail_files, attachment_files, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, NOW(), NOW())`;
|
||||
await pgPool.query(q, [
|
||||
id,
|
||||
p.submitterEmail,
|
||||
p.title,
|
||||
p.situation || "",
|
||||
p.taskGoal || "",
|
||||
p.actionTaken || "",
|
||||
p.resultOutcome || "",
|
||||
p.tags && p.tags.length ? p.tags : [],
|
||||
primaryThumb || null,
|
||||
JSON.stringify(thumbFiles),
|
||||
JSON.stringify(p.attachmentFiles || []),
|
||||
]);
|
||||
return { id };
|
||||
}
|
||||
|
||||
const SUBMISSION_DEPT = "일반 제출";
|
||||
const EXCERPT_MAX = 220;
|
||||
|
||||
/**
|
||||
* @param {string} situation
|
||||
* @param {string} task
|
||||
* @param {string} action
|
||||
* @param {string} result
|
||||
* @param {number} [max]
|
||||
*/
|
||||
function buildExcerpt(situation, task, action, result, max) {
|
||||
const cap = max == null ? EXCERPT_MAX : max;
|
||||
const full = [situation, task, action, result]
|
||||
.map((h) => stripForCount(h || ""))
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!full) return "";
|
||||
if (full.length <= cap) return full;
|
||||
return full.slice(0, cap) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* /ai-cases 목록 카드용
|
||||
* @param {object} row — pg row
|
||||
* @returns {object}
|
||||
*/
|
||||
function mapRowToListCard(row) {
|
||||
const email = (row.submitter_email || "").trim();
|
||||
const author = email && email.indexOf("@") > 0 ? email.split("@")[0] : email || "제출자";
|
||||
const created = row.created_at ? new Date(row.created_at) : new Date(0);
|
||||
const published = Number.isNaN(created.getTime()) ? "" : created.toISOString().slice(0, 10);
|
||||
const cover = primaryThumbnailPath(parseThumbnailFiles(row)) || (row.thumbnail_relative_path || "").trim();
|
||||
return {
|
||||
_source: "submission",
|
||||
submissionId: String(row.id),
|
||||
title: (row.title || "").trim() || "제목 없음",
|
||||
excerpt: buildExcerpt(row.situation, row.task_goal, row.action_taken, row.result_outcome),
|
||||
department: SUBMISSION_DEPT,
|
||||
author,
|
||||
submitterEmail: email,
|
||||
tags: Array.isArray(row.tags) ? row.tags : [],
|
||||
publishedAt: published,
|
||||
_dateSort: created,
|
||||
coverImageUrl: cover,
|
||||
viewCount: Number(row.view_count) || 0,
|
||||
likeCount: Number(row.like_count) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pgPool
|
||||
* @param {number} [limit=500]
|
||||
*/
|
||||
async function listForPublicList(pgPool, limit) {
|
||||
if (!pgPool) return [];
|
||||
const lim = Math.min(Math.max(1, limit || 500), 1000);
|
||||
const r = await pgPool.query(
|
||||
`SELECT
|
||||
s.id,
|
||||
s.submitter_email,
|
||||
s.title,
|
||||
s.situation,
|
||||
s.task_goal,
|
||||
s.action_taken,
|
||||
s.result_outcome,
|
||||
s.tags,
|
||||
s.thumbnail_relative_path,
|
||||
s.thumbnail_files,
|
||||
s.created_at,
|
||||
COALESCE(s.view_count, 0)::int AS view_count,
|
||||
(SELECT COUNT(*)::int FROM ai_use_case_submission_likes l WHERE l.submission_id = s.id) AS like_count
|
||||
FROM ai_use_case_submissions s
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT $1`,
|
||||
[lim]
|
||||
);
|
||||
return (r.rows || []).map(mapRowToListCard);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pgPool
|
||||
* @param {string} id
|
||||
*/
|
||||
async function getSubmissionById(pgPool, id) {
|
||||
if (!pgPool) return null;
|
||||
const r = await pgPool.query(
|
||||
`SELECT * FROM ai_use_case_submissions WHERE id = $1 LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
return r.rows && r.rows[0] ? r.rows[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 권한: 제출자 본인 또는 관리자
|
||||
* @param {string} rowSubmitterEmail
|
||||
* @param {string | null} requestEmail
|
||||
* @param {boolean} isAdmin
|
||||
*/
|
||||
function canUserEditSubmission(rowSubmitterEmail, requestEmail, isAdmin) {
|
||||
if (isAdmin) return true;
|
||||
if (!requestEmail) return false;
|
||||
const a = (rowSubmitterEmail || "").trim().toLowerCase();
|
||||
const b = String(requestEmail).trim().toLowerCase();
|
||||
return Boolean(a && b && a === b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성 화면 Toast 초기 HTML (DB에 저장된 4섹션 HTML을 그대로 감쌈)
|
||||
* @param {string} situation
|
||||
* @param {string} task
|
||||
* @param {string} action
|
||||
* @param {string} result
|
||||
*/
|
||||
function buildComposeEditorHtml(situation, task, action, result) {
|
||||
const b = (x) => (x && String(x).trim() ? String(x) : "<p><br></p>");
|
||||
return (
|
||||
'<div class="uc-doc">' +
|
||||
'<div id="uc-situation" class="uc-section"><h2>1. Situation (배경)</h2>' +
|
||||
b(situation) +
|
||||
"</div>" +
|
||||
'<div id="uc-task" class="uc-section"><h2>2. Task (과제/목표)</h2>' +
|
||||
b(task) +
|
||||
"</div>" +
|
||||
'<div id="uc-action" class="uc-section"><h2>3. Action (행동)</h2>' +
|
||||
b(action) +
|
||||
"</div>" +
|
||||
'<div id="uc-result" class="uc-section"><h2>4. Result (결과)</h2>' +
|
||||
b(result) +
|
||||
"</div>" +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pgPool
|
||||
* @param {object} p
|
||||
* @param {string} p.id
|
||||
* @param {string} p.title
|
||||
* @param {string} p.situation
|
||||
* @param {string} p.taskGoal
|
||||
* @param {string} p.actionTaken
|
||||
* @param {string} p.resultOutcome
|
||||
* @param {string[]} p.tags
|
||||
* @param {string} p.thumbnailFilesJson — 썸네일 배열 전체 JSON
|
||||
* @param {string} p.attachmentFilesJson — json 문자열(배열 전체)
|
||||
*/
|
||||
async function updateSubmission(pgPool, p) {
|
||||
if (!pgPool) throw new Error("PostgreSQL이 필요합니다.");
|
||||
const total = countBodyTotal({
|
||||
situation: p.situation,
|
||||
task_goal: p.taskGoal,
|
||||
action_taken: p.actionTaken,
|
||||
result_outcome: p.resultOutcome,
|
||||
});
|
||||
if (total > MAX_BODY_TOTAL) {
|
||||
throw new Error(`본문(4개 합계)은 ${MAX_BODY_TOTAL}자 이하여야 합니다. (현재 ${total}자)`);
|
||||
}
|
||||
let thumbFiles = [];
|
||||
try {
|
||||
const parsed = typeof p.thumbnailFilesJson === "string" ? JSON.parse(p.thumbnailFilesJson) : p.thumbnailFilesJson;
|
||||
if (Array.isArray(parsed)) thumbFiles = parsed.map(normalizeThumbFileEntry).filter(Boolean);
|
||||
} catch {
|
||||
thumbFiles = [];
|
||||
}
|
||||
const primaryThumb = primaryThumbnailPath(thumbFiles);
|
||||
await pgPool.query(
|
||||
`UPDATE ai_use_case_submissions SET
|
||||
title = $1,
|
||||
situation = $2,
|
||||
task_goal = $3,
|
||||
action_taken = $4,
|
||||
result_outcome = $5,
|
||||
tags = $6,
|
||||
thumbnail_relative_path = $7,
|
||||
thumbnail_files = $8::jsonb,
|
||||
attachment_files = $9::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $10`,
|
||||
[
|
||||
p.title,
|
||||
p.situation || "",
|
||||
p.taskGoal || "",
|
||||
p.actionTaken || "",
|
||||
p.resultOutcome || "",
|
||||
p.tags && p.tags.length ? p.tags : [],
|
||||
primaryThumb,
|
||||
JSON.stringify(thumbFiles),
|
||||
p.attachmentFilesJson || "[]",
|
||||
p.id,
|
||||
]
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 상세 페이지 방문마다 view_count +1 (페이지뷰). 제출자 본인(로그인) 조회만 제외.
|
||||
* @param {import("pg").Pool} pgPool
|
||||
* @param {string} submissionId
|
||||
* @param {string | null | undefined} viewerEmail
|
||||
* @param {string | null | undefined} submitterEmail
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function recordViewFromOther(pgPool, submissionId, viewerEmail, submitterEmail) {
|
||||
if (!pgPool || !submissionId || !UUID_RE.test(submissionId)) return 0;
|
||||
const viewer = String(viewerEmail || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const submitter = String(submitterEmail || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (viewer && submitter && viewer === submitter) {
|
||||
const cur = await pgPool.query(
|
||||
`SELECT view_count FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`,
|
||||
[submissionId]
|
||||
);
|
||||
return cur.rows[0]?.view_count ?? 0;
|
||||
}
|
||||
await pgPool.query(
|
||||
`UPDATE ai_use_case_submissions SET view_count = view_count + 1 WHERE id = $1::uuid`,
|
||||
[submissionId]
|
||||
);
|
||||
const r = await pgPool.query(
|
||||
`SELECT view_count FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`,
|
||||
[submissionId]
|
||||
);
|
||||
return r.rows[0]?.view_count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pgPool
|
||||
* @param {string} submissionId
|
||||
* @param {string | null | undefined} userEmail
|
||||
* @returns {Promise<{ viewCount: number, likeCount: number, myLike: boolean }>}
|
||||
*/
|
||||
async function getSubmissionEngagement(pgPool, submissionId, userEmail) {
|
||||
if (!pgPool || !submissionId || !UUID_RE.test(submissionId)) {
|
||||
return { viewCount: 0, likeCount: 0, myLike: false };
|
||||
}
|
||||
const stats = await pgPool.query(
|
||||
`SELECT
|
||||
COALESCE(s.view_count, 0)::int AS view_count,
|
||||
(SELECT COUNT(*)::int FROM ai_use_case_submission_likes l WHERE l.submission_id = s.id) AS like_count
|
||||
FROM ai_use_case_submissions s
|
||||
WHERE s.id = $1::uuid
|
||||
LIMIT 1`,
|
||||
[submissionId]
|
||||
);
|
||||
if (!stats.rowCount) {
|
||||
return { viewCount: 0, likeCount: 0, myLike: false };
|
||||
}
|
||||
const row = stats.rows[0];
|
||||
let myLike = false;
|
||||
const email = String(userEmail || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (email) {
|
||||
const mine = await pgPool.query(
|
||||
`SELECT 1 FROM ai_use_case_submission_likes
|
||||
WHERE submission_id = $1::uuid AND user_email = $2
|
||||
LIMIT 1`,
|
||||
[submissionId, email]
|
||||
);
|
||||
myLike = mine.rowCount > 0;
|
||||
}
|
||||
return {
|
||||
viewCount: row.view_count || 0,
|
||||
likeCount: row.like_count || 0,
|
||||
myLike,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pgPool
|
||||
* @param {string} submissionId
|
||||
* @param {string} userEmail
|
||||
* @returns {Promise<{ liked: boolean, likeCount: number }>}
|
||||
*/
|
||||
async function toggleSubmissionLike(pgPool, submissionId, userEmail) {
|
||||
if (!pgPool) {
|
||||
const err = new Error("DB를 사용할 수 없습니다.");
|
||||
err.code = "NO_DB";
|
||||
throw err;
|
||||
}
|
||||
const id = String(submissionId || "").trim();
|
||||
if (!UUID_RE.test(id)) {
|
||||
const err = new Error("잘못된 ID입니다.");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
const email = String(userEmail || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!email) {
|
||||
const err = new Error("로그인이 필요합니다.");
|
||||
err.code = "UNAUTHORIZED";
|
||||
throw err;
|
||||
}
|
||||
const exists = await pgPool.query(
|
||||
`SELECT 1 FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
if (!exists.rowCount) {
|
||||
const err = new Error("사례를 찾을 수 없습니다.");
|
||||
err.code = "NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
const del = await pgPool.query(
|
||||
`DELETE FROM ai_use_case_submission_likes
|
||||
WHERE submission_id = $1::uuid AND user_email = $2
|
||||
RETURNING submission_id`,
|
||||
[id, email]
|
||||
);
|
||||
if (del.rowCount) {
|
||||
const cnt = await pgPool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM ai_use_case_submission_likes WHERE submission_id = $1::uuid`,
|
||||
[id]
|
||||
);
|
||||
return { liked: false, likeCount: cnt.rows[0]?.c || 0 };
|
||||
}
|
||||
await pgPool.query(
|
||||
`INSERT INTO ai_use_case_submission_likes (submission_id, user_email) VALUES ($1::uuid, $2)`,
|
||||
[id, email]
|
||||
);
|
||||
const cnt = await pgPool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM ai_use_case_submission_likes WHERE submission_id = $1::uuid`,
|
||||
[id]
|
||||
);
|
||||
return { liked: true, likeCount: cnt.rows[0]?.c || 0 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PLACEHOLDERS,
|
||||
MAX_BODY_TOTAL,
|
||||
MAX_THUMB_BYTES,
|
||||
MAX_THUMB_COUNT,
|
||||
MAX_ATTACH_BYTES,
|
||||
MAX_ATTACH_COUNT,
|
||||
countBodyTotal,
|
||||
normalizeThumbFileEntry,
|
||||
parseThumbnailFiles,
|
||||
primaryThumbnailPath,
|
||||
insertSubmission,
|
||||
buildExcerpt,
|
||||
mapRowToListCard,
|
||||
listForPublicList,
|
||||
getSubmissionById,
|
||||
canUserEditSubmission,
|
||||
buildComposeEditorHtml,
|
||||
updateSubmission,
|
||||
recordViewFromOther,
|
||||
getSubmissionEngagement,
|
||||
toggleSubmissionLike,
|
||||
SUBMISSION_DEPT,
|
||||
};
|
||||
@@ -5,6 +5,7 @@ const fsSync = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const meetingEmployeeNames = require("./meeting-employee-names");
|
||||
const parseCkFromMm = require("./parse-checklist-from-minutes");
|
||||
const { execFile } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -27,7 +28,7 @@ function getGpt4oTranscribeSegmentSeconds() {
|
||||
}
|
||||
|
||||
/** 환경변수 미지정 시 기본 전사 모델 */
|
||||
const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-mini-transcribe").trim();
|
||||
const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-transcribe").trim();
|
||||
|
||||
/** @deprecated 하위 호환 — DEFAULT_TRANSCRIPTION_MODEL 과 동일 */
|
||||
const DEFAULT_WHISPER_MODEL = DEFAULT_TRANSCRIPTION_MODEL;
|
||||
@@ -49,19 +50,12 @@ function resolveTranscriptionApiModel(uiModel) {
|
||||
* DB에 저장된 옵션으로 시스템 프롬프트 구성
|
||||
* @param {object} settings - meeting_ai_prompts 행(카멜 또는 스네이크)
|
||||
*/
|
||||
/** 액션 아이템 — 형식은 사용자 추가 지시 우선(What/Who/When 강제 없음) */
|
||||
const ACTION_ITEMS_GUIDANCE_MINIMAL = [
|
||||
"【액션 아이템】",
|
||||
"「Action Item」포함이 켜져 있으면 별도 마크다운 섹션(예: ## 액션 아이템 또는 ## Action Items)으로 후속 과제를 정리합니다.",
|
||||
"**항목 표기(번호·부제목·영문 Who/When/What 라벨 사용 여부 등)는 아래「사용자 추가 지시」를 가장 우선합니다.** 추가 지시에 형식이 없으면 간단한 번호 목록·불릿으로, 원문·전사에서 확정된 일만 적습니다.",
|
||||
"담당자·이름·기한은 **회의 원문·전사에 명시된 경우에만** 적습니다. 없거나 불명확하면 생략하거나 ‘미정’·‘TBD’로 표기하고, 원문에 없는 인물·담당을 추측하여 쓰지 마세요.",
|
||||
];
|
||||
|
||||
const EMPLOYEE_NAME_GUIDANCE_MINIMAL = [
|
||||
"【인명·담당자】",
|
||||
"참석자·담당자 이름은 **원문·전사에 실제로 등장한 표기**를 따릅니다. 음성 인식 오류로 같은 사람이 문맥상 확실할 때만 철자를 다듬습니다.",
|
||||
"사용자 메시지 상단에「이번 원문/전사 한정 · 임직원 표기 통일」블록이 있으면, **그 안의 매핑만** 적용하고 다른 이름을 임의로 목록에서 끌어오지 마세요.",
|
||||
"전사에 없는 사람을 만들어내지 마세요.",
|
||||
"발언자가 '우리', '우리 팀', '우리 회사', '저희 팀' 등으로 표현한 주체는 **회의 진행 요약·Q&A 등에서는** 원문 표현 그대로 유지하고, 문맥으로 특정 팀명·조직명을 추론하여 대체하지 마세요. (금지 예: '우리 팀(소프트웨어 운영팀)'처럼 괄호 보강) 단, 아래 【6) 액션 아이템】표의 **「담당」열**에서는 이 지칭을 쓸 수 없으며, 구체 이름·실제 조직·팀명이 없으면 **미정**만 둘 수 있습니다.",
|
||||
];
|
||||
|
||||
/** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 AI 연동 */
|
||||
@@ -70,15 +64,11 @@ const MEETING_CHECKLIST_GUIDANCE_LINES = [
|
||||
"정의: 회의가 원활하게 진행되고 목표를 달성할 수 있도록 사전에 준비하거나, 회의 후 검토해야 할 항목들을 목록화한 것입니다.",
|
||||
"목적: 준비 부족으로 인한 시간 낭비를 방지하고, 회의 전·중·후 전 과정을 구조화하여 효율을 높입니다.",
|
||||
"원문에서 도출 가능한 범위에서, 회의 전 준비·회의 중 진행 점검·회의 후 확인·후속 등을 [ ] 체크리스트 형태로 나열할 수 있습니다.",
|
||||
"업무 체크리스트 AI 연동: 위 체크리스트 섹션(예: ## 후속 확인 체크리스트, ## 회의 체크리스트)을 반드시 포함하고, 각 항목은 완료 여부를 파악·표시할 수 있는 형태(예: [ ] 항목, 체크박스)로 작성하세요. ‘사용자 추가 지시’에 체크리스트 관련 문구가 없더라도 이 요구는 동일하게 적용됩니다.",
|
||||
"업무 체크리스트 AI 연동: 위 체크리스트 섹션(예: ## 후속 확인 체크리스트, ## 회의 체크리스트)을 반드시 포함하고, 각 항목은 완료 여부를 파악·표시할 수 있는 형태(예: [ ] 항목, 체크박스)로 작성하세요. '사용자 추가 지시'에 체크리스트 관련 문구가 없더라도 이 요구는 동일하게 적용됩니다.",
|
||||
];
|
||||
|
||||
function buildMeetingMinutesSystemPrompt(settings) {
|
||||
if (!settings || typeof settings !== "object") settings = {};
|
||||
const includeTitle = settings.includeTitleLine !== false && settings.include_title_line !== false;
|
||||
const includeAttendees = settings.includeAttendees !== false && settings.include_attendees !== false;
|
||||
const includeSummary = settings.includeSummary !== false && settings.include_summary !== false;
|
||||
const includeActionItems = settings.includeActionItems !== false && settings.include_action_items !== false;
|
||||
const includeChecklist = settings.includeChecklist === true || settings.include_checklist === true;
|
||||
const custom =
|
||||
(settings.customInstructions && String(settings.customInstructions)) ||
|
||||
@@ -86,71 +76,115 @@ function buildMeetingMinutesSystemPrompt(settings) {
|
||||
"";
|
||||
|
||||
const lines = [
|
||||
"당신은 사내 회의록을 정리하는 전문가입니다. 입력된 회의 원문(또는 음성 전사)을 바탕으로 읽기 쉬운 회의록을 한국어로 작성합니다.",
|
||||
"【규칙 우선순위】이 메시지 아래에「사용자 추가 지시」가 있으면, 섹션 구성·액션 항목 표기·체크리스트 포함 여부 등은 **사용자 추가 지시를 최우선**으로 따릅니다. 일반 지시와 다르면 사용자 추가 지시가 우선합니다.",
|
||||
"당신은 사내 회의록 작성 전문가입니다. 음성 녹취록, 대화체 텍스트, 또는 회의 메모를 입력받아 구조화된 공식 회의록을 한국어로 작성합니다.",
|
||||
];
|
||||
|
||||
if (custom.trim()) {
|
||||
lines.push("【규칙 우선순위】이 메시지 아래에「사용자 추가 지시」가 있으면, 섹션 구성·형식 등은 **사용자 추가 지시를 최우선**으로 따릅니다.");
|
||||
}
|
||||
|
||||
EMPLOYEE_NAME_GUIDANCE_MINIMAL.forEach((line) => lines.push(line));
|
||||
|
||||
lines.push("");
|
||||
lines.push("출력 형식 요구(위 체크박스·저장값):");
|
||||
if (includeTitle) lines.push("- 맨 위에 회의 제목 한 줄(입력에 제목이 없으면 내용에서 추정).");
|
||||
if (includeAttendees) lines.push("- 참석자·발언자가 드러나면 구분해 정리.");
|
||||
if (includeSummary) lines.push("- 핵심 요약(불릿 또는 짧은 단락).");
|
||||
lines.push("- 불필요한 장황한 서론 없이, 마크다운 구조(##, ###, -, 표)를 사용해 읽기 쉽게 구분하세요.");
|
||||
lines.push("【출력 구조】반드시 아래 순서와 항목으로 작성하세요.");
|
||||
lines.push("");
|
||||
lines.push("【원문·전사와 회의록 분리】");
|
||||
lines.push("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드(전사 기록·회의 원문)로 이미 보관됩니다.");
|
||||
if (includeChecklist) {
|
||||
lines.push("## 1) 회의 제목");
|
||||
lines.push("- 회의 내용을 대표하는 명확한 제목을 1줄로 작성합니다.");
|
||||
lines.push("- 형식: [주제] + [목적/활동]");
|
||||
lines.push("");
|
||||
lines.push("## 2) 참석·언급 인원");
|
||||
lines.push("- **표는 쓰지 않습니다.** 제목 다음 줄 내용은 **단일 줄**로, 원문·전사에 등장한 **인물·직함 호칭·조직·팀명**만 **한국어 쉼표(,)** 로 이어 적습니다.");
|
||||
lines.push("- 예시 형식(실제 이름·호칭·팀명은 반드시 **해당 회의 원문·전사**에서만 추출): `홍길동, 이영 팀장, 기획팀`.");
|
||||
lines.push("- **포함 기준:** 실명(예: 홍길동) 또는 원문의 직함·역할 호칭(예: 대표님, 팀장) 또는 명확한 조직·팀명(예: 인사총무팀)이 나온 경우만 나열합니다.");
|
||||
lines.push("- **제외 기준:** '발언자(미상)', '미상', '불명확', '알 수 없음' 등 이름·조직이 확인되지 않으면 빼세요.");
|
||||
lines.push("- **금지:** 구분·비고 형식의 표, 「참석」「언급됨」 비고 표기, 마크다운 표 전체를 이 섹션에 쓰지 마세요.");
|
||||
lines.push("- 동일 인물이 여러 호칭으로 불리더라도 하나의 대표 호칭으로만 적되, 불명하면 원문 표기를 우선합니다.");
|
||||
lines.push("");
|
||||
lines.push("## 3) 회의 주제 및 요약");
|
||||
lines.push("- **핵심 주제:** 1줄 요약");
|
||||
lines.push("- **요약:** 3~5문장으로 회의 전체 흐름과 결론을 서술합니다.");
|
||||
lines.push("- 구어체·중복 내용은 정제하여 공식 문어체로 변환합니다.");
|
||||
lines.push("");
|
||||
lines.push("## 4) 회의 진행 흐름에 따른 주제 변화");
|
||||
lines.push("- 마크다운 표로 작성합니다: 순서 | 주제 | 내용 요약");
|
||||
lines.push("- 회의 시작부터 종료까지 시간 순서대로 주제 전환을 기록합니다.");
|
||||
lines.push("- 각 주제는 2~3문장 이내로 요약합니다.");
|
||||
lines.push("");
|
||||
lines.push("## 5) 회의 중 주요 질의응답");
|
||||
lines.push("- `Q. 질문 내용` / `A. 답변 내용` 형식으로 작성합니다.");
|
||||
lines.push("- 명시적 질문이 없더라도 회의 중 제기된 핵심 쟁점을 Q&A 형태로 재구성합니다.");
|
||||
lines.push("- 발언자 불명확 시 역할로 표기합니다. (예: 대표이사, 팀장)");
|
||||
lines.push("");
|
||||
lines.push("## 6) 액션 아이템");
|
||||
lines.push(
|
||||
"- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, ‘Verbatim’ 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션·체크리스트만 회의록 본문에 포함하세요."
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, ‘Verbatim’ 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션 등 **사용자 추가 지시에 적은 섹션만** 포함하세요."
|
||||
'- **반드시 GitHub Flavored 마크다운 표** 로만 작성합니다. 열 순서 고정 **4열: `#`(순번) | 담당 | 내용 | 기한**(순번은 1부터 정수만). 헤더·구분선·데이터 **모든 행**은 **반드시 맨 앞·맨 뒤 파이프 `|` 포함** 규격을 따릅니다(렌더·자동연동 신뢰성).'
|
||||
);
|
||||
lines.push(
|
||||
"- 사용자 추가 지시에 「회의 체크리스트」「후속 확인 체크리스트」 등이 없으면, 그런 제목의 별도 체크리스트 섹션을 만들지 마세요."
|
||||
"- 헤더·구분선·데이터 각 행의 **열 개수(셀 분할 후 줄마다 같은 개수)·맨 앞과 맨 끝 `|` 존재**가 일치해야 합니다(**액션 아이템 표는 고정 네 열**: `#`(순번), 담당, 내용, 기한)."
|
||||
);
|
||||
}
|
||||
lines.push("- 회의 제목, 참석자, 요약, 결정 사항, 액션 아이템 등은 마크다운 제목(예: ## 회의 제목, ### 요약)으로 구분해 주세요.");
|
||||
if (includeActionItems) {
|
||||
lines.push("");
|
||||
ACTION_ITEMS_GUIDANCE_MINIMAL.forEach((line) => lines.push(line));
|
||||
}
|
||||
lines.push("【올바른 예시(형식 준수, 내용만 원문 반영)");
|
||||
lines.push("| # | 담당 | 내용 | 기한 |");
|
||||
lines.push("| --- | --- | --- | --- |");
|
||||
lines.push("| 1 | 인사팀 | 요청 자료 작성 | 다음 주 |");
|
||||
lines.push("| 2 | 김민수 | 관련 회신 확인 | 다음 주 |");
|
||||
lines.push("【예시 끝】");
|
||||
lines.push("- **담당 열(담당자):** 회의 원문·전사에 **성명 또는 실제 소속 조직·팀명으로 명확히 적힌 경우만** 기입합니다.");
|
||||
lines.push(
|
||||
"- **담당 열 금지:** 「발언자」「질문자」「저희」「우리 팀」「우리 회사」「우리」(단독 지칭)「본인」「해당 부서」처럼 특정인·실제 조직을 지목하지 않는 호칭은 담당 열에 두지 마십시오."
|
||||
);
|
||||
lines.push(
|
||||
'- **직무·역할 명칭 단독**(예: 「엔지니어」「PM」「디자이너」만 들어 있는 경우) 또는 인명 없이 역할 이름만 들어 있는 경우에는 「담당」에 두지 말고 **미정**(또는 TBD)을 씁니다.'
|
||||
);
|
||||
lines.push(
|
||||
'- 담당 주체가 원문만으로 특정되지 않으면 **내용·기한**은 채우고, **담당** 칸은 **미정** 또는 **TBD**만 허용합니다(빈 칸 금지).'
|
||||
);
|
||||
lines.push(
|
||||
'- 누가 수행해야 하는지는 **내용** 칸 서술에 보조할 수 있으나, **담당** 칸은 위 기준만 따릅니다.'
|
||||
);
|
||||
lines.push("- **금지 형식**: 헤더는 3열(`| 담당 | 내용 | 기한`)인데 데이터 행만 4열인 경우, 헤더엔 순번 줄을 안 쓰고 데이터 줄 맨 앞에만 번호 넣기, 시작/끝 `|` 빠진 행 — 이런 경우 브라우저에서 표가 깨져 보입니다.");
|
||||
lines.push("- 셀 내부에 `|` 문자가 들어가면 안 됩니다. 바꿀 표현으로 씁니다.");
|
||||
lines.push("- 회의에서 언급된 모든 후속 과제를 빠짐없이 추출합니다.");
|
||||
lines.push("- 기한이 명시되지 않은 경우 문맥 기반으로 추정하여 기재합니다. (예: 다음 주, 1개월 이내)");
|
||||
lines.push("- 담당자·기한이 원문에 전혀 없으면 '미정'·'TBD'로 표기합니다.");
|
||||
|
||||
if (includeChecklist) {
|
||||
lines.push("");
|
||||
MEETING_CHECKLIST_GUIDANCE_LINES.forEach((line) => lines.push(line));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("【말미 섹션 금지】");
|
||||
if (includeChecklist) {
|
||||
lines.push("【작성 규칙】");
|
||||
lines.push("1. 언어: 구어체 입력이라도 출력은 반드시 공식 문어체 존댓말로 변환합니다.");
|
||||
lines.push("2. 정보 보완: 참석자·일시 등 누락 정보는 ※ 확인 필요 주석으로 표시합니다.");
|
||||
lines.push("3. 맥락 추론: 발언 **의도·내용**이 불명확한 경우에 한해 전후 문맥으로 추론하되, 추론 시 (추정) 표기합니다. 단, 발언 **주체(사람·팀·조직)**는 추론하여 만들지 마세요. **단, 【6) 액션 아이템】의 「담당」열에서는** 이름·실제 조직·팀명이 없으면 반드시 **미정**(또는 TBD); 「발언자」「우리 팀」「저희」 같은 지칭은 **담당 열 금지**입니다. 회의 진행·Q&A 본문 등 다른 곳에서는 원문 호칭을 유지할 수 있습니다.");
|
||||
lines.push("4. 중립성 유지: 특정 발언자에 유리하거나 불리한 방향으로 편집하지 않습니다.");
|
||||
lines.push("5. 비고 처리: 원문에서 명확히 파악되지 않는 사항(발언자 미상, 참석자 불명, 담당자 미확인 등)은 해당 섹션 내부 또는 섹션 6 아래에 `※ 비고` 평문 각주로만 표기합니다. 반드시 `##` 제목의 별도 섹션으로 만들지 마세요.");
|
||||
lines.push(
|
||||
"- 회의 체크리스트·액션 아이템 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다."
|
||||
'6. 표 형식: 4)·5)·6)·체크리스트 등 **표가 필요한 섹션**에서만 마크다운 표를 씁니다. 구분 줄은 각 열마다 `---`만(정렬 마커 `:---` 등 금지). **행마다 줄 선두·줄 말미에 반드시 `|` 를 둘 것.** **6) 액션 아이템 은 헤더 4열 고정 및 위 예시 규격 엄수.** **2) 참석·언급 인원은 표 없이 쉼표 목록 한 줄입니다.**'
|
||||
);
|
||||
|
||||
lines.push("");
|
||||
lines.push("【금지 사항】");
|
||||
lines.push("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드로 이미 보관됩니다.");
|
||||
lines.push("- '스크립트', '스크랩트'(오타), '원문 전사', '전사문', 'Verbatim' 등 원문을 통째로 실어 나르는 섹션을 만들지 마세요.");
|
||||
lines.push("- '추가 권고', '회의록 작성자의 제안', 엑셀 담당자 배정 템플릿·추적 체크리스트 제안 등 회의 본문과 무관한 조언·제안 섹션을 두지 마세요.");
|
||||
lines.push("- 액션 아이템 이후에 `##` 제목의 추가 섹션(예: '추가 메모', '확인 필요 사항')을 두지 마세요. 불명확 사항은 위 규칙 5(※ 비고 평문 각주)로만 처리하세요.");
|
||||
lines.push(
|
||||
"- 체크리스트 섹션을 마지막으로 두고, 그 아래에 시연·피드백 제출 방식(문서/슬랙/이메일) 회신, 액션 우선순위 재정렬·담당·기한 확정 안내, DRM·후보군 추가 작성 제안 같은 **운영/후속 안내 문단**을 붙이지 마세요."
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"- 액션 아이템·결정 사항 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다."
|
||||
'- 액션 아이템 표 **「담당」열**에는 실명 또는 원문 기준 실제 조직·팀명 외 문자열 금지(「발언자」「저희」「우리 팀」「우리」 호칭 전용 포함).'
|
||||
);
|
||||
lines.push("- (일반) 원문에 없는 인명·팀명을 문맥으로 추측하여 괄호로 채워 넣는 것(예: '우리 팀(운영팀)')은 회의 진행 요약 등에도 두지 마십시오.");
|
||||
if (!includeChecklist) {
|
||||
lines.push("- '## 회의 체크리스트', '## 후속 확인 체크리스트' 같은 체크리스트 전용 섹션을 만들지 마세요.");
|
||||
}
|
||||
lines.push(
|
||||
"- ‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 담당자 배정 템플릿·추적 체크리스트 제안 등 **회의 본문과 무관한 조언·제안 섹션**을 두지 마세요."
|
||||
);
|
||||
|
||||
if (custom.trim()) {
|
||||
lines.push("");
|
||||
lines.push("사용자 추가 지시:");
|
||||
lines.push(custom.trim());
|
||||
}
|
||||
if (!includeChecklist) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"【출력에서 제외(최종)】`## 회의 체크리스트`, `## 후속 확인 체크리스트` 같은 체크리스트 전용 제목, 그 아래 `- [ ]`·불릿 목록, 회의 본문 끝의 괄호 메타 문장(예: 체크리스트를 마지막으로 작성)은 넣지 마세요."
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크립트/스크랩트(오타) 등 원문 통째 반복 섹션의 제목인지 (마크다운 # 제목)
|
||||
* @param {string} title
|
||||
@@ -536,6 +570,286 @@ function stripAllMeetingChecklistSectionBlocks(markdown) {
|
||||
return md;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 표「담당」셀이 일반 호칭·지칭이면 업무 규격에 맞게 **미정**으로 바꿉니다.
|
||||
*/
|
||||
function sanitizeActionItemAssigneeCell(assigneeRaw) {
|
||||
const vOrig = String(assigneeRaw ?? "")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/|/g, "|");
|
||||
if (!vOrig) return "미정";
|
||||
if (/^미정$/i.test(vOrig)) return "미정";
|
||||
if (/^TBD$/i.test(vOrig)) return "TBD";
|
||||
|
||||
/** @param {string} seg */
|
||||
const segmentIsForbiddenGeneric = (seg) => {
|
||||
const v = seg.replace(/\([^)]*\)/g, "").trim();
|
||||
if (!v) return true;
|
||||
return (
|
||||
/발언자|질문자|발언측/i.test(v) ||
|
||||
/^저희(\s+[팀회사])*$/u.test(v) ||
|
||||
/^우리\s*$/u.test(v) ||
|
||||
/^우리\s+(팀|회사)$/u.test(v) ||
|
||||
/^당사$/u.test(v) ||
|
||||
/^해당\s*부서$/u.test(v) ||
|
||||
/^본인$/u.test(v) ||
|
||||
/^미상$/u.test(v) ||
|
||||
/^담당자\s*미상$/u.test(v) ||
|
||||
/^직원$/u.test(v) ||
|
||||
/^(엔지니어|개발\s*담당자?|디자이너|기획자|PM|P\.M\.|QA|테스터|운영)$/iu.test(v)
|
||||
);
|
||||
};
|
||||
|
||||
const segments = vOrig
|
||||
.split(/[//]/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (segments.length === 0) return "미정";
|
||||
/** "인사팀 / 발언자"처럼 금지 조각이 섞여 있어도 담당 특정 불가로 통일합니다 */
|
||||
if (segments.some(segmentIsForbiddenGeneric)) return "미정";
|
||||
|
||||
return vOrig;
|
||||
}
|
||||
|
||||
function rebuildMarkdownTableRowFromCells(cells) {
|
||||
const parts = cells.map((c) => String(c ?? "").trim());
|
||||
return "| " + parts.join(" | ") + " |";
|
||||
}
|
||||
|
||||
/**
|
||||
* 【6) 액션 아이템】 블록 안 표 데이터 행(첫 칸 순번 숫자)의 담당 열만 정제합니다.
|
||||
* @param {string} markdown
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeActionItemAssigneesInMarkdown(markdown) {
|
||||
const lines = String(markdown || "").split(/\r?\n/);
|
||||
/** @type {string[]} */
|
||||
const out = [];
|
||||
let i = 0;
|
||||
|
||||
const canonLn = (ln) => String(ln || "").trimEnd().replace(/|/g, "|");
|
||||
|
||||
while (i < lines.length) {
|
||||
const raw = lines[i];
|
||||
const ts = raw.trim();
|
||||
|
||||
if (/^##\s+/.test(ts)) {
|
||||
out.push(raw);
|
||||
const isActionHeading = /액션\s*아이템/.test(ts) || /^##\s+Action\s+Items\b/i.test(ts);
|
||||
if (!isActionHeading) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
let secEnd = lines.length;
|
||||
for (let k = i; k < lines.length; k++) {
|
||||
const v = lines[k].trim();
|
||||
if (v && /^##\s+/.test(v)) {
|
||||
secEnd = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let jj = i;
|
||||
while (jj < secEnd) {
|
||||
if (canonLn(lines[jj]).trim() === "") {
|
||||
out.push(lines[jj]);
|
||||
jj++;
|
||||
continue;
|
||||
}
|
||||
const trimmed = canonLn(lines[jj]).trim();
|
||||
const pipeRow =
|
||||
trimmed.startsWith("|") || (/^\d+\s*\|/.test(trimmed) && !trimmed.startsWith("|"));
|
||||
if (!pipeRow) {
|
||||
out.push(lines[jj]);
|
||||
jj++;
|
||||
continue;
|
||||
}
|
||||
if (parseCkFromMm.isTableSeparatorRow(trimmed)) {
|
||||
out.push(lines[jj]);
|
||||
jj++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cells = parseCkFromMm.parseTableCells(lines[jj]);
|
||||
if (
|
||||
cells.length >= 4 &&
|
||||
/^\d+$/.test(String(cells[0] || "").trim())
|
||||
) {
|
||||
const assigneeIx = 1;
|
||||
const next = [...cells];
|
||||
next[assigneeIx] = sanitizeActionItemAssigneeCell(next[assigneeIx]);
|
||||
out.push(rebuildMarkdownTableRowFromCells(next));
|
||||
jj++;
|
||||
} else {
|
||||
out.push(lines[jj]);
|
||||
jj++;
|
||||
}
|
||||
}
|
||||
i = secEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(raw);
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* GFM 규격: 헤더·구분·데이터 열 개수 불일치로 깨진 액션 아이템 파이프블록을 교정합니다.
|
||||
* `prepareMeetingMinutesForApi`에서 호출되어 저장·표시 전에 적용됩니다.
|
||||
*
|
||||
* @param {string[]} lines 전체 줄
|
||||
* @param {number} start 의심 표 첫 줄(헤더)
|
||||
* @param {number} sectionEndExclusive 같은 절 안에서 다음 `##` 직전 인덱스
|
||||
* @returns {{ replacement: string[], nextIndex: number } | null}
|
||||
*/
|
||||
function tryNormalizeBrokenActionItemTable(lines, start, sectionEndExclusive) {
|
||||
/** @param {string} ln */
|
||||
const canon = (ln) => String(ln || "").trimEnd().replace(/|/g, "|");
|
||||
|
||||
const headerTrim = canon(lines[start]).trim();
|
||||
if (
|
||||
!headerTrim.includes("|") ||
|
||||
!headerTrim.includes("담당") ||
|
||||
!headerTrim.includes("내용") ||
|
||||
!headerTrim.includes("기한")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hdrCells = parseCkFromMm.parseTableCells(lines[start]);
|
||||
if (!hdrCells || hdrCells.length !== 3) return null;
|
||||
|
||||
let j = start + 1;
|
||||
/** @type {string[]} */
|
||||
const leadingBlankLines = [];
|
||||
while (j < sectionEndExclusive) {
|
||||
const u = canon(lines[j]).trim();
|
||||
if (u !== "") break;
|
||||
leadingBlankLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
if (j < sectionEndExclusive) {
|
||||
const maybeSep = canon(lines[j]).trim();
|
||||
if (parseCkFromMm.isTableSeparatorRow(maybeSep)) j++;
|
||||
}
|
||||
|
||||
if (j >= sectionEndExclusive) return null;
|
||||
|
||||
const firstCells = parseCkFromMm.parseTableCells(lines[j]);
|
||||
if (
|
||||
!firstCells ||
|
||||
firstCells.length < 4 ||
|
||||
!/^\d+$/.test(String(firstCells[0] || "").trim())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const rebuilt = [];
|
||||
rebuilt.push("| # | 담당 | 내용 | 기한 |");
|
||||
rebuilt.push("| --- | --- | --- | --- |");
|
||||
|
||||
let p = j;
|
||||
while (p < sectionEndExclusive) {
|
||||
const cand = canon(lines[p]).trim();
|
||||
if (!cand) break;
|
||||
if (cand.startsWith("※")) break;
|
||||
|
||||
const cells = parseCkFromMm.parseTableCells(lines[p]);
|
||||
if (!cells || cells.length < 4 || !/^\d+$/.test(String(cells[0] || "").trim())) break;
|
||||
|
||||
const num = String(cells[0]).trim();
|
||||
const assignee = String(cells[1] || "").trim();
|
||||
const due = String(cells[cells.length - 1] || "").trim();
|
||||
const body = cells
|
||||
.slice(2, cells.length - 1)
|
||||
.map((c) => c.trim())
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
rebuilt.push(`| ${num} | ${sanitizeActionItemAssigneeCell(assignee)} | ${body} | ${due} |`);
|
||||
p++;
|
||||
}
|
||||
|
||||
if (rebuilt.length < 3) return null;
|
||||
|
||||
return {
|
||||
replacement: [...leadingBlankLines, ...rebuilt],
|
||||
nextIndex: p,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「## … 액션 아이템」(Action Items) 다음 절에서 위 깨진 표만 반복 교정합니다.
|
||||
* @param {string} markdown
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeActionItemsMarkdownTables(markdown) {
|
||||
const lines = String(markdown || "").split(/\r?\n/);
|
||||
/** @param {string} ln */
|
||||
const canonLn = (ln) => String(ln || "").trimEnd().replace(/|/g, "|");
|
||||
/** @type {string[]} */
|
||||
const out = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const raw = lines[i];
|
||||
const ts = raw.trim();
|
||||
|
||||
if (/^##\s+/.test(ts)) {
|
||||
out.push(raw);
|
||||
const isActionHeading = /액션\s*아이템/.test(ts) || /^##\s+Action\s+Items\b/i.test(ts);
|
||||
if (!isActionHeading) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
let secEnd = lines.length;
|
||||
for (let k = i; k < lines.length; k++) {
|
||||
const v = lines[k].trim();
|
||||
if (v && /^##\s+/.test(v)) {
|
||||
secEnd = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let jj = i;
|
||||
while (jj < secEnd) {
|
||||
if (canonLn(lines[jj]).trim() === "") {
|
||||
out.push(lines[jj]);
|
||||
jj++;
|
||||
continue;
|
||||
}
|
||||
const patch = tryNormalizeBrokenActionItemTable(lines, jj, secEnd);
|
||||
if (patch) {
|
||||
patch.replacement.forEach((ln) => out.push(ln));
|
||||
jj = patch.nextIndex;
|
||||
} else {
|
||||
out.push(lines[jj]);
|
||||
jj++;
|
||||
}
|
||||
}
|
||||
i = secEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(raw);
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* API·저장·생성 공통: 스크립트 제거 → (옵션) 체크리스트 섹션 삭제 → 체크리스트 이후 말미 정리 → …
|
||||
* @param {string} markdown
|
||||
@@ -550,6 +864,8 @@ function prepareMeetingMinutesForApi(markdown, options = {}) {
|
||||
md = stripTrailingAfterMeetingChecklistSection(md);
|
||||
md = removeKnownBoilerplateLines(md);
|
||||
md = stripTrailingJunkSectionsFromStart(md);
|
||||
md = normalizeActionItemsMarkdownTables(md);
|
||||
md = sanitizeActionItemAssigneesInMarkdown(md);
|
||||
return enhanceMeetingMinutesHeadingLines(md);
|
||||
}
|
||||
|
||||
@@ -653,18 +969,23 @@ async function ffmpegSplitAudioForTranscription(inputPath, segmentSeconds) {
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg 분할 후 각 구간 파일을 순서대로 전사해 합칩니다(OpenAI 호출 순차 처리).
|
||||
* @param {import("openai").default} openai
|
||||
* @param {string} filePath
|
||||
* @param {string} [uiModel]
|
||||
* @param {number} [segmentSeconds] - ffmpeg 분할 길이(초)
|
||||
* @param {(p: {stage: string, done: number, total: number}) => void} [onProgress]
|
||||
*/
|
||||
async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentSeconds) {
|
||||
async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentSeconds, onProgress) {
|
||||
const { files, tmpDir } = await ffmpegSplitAudioForTranscription(filePath, segmentSeconds);
|
||||
if (onProgress) onProgress({ stage: "init", done: 0, total: files.length });
|
||||
try {
|
||||
const parts = [];
|
||||
for (const fp of files) {
|
||||
const text = await transcribeMeetingAudioOnce(openai, fp, uiModel);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const text = await transcribeMeetingAudioOnce(openai, files[i], uiModel);
|
||||
parts.push(text);
|
||||
const done = i + 1;
|
||||
if (onProgress) onProgress({ stage: "transcribe", done, total: files.length });
|
||||
}
|
||||
return parts.join("\n\n").trim();
|
||||
} finally {
|
||||
@@ -677,13 +998,14 @@ async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentS
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 파일 전사. OpenAI 요청당 한도(약 25MB)를 넘으면 ffmpeg로 분할 후 순차 전사.
|
||||
* gpt-4o 전사 계열은 오디오 토큰 상한으로, 용량이 작아도 전체를 한 요청에 넣으면 400이 날 수 있어 짧은 구간으로 분할한다.
|
||||
* 단일 파일 전사. OpenAI 요청당 한도(약 25MB)를 넘으면 ffmpeg로 분할 후 구간 순차 전사.
|
||||
* gpt-4o 전사 계열은 오디오 토큰 상한으로, 용량이 작아도 한 파일 전체를 한 번에 보내면 400이 날 수 있어 짧게 분할한다.
|
||||
* @param {import("openai").default} openai
|
||||
* @param {string} filePath
|
||||
* @param {string} [uiModel] - gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||
* @param {(p: {stage: string, done: number, total: number}) => void} [onProgress]
|
||||
*/
|
||||
async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL) {
|
||||
async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL, onProgress) {
|
||||
const apiModel = resolveTranscriptionApiModel(uiModel);
|
||||
const isGpt4oStyleTranscribe =
|
||||
apiModel.startsWith("gpt-4o") && apiModel.includes("transcribe") && !apiModel.includes("diarize");
|
||||
@@ -691,13 +1013,16 @@ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSC
|
||||
const gpt4oSeg = getGpt4oTranscribeSegmentSeconds();
|
||||
|
||||
if (isGpt4oStyleTranscribe) {
|
||||
return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg);
|
||||
return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg, onProgress);
|
||||
}
|
||||
|
||||
if (size <= SAFE_SINGLE_REQUEST_BYTES) {
|
||||
return transcribeMeetingAudioOnce(openai, filePath, uiModel);
|
||||
if (onProgress) onProgress({ stage: "init", done: 0, total: 1 });
|
||||
const result = await transcribeMeetingAudioOnce(openai, filePath, uiModel);
|
||||
if (onProgress) onProgress({ stage: "transcribe", done: 1, total: 1 });
|
||||
return result;
|
||||
}
|
||||
return transcribeMeetingAudioChunked(openai, filePath, uiModel);
|
||||
return transcribeMeetingAudioChunked(openai, filePath, uiModel, undefined, onProgress);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -709,7 +1034,7 @@ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSC
|
||||
* @param {(m: string) => string} opts.resolveApiModel
|
||||
*/
|
||||
async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel, omitMeetingChecklistSection }) {
|
||||
const apiModel = resolveApiModel(uiModel || "gpt-5-mini");
|
||||
const apiModel = resolveApiModel(uiModel || "gpt-5.4");
|
||||
const namePrefix = meetingEmployeeNames.buildNameNormalizationUserPrefix(userContent);
|
||||
const userPayload = namePrefix ? `${namePrefix}${userContent}` : userContent;
|
||||
const completion = await openai.chat.completions.create({
|
||||
@@ -730,11 +1055,14 @@ const CHECKLIST_EXTRACT_SYSTEM = `You extract actionable work items from Korean
|
||||
Return ONLY valid JSON with this exact shape (no markdown fence, no extra keys):
|
||||
{"items":[{"title":"string","detail":"string or empty","assignee":"string or empty","dueNote":"string or empty"}]}
|
||||
Priority (Action Items are as important as checklists — they are real work):
|
||||
1) **Action Items / 액션 아이템** sections: Every numbered line (1. 2. 3. or 1) 2) 3)) MUST become a **separate** item. Title = the numbered line’s main task name; put 담당/기한/할 일 lines into assignee, dueNote, detail as appropriate.
|
||||
1) **Action Items / 액션 아이템** sections (headings like "## 6) 액션 아이템" or "## 액션 아이템"):
|
||||
- If the section contains a **Markdown table**: expect **four columns**: # | 담당 | 내용 | 기한 (header may mistakenly omit the first column '#' / 순번, but data rows often have numeric first cells — map columns by header names when clear, otherwise treat consecutive cells as 순번→담당→내용→기한).
|
||||
- **Each logical table row becomes a separate item.** Map **내용** column → title, **담당** → assignee, **기한** → dueNote. Leading **#**(순번) is not the title unless 내용 column is absent.
|
||||
- If the section contains **numbered lines** (1. 2. 3. or 1) 2) 3)): each numbered line becomes a separate item. Title = the line's main task name; put 담당/기한/할 일 sub-lines into assignee, dueNote, detail.
|
||||
2) **회의 체크리스트 / 후속 확인 체크리스트** sections: Each [ ] or bullet follow-up as one item.
|
||||
3) If an action item has 담당:, 기한:, 할 일: sub-lines, map them to assignee, dueNote, detail.
|
||||
- For assignee (담당자): follow the spelling already used in the minutes. Korean person names should match the company roster implied in the meeting minutes system prompt when the minutes corrected them; do not invent new spellings.
|
||||
- Do not merge multiple numbered actions into one item.
|
||||
- For assignee (담당): use only literal person names or real org or team names as printed in those minutes or table cells. Put an empty assignee string or 미정 when the cell would otherwise be placeholders such as 발언자, 저희, 우리 팀, 우리 alone, or 해당 부서. Do not invent names.
|
||||
- Do not merge multiple rows or numbered actions into one item.
|
||||
- Deduplicate only exact duplicate titles.
|
||||
- If nothing found, return {"items":[]}.
|
||||
- All human-readable text in Korean.`;
|
||||
@@ -746,7 +1074,7 @@ Priority (Action Items are as important as checklists — they are real work):
|
||||
* @returns {Promise<Array<{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }>>}
|
||||
*/
|
||||
async function extractChecklistStructured(openai, { minutesMarkdown, uiModel, resolveApiModel }) {
|
||||
const apiModel = resolveApiModel(uiModel || "gpt-5-mini");
|
||||
const apiModel = resolveApiModel(uiModel || "gpt-5.4");
|
||||
const text = (minutesMarkdown || "").trim();
|
||||
if (!text) return [];
|
||||
const completion = await openai.chat.completions.create({
|
||||
@@ -758,7 +1086,7 @@ async function extractChecklistStructured(openai, { minutesMarkdown, uiModel, re
|
||||
role: "user",
|
||||
content:
|
||||
"아래 회의록에서 업무 항목을 JSON으로 추출하세요.\n" +
|
||||
"액션 아이템(번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 번호마다 별도 item으로 나누세요.\n\n" +
|
||||
"액션 아이템(마크다운 표 또는 번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 행·번호마다 별도 item으로 나누세요.\n\n" +
|
||||
"---\n\n" +
|
||||
text,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ function loadXlsx() {
|
||||
}
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "data", "mgmt-perf-default-payload.json");
|
||||
const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "config", "mgmt-perf-default-payload.json");
|
||||
const FILE_STATE_PATH = path.join(ROOT, "data", "mgmt-perf-last-state.json");
|
||||
|
||||
function loadDefaultPayload() {
|
||||
|
||||
115
lib/ops-session-revoke.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* OPS 이메일 세션 전체 무효화(모든 디바이스 로그아웃)
|
||||
* 쿠키에 iat(발급 시각)를 넣고, DB sessions_revoked_at 이후 iat만 유효하게 한다.
|
||||
*/
|
||||
|
||||
const REVOKE_FILE = "ops-session-revocations.json";
|
||||
|
||||
/**
|
||||
* @param {string} email
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeEmail(email) {
|
||||
return String(email || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 320);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool | null | undefined} pgPool
|
||||
* @param {string} dataDir
|
||||
* @param {string} email
|
||||
* @returns {Promise<number>} revoked at epoch ms, 0 if never revoked
|
||||
*/
|
||||
async function getSessionsRevokedAtMs(pgPool, dataDir, email) {
|
||||
const e = normalizeEmail(email);
|
||||
if (!e) return 0;
|
||||
|
||||
if (pgPool) {
|
||||
try {
|
||||
const r = await pgPool.query(
|
||||
`SELECT sessions_revoked_at FROM ops_email_users WHERE email = $1`,
|
||||
[e]
|
||||
);
|
||||
const ts = r.rows?.[0]?.sessions_revoked_at;
|
||||
return ts ? new Date(ts).getTime() : 0;
|
||||
} catch (err) {
|
||||
console.error("[ops-session-revoke] get failed:", err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const fp = path.join(dataDir, REVOKE_FILE);
|
||||
if (!fs.existsSync(fp)) return 0;
|
||||
const raw = fs.readFileSync(fp, "utf8");
|
||||
const map = JSON.parse(raw);
|
||||
const v = map?.[e];
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool | null | undefined} pgPool
|
||||
* @param {string} dataDir
|
||||
* @param {string} email
|
||||
* @param {number} iatMs
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function isOpsSessionRevoked(pgPool, dataDir, email, iatMs) {
|
||||
const revokedAt = await getSessionsRevokedAtMs(pgPool, dataDir, email);
|
||||
if (!revokedAt) return false;
|
||||
const iat = typeof iatMs === "number" && Number.isFinite(iatMs) ? iatMs : 0;
|
||||
return iat <= revokedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool | null | undefined} pgPool
|
||||
* @param {string} dataDir
|
||||
* @param {string} email
|
||||
* @returns {Promise<{ revokedAtMs: number }>}
|
||||
*/
|
||||
async function revokeAllOpsSessionsForEmail(pgPool, dataDir, email) {
|
||||
const e = normalizeEmail(email);
|
||||
if (!e) {
|
||||
throw new Error("email is required");
|
||||
}
|
||||
const revokedAtMs = Date.now();
|
||||
|
||||
if (pgPool) {
|
||||
await pgPool.query(
|
||||
`INSERT INTO ops_email_users (email, first_seen_at, last_login_at, login_count, sessions_revoked_at)
|
||||
VALUES ($1, NOW(), NOW(), 0, to_timestamp($2 / 1000.0))
|
||||
ON CONFLICT (email) DO UPDATE SET sessions_revoked_at = EXCLUDED.sessions_revoked_at`,
|
||||
[e, revokedAtMs]
|
||||
);
|
||||
return { revokedAtMs };
|
||||
}
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const fp = path.join(dataDir, REVOKE_FILE);
|
||||
let map = {};
|
||||
try {
|
||||
if (fs.existsSync(fp)) {
|
||||
map = JSON.parse(fs.readFileSync(fp, "utf8")) || {};
|
||||
}
|
||||
} catch {
|
||||
map = {};
|
||||
}
|
||||
map[e] = revokedAtMs;
|
||||
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
||||
fs.writeFileSync(fp, JSON.stringify(map, null, 2), "utf8");
|
||||
return { revokedAtMs };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSessionsRevokedAtMs,
|
||||
isOpsSessionRevoked,
|
||||
revokeAllOpsSessionsForEmail,
|
||||
};
|
||||
@@ -21,7 +21,7 @@ function isOpsStateSuper() {
|
||||
return normalizeOpsState() === "SUPER";
|
||||
}
|
||||
|
||||
/** 임직원 이메일(@xavis.co.kr) 매직 링크 로그인을 강제하는 모드 (REAL 구 값 포함) */
|
||||
/** 임직원 이메일(@ncue.net) 매직 링크 로그인을 강제하는 모드 (REAL 구 값 포함) */
|
||||
function isOpsProdMode() {
|
||||
return isOpsStateProd();
|
||||
}
|
||||
|
||||
@@ -142,9 +142,16 @@ function parseBulletItems(body) {
|
||||
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();
|
||||
@@ -205,6 +212,95 @@ function parseBulletItems(body) {
|
||||
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
|
||||
@@ -329,6 +425,8 @@ function parseItemsFromMinutes(generatedMinutes, mode = "checklist") {
|
||||
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);
|
||||
@@ -345,6 +443,9 @@ module.exports = {
|
||||
extractActionSectionBody,
|
||||
parseBulletItems,
|
||||
parseNumberedActionBlocks,
|
||||
parseTableCells,
|
||||
parseTableActionRows,
|
||||
isTableSeparatorRow,
|
||||
parseItemsFromMinutes,
|
||||
parseAllRuleBasedWorkItems,
|
||||
CHECKLIST_HEADINGS,
|
||||
|
||||
334
lib/prompt-library.js
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 프롬프트 라이브러리(공식 JSON + DB 커뮤니티/좋아요)
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function maskEmailForDisplay(email) {
|
||||
const s = String(email || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const at = s.indexOf("@");
|
||||
if (at < 1) return "팀원";
|
||||
const local = s.slice(0, at);
|
||||
const dom = s.slice(at);
|
||||
if (local.length <= 2) {
|
||||
return `${local[0] || "*"}*${dom}`;
|
||||
}
|
||||
return `${local[0]}**${local.slice(-1)}${dom}`;
|
||||
}
|
||||
|
||||
/** @ 사인 앞 로컬부(예: spark_ai@ncue.net → spark_ai) — 라이브러리 카드·메타 표시용 */
|
||||
function emailLocalPartForDisplay(email) {
|
||||
const s = String(email || "").trim();
|
||||
if (!s) return "팀원";
|
||||
const at = s.indexOf("@");
|
||||
if (at < 1) {
|
||||
const t = s.slice(0, 80);
|
||||
return t || "팀원";
|
||||
}
|
||||
const local = s.slice(0, at);
|
||||
return (local && local.trim()) || "팀원";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pool
|
||||
* @param {Array<{id:string,title?:string,description?:string,tag?:string,body?:string}>} officialPrompts
|
||||
* @param {string | null} userEmail
|
||||
*/
|
||||
async function getLibraryData(pool, officialPrompts, userEmail) {
|
||||
const official = Array.isArray(officialPrompts) ? officialPrompts : [];
|
||||
const officialIds = official.map((p) => p.id).filter((id) => id && String(id).length < 200);
|
||||
|
||||
if (!pool) {
|
||||
return {
|
||||
hasDb: false,
|
||||
community: [],
|
||||
likeCountOfficial: {},
|
||||
likeCountCommunity: {},
|
||||
myOfficialLikes: [],
|
||||
myCommunityLikes: [],
|
||||
mySubmissions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [commRes, offCntRes, myLikesRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT id, author_email, title, description, body, tag, created_at, prompt_attachments, result_sample_attachments
|
||||
FROM prompt_community_entries
|
||||
WHERE is_published = true AND is_deleted = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300`
|
||||
),
|
||||
officialIds.length
|
||||
? pool.query(
|
||||
`SELECT target_id, COUNT(*)::int AS c
|
||||
FROM prompt_likes
|
||||
WHERE target_kind = 'official' AND target_id = ANY($1::text[])
|
||||
GROUP BY target_id`,
|
||||
[officialIds]
|
||||
)
|
||||
: Promise.resolve({ rows: [] }),
|
||||
userEmail
|
||||
? pool.query(
|
||||
`SELECT target_kind, target_id FROM prompt_likes WHERE user_email = $1`,
|
||||
[userEmail]
|
||||
)
|
||||
: Promise.resolve({ rows: [] }),
|
||||
]);
|
||||
|
||||
const commIds = commRes.rows.map((r) => r.id);
|
||||
let commCntRes = { rows: [] };
|
||||
if (commIds.length) {
|
||||
commCntRes = await pool.query(
|
||||
`SELECT target_id, COUNT(*)::int AS c
|
||||
FROM prompt_likes
|
||||
WHERE target_kind = 'community' AND target_id = ANY($1::text[])
|
||||
GROUP BY target_id`,
|
||||
[commIds.map((id) => String(id))]
|
||||
);
|
||||
}
|
||||
|
||||
const likeCountOfficial = {};
|
||||
for (const r of offCntRes.rows) {
|
||||
likeCountOfficial[r.target_id] = r.c;
|
||||
}
|
||||
const likeCountCommunity = {};
|
||||
for (const r of commCntRes.rows) {
|
||||
likeCountCommunity[r.target_id] = r.c;
|
||||
}
|
||||
|
||||
const myOfficialLikes = [];
|
||||
const myCommunityLikes = [];
|
||||
for (const r of myLikesRes.rows) {
|
||||
if (r.target_kind === "official") myOfficialLikes.push(r.target_id);
|
||||
else if (r.target_kind === "community") myCommunityLikes.push(r.target_id);
|
||||
}
|
||||
|
||||
let mySubmissions = [];
|
||||
if (userEmail) {
|
||||
const mine = await pool.query(
|
||||
`SELECT id, title, created_at
|
||||
FROM prompt_community_entries
|
||||
WHERE author_email = $1 AND is_deleted = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
[userEmail]
|
||||
);
|
||||
mySubmissions = (mine.rows || []).map((row) => ({
|
||||
id: String(row.id),
|
||||
title: (row.title || "").trim() || "제목 없음",
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
|
||||
}));
|
||||
}
|
||||
|
||||
const community = (commRes.rows || []).map((row) => {
|
||||
const parseFileJson = (raw) => {
|
||||
try {
|
||||
const j = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(j) ? j : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: String(row.id),
|
||||
title: (row.title || "").trim() || "제목 없음",
|
||||
description: String(row.description || "").trim(),
|
||||
body: String(row.body || ""),
|
||||
tag: (row.tag || "기타").trim() || "기타",
|
||||
authorLabel: emailLocalPartForDisplay(row.author_email),
|
||||
likeCount: likeCountCommunity[String(row.id)] || 0,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
|
||||
promptFiles: parseFileJson(row.prompt_attachments).filter((f) => f && f.relativePath),
|
||||
resultFiles: parseFileJson(row.result_sample_attachments).filter((f) => f && f.relativePath),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
hasDb: true,
|
||||
community,
|
||||
likeCountOfficial,
|
||||
likeCountCommunity,
|
||||
myOfficialLikes,
|
||||
myCommunityLikes,
|
||||
mySubmissions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pool
|
||||
* @param {string} userEmail
|
||||
* @param {string} kind
|
||||
* @param {string} targetId
|
||||
* @param {Set<string>} officialIdSet
|
||||
*/
|
||||
async function toggleLike(pool, userEmail, kind, targetId, officialIdSet) {
|
||||
if (!["official", "community"].includes(kind)) {
|
||||
const err = new Error("target_kind");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
const id = String(targetId || "").trim();
|
||||
if (!id) {
|
||||
const err = new Error("target_id");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
if (kind === "official" && !officialIdSet.has(id)) {
|
||||
const err = new Error("알 수 없는 공식 프롬프트입니다.");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
if (kind === "community" && !UUID_RE.test(id)) {
|
||||
const err = new Error("잘못된 ID입니다.");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
if (kind === "community") {
|
||||
const r = await pool.query(
|
||||
`SELECT 1 FROM prompt_community_entries WHERE id = $1::uuid AND is_deleted = false AND is_published = true LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
if (!r.rowCount) {
|
||||
const err = new Error("삭제되었거나 없는 프롬프트입니다.");
|
||||
err.code = "NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const del = await pool.query(
|
||||
`DELETE FROM prompt_likes
|
||||
WHERE user_email = $1 AND target_kind = $2 AND target_id = $3
|
||||
RETURNING id`,
|
||||
[userEmail, kind, id]
|
||||
);
|
||||
if (del.rowCount) {
|
||||
const cnt = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM prompt_likes WHERE target_kind = $1 AND target_id = $2`,
|
||||
[kind, id]
|
||||
);
|
||||
return { liked: false, likeCount: cnt.rows[0].c || 0 };
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO prompt_likes (user_email, target_kind, target_id) VALUES ($1, $2, $3)`,
|
||||
[userEmail, kind, id]
|
||||
);
|
||||
const cnt = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM prompt_likes WHERE target_kind = $1 AND target_id = $2`,
|
||||
[kind, id]
|
||||
);
|
||||
return { liked: true, likeCount: cnt.rows[0].c || 0 };
|
||||
}
|
||||
|
||||
const MAX_TITLE = 500;
|
||||
const MAX_DESC = 2000;
|
||||
const MAX_BODY = 50000;
|
||||
const MAX_TAG = 100;
|
||||
const MAX_ATTACH_PER_GROUP = 5;
|
||||
const MAX_ATTACH_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pool
|
||||
* @param {object} p
|
||||
* @param {string} p.authorEmail
|
||||
* @param {string} p.title
|
||||
* @param {string} p.description
|
||||
* @param {string} p.body
|
||||
* @param {string} p.tag
|
||||
* @param {Array<{ originalName: string, relativePath: string, size: number }>} [p.promptAttachments]
|
||||
* @param {Array<{ originalName: string, relativePath: string, size: number }>} [p.resultSampleAttachments]
|
||||
*/
|
||||
function normalizeFileList(arr, maxN) {
|
||||
if (!Array.isArray(arr) || !arr.length) return [];
|
||||
return arr
|
||||
.filter((a) => a && typeof a.relativePath === "string" && a.relativePath.startsWith("/uploads/"))
|
||||
.slice(0, maxN)
|
||||
.map((a) => ({
|
||||
originalName: String(a.originalName || "file").slice(0, 400),
|
||||
relativePath: String(a.relativePath).slice(0, 2000),
|
||||
size: Math.min(Number(a.size) || 0, MAX_ATTACH_BYTES * 2),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createCommunityEntry(pool, p) {
|
||||
const title = String(p.title || "")
|
||||
.trim()
|
||||
.slice(0, MAX_TITLE);
|
||||
const description = String(p.description || "")
|
||||
.trim()
|
||||
.slice(0, MAX_DESC);
|
||||
const body = String(p.body || "").trim();
|
||||
const tag = String(p.tag || "기타")
|
||||
.trim()
|
||||
.slice(0, MAX_TAG) || "기타";
|
||||
if (!title) {
|
||||
const e = new Error("제목을 입력해 주세요.");
|
||||
e.code = "VALIDATION";
|
||||
throw e;
|
||||
}
|
||||
if (body.length < 10) {
|
||||
const e = new Error("본문은 10자 이상 입력해 주세요.");
|
||||
e.code = "VALIDATION";
|
||||
throw e;
|
||||
}
|
||||
if (body.length > MAX_BODY) {
|
||||
const e = new Error(`본문은 ${MAX_BODY}자 이하여야 합니다.`);
|
||||
e.code = "VALIDATION";
|
||||
throw e;
|
||||
}
|
||||
const promptA = normalizeFileList(p.promptAttachments, MAX_ATTACH_PER_GROUP);
|
||||
const resultA = normalizeFileList(p.resultSampleAttachments, MAX_ATTACH_PER_GROUP);
|
||||
const r = await pool.query(
|
||||
`INSERT INTO prompt_community_entries (author_email, title, description, body, tag, prompt_attachments, result_sample_attachments)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
|
||||
RETURNING id, created_at`,
|
||||
[p.authorEmail, title, description, body, tag, JSON.stringify(promptA), JSON.stringify(resultA)]
|
||||
);
|
||||
const row = r.rows[0];
|
||||
return {
|
||||
id: String(row.id),
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
|
||||
promptFiles: promptA,
|
||||
resultFiles: resultA,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pool
|
||||
* @param {string} id
|
||||
* @param {string} authorEmail
|
||||
*/
|
||||
async function softDeleteCommunityEntry(pool, id, authorEmail) {
|
||||
if (!UUID_RE.test(String(id || "").trim())) {
|
||||
const e = new Error("잘못된 ID입니다.");
|
||||
e.code = "VALIDATION";
|
||||
throw e;
|
||||
}
|
||||
const r = await pool.query(
|
||||
`UPDATE prompt_community_entries
|
||||
SET is_deleted = true, is_published = false, updated_at = NOW()
|
||||
WHERE id = $1::uuid AND author_email = $2 AND is_deleted = false
|
||||
RETURNING id`,
|
||||
[id, authorEmail]
|
||||
);
|
||||
if (!r.rowCount) {
|
||||
const e = new Error("삭제할 수 없습니다. 본인이 올린 글만 삭제할 수 있습니다.");
|
||||
e.code = "NOT_FOUND";
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLibraryData,
|
||||
toggleLike,
|
||||
createCommunityEntry,
|
||||
softDeleteCommunityEntry,
|
||||
maskEmailForDisplay,
|
||||
emailLocalPartForDisplay,
|
||||
UUID_RE,
|
||||
MAX_ATTACH_PER_GROUP,
|
||||
MAX_ATTACH_BYTES,
|
||||
};
|
||||
65
lib/sanitize-use-case-body.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const sanitizeHtml = require("sanitize-html");
|
||||
|
||||
const SANITIZE_OPTIONS = {
|
||||
allowedTags: [
|
||||
"p",
|
||||
"br",
|
||||
"div",
|
||||
"span",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"del",
|
||||
"strike",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"code",
|
||||
"hr",
|
||||
"a",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tfoot",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "target", "rel", "name"],
|
||||
th: ["colspan", "rowspan", "align"],
|
||||
td: ["colspan", "rowspan", "align"],
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto", "tel"],
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => {
|
||||
const next = { ...attribs };
|
||||
if (next.target === "_blank") {
|
||||
next.rel = (next.rel || "noopener") + (next.rel && next.rel.indexOf("noreferrer") >= 0 ? "" : " noreferrer");
|
||||
}
|
||||
return { tagName, attribs: next };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeUseCaseBody(html) {
|
||||
if (html == null) return "";
|
||||
return sanitizeHtml(String(html), SANITIZE_OPTIONS);
|
||||
}
|
||||
|
||||
module.exports = { sanitizeUseCaseBody };
|
||||
20
lib/strip-for-count.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* HTML·태그·엔티티를 제외한 보이는 텍스트(글자 수 제한용)
|
||||
* — sanitize-html 없이 사용해 서버 기동 시 모듈 누락으로 전체 앱이 죽는 것을 막습니다.
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
function stripForCount(html) {
|
||||
if (!html) return "";
|
||||
return String(html)
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&#[0-9]+;/g, " ")
|
||||
.replace(/&[a-zA-Z0-9]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
module.exports = { stripForCount };
|
||||
136
ops-auth.js
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* OPS_STATE=PROD(구 REAL) 일 때 이메일(@xavis.co.kr) 매직 링크 인증
|
||||
* OPS_STATE=PROD(구 REAL) 일 때 이메일(@ncue.net) 매직 링크 인증
|
||||
*/
|
||||
const crypto = require("crypto");
|
||||
const { isOpsProdMode } = require("./lib/ops-state");
|
||||
@@ -15,10 +15,16 @@ try {
|
||||
}
|
||||
|
||||
const OPS_AUTH_COOKIE = "ops_user_session";
|
||||
const ALLOWED_EMAIL_SUFFIX = "@xavis.co.kr";
|
||||
const ALLOWED_EMAIL_SUFFIX = "@ncue.net";
|
||||
/** 메일 안 인증 링크 유효 시간(토큰 만료) */
|
||||
const MAGIC_LINK_MAX_AGE_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 만기 없음 모드일 때 Set-Cookie max-age 상한(브라우저·HTTP 한도; 서명 payload의 exp는 Number.MAX_SAFE_INTEGER)
|
||||
* 10년마다 쿠키가 사라지면 다시 이메일 인증(매직 링크)이 필요할 수 있음
|
||||
*/
|
||||
const OPS_SESSION_BROWSER_MAX_AGE_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** OPS_SESSION_TZ 기준 달력 날짜 키 (YYYY-MM-DD) */
|
||||
function calendarDateKeyInTz(tsMs, tz) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
@@ -80,16 +86,33 @@ function getLastMsOfCalendarDayInTz(dateKey, tz) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 세션 만료: 로그인일(OPS_SESSION_TZ 달력) + OPS_SESSION_TTL_DAYS(기본 15)일의 마지막 순간
|
||||
* 로그인 세션 만료 시각(ms)
|
||||
* - OPS_SESSION_TTL_DAYS: 미설정·0·never·none → 만기 없음(서명 exp = Number.MAX_SAFE_INTEGER)
|
||||
* - 양의 정수 N → 로그인일(OPS_SESSION_TZ 달력) + N일의 마지막 순간
|
||||
*/
|
||||
function getOpsSessionExpiresAtMs(nowMs = Date.now()) {
|
||||
const tz = (process.env.OPS_SESSION_TZ || "Asia/Seoul").trim() || "Asia/Seoul";
|
||||
const raw = (process.env.OPS_SESSION_TTL_DAYS || "15").trim();
|
||||
const raw = String(process.env.OPS_SESSION_TTL_DAYS ?? "0").trim() || "0";
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower === "never" || lower === "none" || raw === "0") {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
const ttlDays = Number.isFinite(parsed) && parsed >= 0 ? parsed : 15;
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
const loginDayKey = calendarDateKeyInTz(nowMs, tz);
|
||||
const targetKey = addCalendarDaysToKey(loginDayKey, ttlDays);
|
||||
const targetKey = addCalendarDaysToKey(loginDayKey, parsed);
|
||||
return getLastMsOfCalendarDayInTz(targetKey, tz);
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/** res.cookie maxAge(ms) — 만기 없음일 때 astronomically large 값 대신 브라우저에 맞는 상한 */
|
||||
function getOpsSessionCookieMaxAgeMs(sessionExpMs) {
|
||||
const remain = Math.max(0, sessionExpMs - Date.now());
|
||||
if (sessionExpMs === Number.MAX_SAFE_INTEGER) {
|
||||
return Math.min(remain, OPS_SESSION_BROWSER_MAX_AGE_MS);
|
||||
}
|
||||
return remain;
|
||||
}
|
||||
|
||||
function isOpsProd() {
|
||||
@@ -97,7 +120,7 @@ function isOpsProd() {
|
||||
}
|
||||
|
||||
function getAuthSecret() {
|
||||
return (process.env.AUTH_SECRET || process.env.ADMIN_TOKEN || "xavis-admin").trim();
|
||||
return (process.env.AUTH_SECRET || process.env.ADMIN_TOKEN || "ncue-admin").trim();
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
@@ -119,9 +142,38 @@ function buildMagicVerifyUrl(baseUrl, token) {
|
||||
return new URL(path, `${base}/`).href;
|
||||
}
|
||||
|
||||
const RETURN_TO_DEFAULT = "/learning";
|
||||
|
||||
/**
|
||||
* 인증 후·로그인 returnTo — 상대 경로만 허용(오픈 리다이렉트·한글 조사 등 오염 차단)
|
||||
* @param {unknown} v
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeReturnTo(v) {
|
||||
const s = (v || "").toString().trim();
|
||||
if (!s.startsWith("/") || s.startsWith("//")) return "/learning";
|
||||
if (!s.startsWith("/") || s.startsWith("//")) return RETURN_TO_DEFAULT;
|
||||
if (s.includes("\\") || s.includes("..")) return RETURN_TO_DEFAULT;
|
||||
|
||||
const qIdx = s.indexOf("?");
|
||||
const hIdx = s.indexOf("#");
|
||||
let pathEnd = s.length;
|
||||
if (qIdx >= 0) pathEnd = Math.min(pathEnd, qIdx);
|
||||
if (hIdx >= 0) pathEnd = Math.min(pathEnd, hIdx);
|
||||
const pathPart = s.slice(0, pathEnd);
|
||||
const suffix = s.slice(pathEnd);
|
||||
|
||||
if (pathPart !== "/" && !/^\/[a-zA-Z0-9][a-zA-Z0-9/_\-.]*$/.test(pathPart)) {
|
||||
if (s !== RETURN_TO_DEFAULT) {
|
||||
console.warn("[OPS] invalid returnTo path rejected:", s.slice(0, 160));
|
||||
}
|
||||
return RETURN_TO_DEFAULT;
|
||||
}
|
||||
if (suffix && !/^[?#][a-zA-Z0-9_\-=&.%+]*$/.test(suffix)) {
|
||||
if (s !== RETURN_TO_DEFAULT) {
|
||||
console.warn("[OPS] invalid returnTo query rejected:", s.slice(0, 160));
|
||||
}
|
||||
return RETURN_TO_DEFAULT;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -138,11 +190,12 @@ function isAllowedXavisEmail(email) {
|
||||
return s.endsWith(ALLOWED_EMAIL_SUFFIX);
|
||||
}
|
||||
|
||||
function signSessionCookie(email, expMs) {
|
||||
function signSessionCookie(email, expMs, iatMs = Date.now()) {
|
||||
const exp = expMs;
|
||||
const payload = `${email}|${exp}`;
|
||||
const iat = iatMs;
|
||||
const payload = `${email}|${exp}|${iat}`;
|
||||
const sig = crypto.createHmac("sha256", getAuthSecret()).update(payload).digest("hex");
|
||||
return Buffer.from(JSON.stringify({ email, exp, sig })).toString("base64url");
|
||||
return Buffer.from(JSON.stringify({ email, exp, iat, sig })).toString("base64url");
|
||||
}
|
||||
|
||||
function parseSessionCookie(val) {
|
||||
@@ -151,17 +204,41 @@ function parseSessionCookie(val) {
|
||||
const j = JSON.parse(Buffer.from(val, "base64url").toString("utf8"));
|
||||
if (!j.email || typeof j.exp !== "number") return null;
|
||||
if (Date.now() > j.exp) return null;
|
||||
const payload = `${j.email}|${j.exp}`;
|
||||
const sig = crypto.createHmac("sha256", getAuthSecret()).update(payload).digest("hex");
|
||||
if (sig !== j.sig) return null;
|
||||
return String(j.email).toLowerCase();
|
||||
const email = String(j.email).toLowerCase();
|
||||
const exp = j.exp;
|
||||
const iat = typeof j.iat === "number" ? j.iat : 0;
|
||||
const payloadNew = `${email}|${exp}|${iat}`;
|
||||
const sigNew = crypto.createHmac("sha256", getAuthSecret()).update(payloadNew).digest("hex");
|
||||
if (sigNew === j.sig) {
|
||||
return { email, exp, iat };
|
||||
}
|
||||
const payloadLegacy = `${email}|${exp}`;
|
||||
const sigLegacy = crypto.createHmac("sha256", getAuthSecret()).update(payloadLegacy).digest("hex");
|
||||
if (sigLegacy === j.sig) {
|
||||
return { email, exp, iat: 0 };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getOpsSessionEmail(req) {
|
||||
return parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]);
|
||||
const session = parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]);
|
||||
return session ? session.email : null;
|
||||
}
|
||||
|
||||
async function resolveOpsSessionEmail(req, hooks) {
|
||||
const session = parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]);
|
||||
if (!session) return null;
|
||||
if (typeof hooks.isSessionRevoked === "function") {
|
||||
const revoked = await hooks.isSessionRevoked({
|
||||
email: session.email,
|
||||
iatMs: session.iat,
|
||||
});
|
||||
if (revoked) return null;
|
||||
}
|
||||
return session.email;
|
||||
}
|
||||
|
||||
function loadMagicLinks(MAGIC_LINK_PATH) {
|
||||
@@ -303,7 +380,7 @@ async function sendMagicLinkEmail(to, linkUrl) {
|
||||
console.warn("[OPS] Magic link for", to, "→", linkUrl);
|
||||
return;
|
||||
}
|
||||
const from = (process.env.SMTP_FROM || (process.env.SMTP_USER || "").trim() || "noreply@xavis.co.kr").trim();
|
||||
const from = (process.env.SMTP_FROM || (process.env.SMTP_USER || "").trim() || "noreply@ncue.net").trim();
|
||||
const transporter = createSmtpTransport();
|
||||
const linkMinutes = Math.max(1, Math.floor(MAGIC_LINK_MAX_AGE_MS / 60000));
|
||||
const textBody = [
|
||||
@@ -344,7 +421,8 @@ module.exports = function createOpsAuth(DATA_DIR, hooks = {}) {
|
||||
if (isOpsPublicPath(req)) {
|
||||
return next();
|
||||
}
|
||||
const email = getOpsSessionEmail(req);
|
||||
resolveOpsSessionEmail(req, hooks)
|
||||
.then((email) => {
|
||||
if (email) {
|
||||
res.locals.opsUserEmail = email;
|
||||
return next();
|
||||
@@ -354,14 +432,23 @@ module.exports = function createOpsAuth(DATA_DIR, hooks = {}) {
|
||||
}
|
||||
const returnTo = req.originalUrl || "/learning";
|
||||
return res.redirect("/login?returnTo=" + encodeURIComponent(returnTo));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[OPS] session resolve failed:", err?.message || err);
|
||||
if (req.path.startsWith("/api/")) {
|
||||
return res.status(500).json({ error: "인증 확인에 실패했습니다." });
|
||||
}
|
||||
return res.redirect("/login");
|
||||
});
|
||||
}
|
||||
|
||||
function registerRoutes(app) {
|
||||
app.get("/login", (req, res) => {
|
||||
app.get("/login", async (req, res) => {
|
||||
if (!isOpsProd()) {
|
||||
return res.redirect("/learning");
|
||||
}
|
||||
if (getOpsSessionEmail(req)) {
|
||||
const email = await resolveOpsSessionEmail(req, hooks);
|
||||
if (email) {
|
||||
return res.redirect(sanitizeReturnTo(req.query.returnTo));
|
||||
}
|
||||
return res.render("login", {
|
||||
@@ -371,7 +458,7 @@ module.exports = function createOpsAuth(DATA_DIR, hooks = {}) {
|
||||
});
|
||||
|
||||
app.get("/logout", async (req, res) => {
|
||||
const sessionEmail = getOpsSessionEmail(req);
|
||||
const sessionEmail = await resolveOpsSessionEmail(req, hooks);
|
||||
if (typeof hooks.onLogout === "function" && sessionEmail) {
|
||||
try {
|
||||
await hooks.onLogout({ email: sessionEmail, req });
|
||||
@@ -464,16 +551,17 @@ module.exports = function createOpsAuth(DATA_DIR, hooks = {}) {
|
||||
}
|
||||
}
|
||||
const sessionExp = getOpsSessionExpiresAtMs();
|
||||
const cookieVal = signSessionCookie(row.email, sessionExp);
|
||||
const issuedAt = Date.now();
|
||||
const cookieVal = signSessionCookie(row.email, sessionExp, issuedAt);
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
res.cookie(OPS_AUTH_COOKIE, cookieVal, {
|
||||
httpOnly: true,
|
||||
maxAge: Math.max(0, sessionExp - Date.now()),
|
||||
maxAge: getOpsSessionCookieMaxAgeMs(sessionExp),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure,
|
||||
});
|
||||
const dest = appendVerifiedParam(sanitizeReturnTo(row.returnTo || "/learning"));
|
||||
const dest = appendVerifiedParam(row.returnTo || RETURN_TO_DEFAULT);
|
||||
return res.redirect(dest);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js",
|
||||
"db:schema": "node scripts/apply-schema.js",
|
||||
"db:backup": "bash scripts/pg-backup.sh",
|
||||
"db:restore": "bash scripts/pg-restore.sh",
|
||||
"db:migrate-from-ncue": "node scripts/migrate-db-ncue-to-env.js",
|
||||
"db:normalize-meeting-minutes": "node scripts/normalize-stored-meeting-minutes.js",
|
||||
"ai-success:merge-orphans": "node scripts/merge-orphan-ai-success-stories.js",
|
||||
"test": "echo \"No tests configured\"",
|
||||
"test:ax-apply": "node scripts/test-ax-apply.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.ncue.net/xavis/webplatform.git"
|
||||
"url": "https://git.xavis.co.kr/AI_Innovation_Team/ai_platform.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -34,6 +37,7 @@
|
||||
"nodemailer": "^6.10.1",
|
||||
"openai": "^6.29.0",
|
||||
"pg": "^8.20.0",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"uuid": "^13.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
|
||||
386
public/resources/fscan/fscan-selector-v1.html
Normal file
@@ -0,0 +1,386 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FSCAN 시리즈 제품 선정 도우미</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; background: #f0f4f8; min-height: 100vh; padding: 20px 12px; }
|
||||
.container { max-width: 680px; margin: 0 auto; }
|
||||
|
||||
.header { background: #1a237e; color: white; padding: 18px 24px; border-radius: 12px; margin-bottom: 20px; display: flex; align-items: center; gap: 16px; }
|
||||
.header-logo { height: 36px; width: auto; flex-shrink: 0; background: white; border-radius: 6px; padding: 4px 8px; margin-left: auto; order: 1; }
|
||||
.header-text h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.header-text p { font-size: 0.8rem; opacity: 0.75; margin-top: 4px; }
|
||||
|
||||
.card { background: white; border-radius: 12px; padding: 22px; margin-bottom: 16px; box-shadow: 0 1px 6px rgba(0,0,0,0.08); }
|
||||
|
||||
.diagram-wrap { text-align: center; margin-bottom: 20px; }
|
||||
.diagram-wrap svg { max-width: 260px; }
|
||||
|
||||
.input-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 0.82rem; font-weight: 700; color: #374151; margin-bottom: 5px; }
|
||||
.field .sub { font-size: 0.72rem; color: #9ca3af; margin-bottom: 5px; }
|
||||
.input-wrap { position: relative; }
|
||||
.input-wrap input {
|
||||
width: 100%; padding: 11px 40px 11px 14px;
|
||||
border: 2px solid #e5e7eb; border-radius: 8px;
|
||||
font-size: 1.05rem; font-family: inherit; outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.input-wrap input:focus { border-color: #3b82f6; }
|
||||
.input-wrap .unit { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 0.8rem; color: #9ca3af; pointer-events: none; }
|
||||
|
||||
.btn {
|
||||
width: 100%; padding: 13px;
|
||||
background: #1a237e; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
font-size: 0.95rem; font-weight: 700; font-family: inherit;
|
||||
cursor: pointer; transition: background 0.15s;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.btn:hover { background: #283593; }
|
||||
.btn:active { background: #0d1757; }
|
||||
|
||||
.result-header { font-size: 0.95rem; font-weight: 700; color: #111827; margin-bottom: 12px; }
|
||||
.result-sub { font-size: 0.78rem; color: #6b7280; font-weight: 400; margin-left: 6px; }
|
||||
.model-list { display: grid; gap: 9px; }
|
||||
|
||||
.model-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 14px; border-radius: 8px;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
.model-item.rank1 { border-color: #16a34a; background: #f0fdf4; }
|
||||
.model-item.rank2 { border-color: #2563eb; background: #eff6ff; }
|
||||
.model-item.rank3 { border-color: #9ca3af; background: #f9fafb; }
|
||||
|
||||
.badge {
|
||||
min-width: 42px; height: 42px; border-radius: 7px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
font-size: 0.68rem; font-weight: 700; flex-shrink: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.rank1 .badge { background: #16a34a; color: white; }
|
||||
.rank2 .badge { background: #2563eb; color: white; }
|
||||
.rank3 .badge { background: #6b7280; color: white; }
|
||||
|
||||
.model-name { font-size: 0.95rem; font-weight: 700; color: #111827; }
|
||||
.model-detail { font-size: 0.76rem; color: #6b7280; margin-top: 2px; }
|
||||
|
||||
.no-result { text-align: center; padding: 36px 20px; color: #6b7280; }
|
||||
.no-result .icon { font-size: 2.5rem; margin-bottom: 10px; }
|
||||
.no-result p { font-size: 0.88rem; }
|
||||
|
||||
.note { font-size: 0.7rem; color: #9ca3af; text-align: center; margin-top: 12px; padding: 0 8px; line-height: 1.5; }
|
||||
|
||||
.tab-wrap { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.tab-btn {
|
||||
flex: 1; padding: 9px 6px; border-radius: 7px; border: 2px solid #e5e7eb;
|
||||
background: white; font-family: inherit; font-size: 0.82rem; font-weight: 700;
|
||||
color: #6b7280; cursor: pointer; transition: all 0.15s;
|
||||
line-height: 1.35; white-space: normal; word-break: keep-all;
|
||||
}
|
||||
.tab-btn.active { background: #1a237e; border-color: #1a237e; color: white; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
.model-select-wrap { margin-bottom: 14px; }
|
||||
.model-select-wrap label { display: block; font-size: 0.82rem; font-weight: 700; color: #374151; margin-bottom: 6px; }
|
||||
.model-select-wrap select {
|
||||
width: 100%; padding: 10px 14px; border: 2px solid #e5e7eb; border-radius: 8px;
|
||||
font-size: 0.95rem; font-family: inherit; outline: none; background: white;
|
||||
cursor: pointer; transition: border-color 0.15s;
|
||||
}
|
||||
.model-select-wrap select:focus { border-color: #3b82f6; }
|
||||
|
||||
.chart-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
||||
.chart-table th { background: #1a237e; color: white; padding: 8px 12px; text-align: center; font-weight: 700; position: sticky; top: 0; }
|
||||
.chart-table td { padding: 6px 12px; text-align: center; border-bottom: 1px solid #f3f4f6; }
|
||||
.chart-table tr:hover td { background: #f0f4ff; }
|
||||
.chart-table tr.hl td { background: #fef9c3; font-weight: 700; }
|
||||
.chart-table td.h-col { color: #374151; font-weight: 600; }
|
||||
.chart-table td.w-val { color: #1a237e; font-weight: 700; }
|
||||
.table-scroll { max-height: 360px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||
|
||||
.chart-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
|
||||
.chart-field label { display: block; font-size: 0.82rem; font-weight: 700; color: #374151; margin-bottom: 5px; }
|
||||
|
||||
.chart-summary { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 8px; margin-bottom: 12px; font-size: 0.85rem; }
|
||||
.chart-summary.ok { background: #f0fdf4; border: 1.5px solid #86efac; }
|
||||
.chart-summary.ng { background: #fff1f2; border: 1.5px solid #fca5a5; }
|
||||
.chart-summary.na { background: #f9fafb; border: 1.5px solid #e5e7eb; color: #6b7280; }
|
||||
.sum-icon { font-size: 1.2rem; flex-shrink: 0; }
|
||||
.sum-icon.ok { color: #16a34a; }
|
||||
.sum-icon.ng { color: #dc2626; }
|
||||
|
||||
.fit-ok { color: #16a34a; font-weight: 700; font-size: 0.8rem; }
|
||||
.fit-ng { color: #dc2626; font-weight: 700; font-size: 0.8rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img class="header-logo" src="/public/images/xavis-logo.png" alt="Xavis">
|
||||
<div class="header-text">
|
||||
<h1>FSCAN 시리즈 제품 선정 도우미</h1>
|
||||
<p>검사 대상물의 H · W 치수를 입력하여 적합한 모델을 선정합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="tab-wrap">
|
||||
<button class="tab-btn active" onclick="switchTab('search')">검사 대상물 치수로 모델 찾기</button>
|
||||
<button class="tab-btn" onclick="switchTab('chart')">모델별 치수 조회 · 적합 확인</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-search" class="tab-content active">
|
||||
<div class="diagram-wrap">
|
||||
<svg viewBox="0 0 280 180" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="60,125 220,125 200,30 80,30" fill="#e8f0fe" stroke="#1a237e" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<line x1="240" y1="30" x2="240" y2="125" stroke="#dc2626" stroke-width="2"/>
|
||||
<polygon points="240,26 237,36 243,36" fill="#dc2626"/>
|
||||
<polygon points="240,129 237,119 243,119" fill="#dc2626"/>
|
||||
<text x="252" y="82" fill="#dc2626" font-size="16" font-weight="bold" font-family="Arial">H</text>
|
||||
<line x1="60" y1="152" x2="220" y2="152" stroke="#dc2626" stroke-width="2"/>
|
||||
<polygon points="56,152 66,149 66,155" fill="#dc2626"/>
|
||||
<polygon points="224,152 214,149 214,155" fill="#dc2626"/>
|
||||
<text x="134" y="175" fill="#dc2626" font-size="16" font-weight="bold" font-family="Arial" text-anchor="middle">W</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="field">
|
||||
<label>H (높이)</label>
|
||||
<div class="sub">검사 대상물 높이</div>
|
||||
<div class="input-wrap">
|
||||
<input type="number" id="iH" placeholder="예: 120" min="0" max="500" step="1">
|
||||
<span class="unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>W (폭)</label>
|
||||
<div class="sub">검사 대상물 폭</div>
|
||||
<div class="input-wrap">
|
||||
<input type="number" id="iW" placeholder="예: 280" min="0" step="1">
|
||||
<span class="unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="run()">모델 찾기</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-chart" class="tab-content">
|
||||
<div class="model-select-wrap">
|
||||
<label>모델 선택</label>
|
||||
<select id="modelSelect" onchange="showModelChart()">
|
||||
<option value="">-- 모델을 선택하세요 --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="chart-inputs">
|
||||
<div class="chart-field">
|
||||
<label>H (높이) <span style="font-weight:400;color:#9ca3af">선택</span></label>
|
||||
<div class="input-wrap">
|
||||
<input type="number" id="chartH" placeholder="예: 120" min="0" max="500" step="1" oninput="showModelChart()">
|
||||
<span class="unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-field">
|
||||
<label>W (폭) <span style="font-weight:400;color:#9ca3af">선택</span></label>
|
||||
<div class="input-wrap">
|
||||
<input type="number" id="chartW" placeholder="예: 280" min="0" step="1" oninput="showModelChart()">
|
||||
<span class="unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modelChartResult"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
<div class="note">※ 이미지 기준표에서 추출한 데이터입니다. 최종 확인은 원본 FSCAN-D 기준표를 참조하세요.</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const MODELS = [
|
||||
'1880','3280D','4280D','4280K','4280DH','4350G',
|
||||
'4500L','4500D','4500DH','6280D',
|
||||
'6350G(350w)','6350G(500w)',
|
||||
'6500D','6500DH','6500D(H2)','7500D','9500D'
|
||||
];
|
||||
const T = [
|
||||
{h:0, w:[217,231,369,369,384,394,377,389,392,573,603,618,583,587,589,694,954]},
|
||||
{h:10, w:[205,224,357,357,377,386,366,381,386,561,595,606,574,579,583,681,939]},
|
||||
{h:20, w:[192,216,346,346,369,378,356,374,379,549,587,595,565,571,577,668,924]},
|
||||
{h:30, w:[180,209,334,334,362,370,345,367,372,537,580,583,556,563,571,605,909]},
|
||||
{h:40, w:[168,202,323,323,354,362,334,359,365,525,572,572,546,555,565,642,894]},
|
||||
{h:50, w:[155,195,311,311,347,354,323,352,361,513,564,560,537,547,559,629,879]},
|
||||
{h:60, w:[143,187,299,299,340,346,313,345,355,501,556,549,528,539,553,617,864]},
|
||||
{h:70, w:[null,180,288,288,332,338,302,338,349,489,549,537,519,531,547,604,849]},
|
||||
{h:80, w:[null,173,276,276,325,330,291,330,343,477,541,525,510,523,541,591,834]},
|
||||
{h:90, w:[null,165,265,265,317,322,280,323,337,466,533,514,501,515,535,578,819]},
|
||||
{h:100, w:[null,158,253,253,310,314,270,316,330,454,525,502,491,507,529,565,804]},
|
||||
{h:110, w:[null,151,241,241,302,306,259,308,324,442,517,491,482,499,523,552,789]},
|
||||
{h:120, w:[null,144,230,230,295,298,248,301,318,430,510,479,473,491,517,540,774]},
|
||||
{h:130, w:[null,136,218,218,288,290,237,294,312,418,502,468,464,483,511,527,760]},
|
||||
{h:140, w:[null,129,207,207,280,282,227,287,306,406,494,456,455,475,505,514,745]},
|
||||
{h:145, w:[null,125,200,200,276,278,221,282,302,399,490,450,450,470,502,508,737]},
|
||||
{h:150, w:[null,122,195,195,273,274,216,279,299,394,486,444,446,467,499,501,730]},
|
||||
{h:155, w:[null,118,189,189,269,270,210,275,296,387,482,439,440,462,496,495,722]},
|
||||
{h:160, w:[null,115,183,183,265,266,205,272,293,382,478,433,436,459,493,488,715]},
|
||||
{h:165, w:[null,111,177,177,261,262,199,268,290,376,475,427,431,454,490,482,707]},
|
||||
{h:170, w:[null,107,172,172,258,258,194,265,287,370,471,421,427,451,487,475,700]},
|
||||
{h:175, w:[null,103,166,null,254,254,188,261,283,364,467,415,422,446,484,469,692]},
|
||||
{h:180, w:[null,100,160,null,251,250,184,257,281,358,463,410,418,443,481,463,685]},
|
||||
{h:185, w:[null,null,154,null,246,246,178,253,277,352,459,404,413,438,479,457,677]},
|
||||
{h:190, w:[null,null,148,null,243,242,173,250,274,346,455,398,409,435,476,450,669]},
|
||||
{h:195, w:[null,null,null,null,239,238,167,246,271,340,451,392,404,430,473,444,662]},
|
||||
{h:200, w:[null,null,null,null,236,234,162,243,268,334,447,386,400,427,470,437,655]},
|
||||
{h:205, w:[null,null,null,null,232,null,null,null,265,328,443,381,395,422,467,431,647]},
|
||||
{h:210, w:[null,null,null,null,228,null,null,null,262,322,440,375,391,419,464,424,640]},
|
||||
{h:215, w:[null,null,null,null,224,null,null,null,259,316,436,369,385,414,461,418,632]},
|
||||
{h:220, w:[null,null,null,null,221,null,null,null,256,311,432,363,381,411,458,411,625]},
|
||||
{h:225, w:[null,null,null,null,217,null,null,null,252,304,428,358,376,406,455,null,617]},
|
||||
{h:230, w:[null,null,null,null,213,null,null,null,250,299,424,352,372,403,452,null,610]},
|
||||
{h:235, w:[null,null,null,null,209,null,null,null,246,292,420,346,367,398,449,null,602]},
|
||||
{h:240, w:[null,null,null,null,206,null,null,null,244,287,416,340,363,395,446,null,595]},
|
||||
{h:245, w:[null,null,null,null,202,null,null,null,240,280,412,334,358,390,443,null,587]},
|
||||
{h:250, w:[null,null,null,null,199,null,null,null,237,275,408,329,354,387,440,null,580]},
|
||||
{h:255, w:[null,null,null,null,null,null,null,null,234,268,404,323,349,382,437,null,null]},
|
||||
{h:260, w:[null,null,null,null,null,null,null,null,228,256,401,317,340,374,434,null,null]},
|
||||
{h:270, w:[null,null,null,null,null,null,null,null,221,244,393,305,330,366,428,null,null]},
|
||||
{h:280, w:[null,null,null,null,null,null,null,null,215,232,null,null,321,358,422,null,null]},
|
||||
{h:290, w:[null,null,null,null,null,null,null,null,209,220,null,null,312,350,416,null,null]},
|
||||
{h:300, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,342,410,null,null]},
|
||||
{h:310, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,334,404,null,null]},
|
||||
{h:320, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,326,398,null,null]},
|
||||
{h:330, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,318,392,null,null]},
|
||||
{h:340, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,310,386,null,null]},
|
||||
{h:350, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,302,380,null,null]},
|
||||
{h:360, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,294,374,null,null]},
|
||||
{h:370, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,286,368,null,null]},
|
||||
{h:380, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,278,362,null,null]},
|
||||
{h:390, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,270,356,null,null]},
|
||||
{h:400, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,267,350,null,null]},
|
||||
{h:410, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,344,null,null]},
|
||||
{h:420, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,338,null,null]},
|
||||
{h:430, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,332,null,null]},
|
||||
{h:440, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,326,null,null]},
|
||||
{h:450, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,320,null,null]},
|
||||
{h:460, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,314,null,null]},
|
||||
{h:470, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,308,null,null]},
|
||||
{h:480, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,302,null,null]},
|
||||
{h:490, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,296,null,null]},
|
||||
{h:500, w:[null,null,null,null,null,null,null,null,null,null,null,null,null,null,291,null,null]}
|
||||
];
|
||||
|
||||
function getMaxW(modelIdx, h) {
|
||||
let lo = null, hi = null;
|
||||
for (const row of T) {
|
||||
if (row.h <= h) lo = row;
|
||||
if (row.h >= h && hi === null) hi = row;
|
||||
}
|
||||
if (!lo && !hi) return null;
|
||||
lo = lo || hi;
|
||||
hi = hi || lo;
|
||||
const lv = lo.w[modelIdx];
|
||||
const hv = hi.w[modelIdx];
|
||||
if (lv === null && hv === null) return null;
|
||||
if (lv === null) return hv;
|
||||
if (hv === null) return lv;
|
||||
if (lo.h === hi.h) return lv;
|
||||
const t = (h - lo.h) / (hi.h - lo.h);
|
||||
return lv + t * (hv - lv);
|
||||
}
|
||||
|
||||
function run() {
|
||||
const h = parseFloat(document.getElementById('iH').value);
|
||||
const w = parseFloat(document.getElementById('iW').value);
|
||||
const el = document.getElementById('result');
|
||||
if (isNaN(h) || isNaN(w) || h < 0 || w <= 0) {
|
||||
el.innerHTML = '<div class="card"><p style="color:#dc2626;font-size:.85rem;text-align:center">H와 W를 올바르게 입력해주세요 (H: 0~500mm)</p></div>';
|
||||
return;
|
||||
}
|
||||
if (h > 500) {
|
||||
el.innerHTML = '<div class="card"><p style="color:#dc2626;font-size:.85rem;text-align:center">H는 최대 500mm까지 지원합니다.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const hits = [];
|
||||
for (let i = 0; i < MODELS.length; i++) {
|
||||
const maxW = getMaxW(i, h);
|
||||
if (maxW !== null && maxW >= w) hits.push({ name: MODELS[i], maxW: Math.round(maxW), margin: Math.round(maxW - w) });
|
||||
}
|
||||
hits.sort((a, b) => a.margin - b.margin);
|
||||
|
||||
if (!hits.length) {
|
||||
el.innerHTML = '<div class="card"><div class="no-result"><div class="icon">⚠️</div><p><b>적합한 모델이 없습니다.</b></p><p style="margin-top:6px;font-size:.78rem;color:#9ca3af">치수를 다시 확인하거나 영업팀에 문의하세요.</p></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rankClass = (i) => (i === 0 ? 'rank1' : i === 1 ? 'rank2' : 'rank3');
|
||||
const rankLabel = (i) => (i === 0 ? ['최적','모델'] : i === 1 ? ['차선','모델'] : ['적합','모델']);
|
||||
const rows = hits.map((r, i) => {
|
||||
const lab = rankLabel(i);
|
||||
return '<div class="model-item ' + rankClass(i) + '"><div class="badge"><span>' + lab[0] + '</span><span>' + lab[1] + '</span></div><div><div class="model-name">FSCAN-' + r.name + '</div><div class="model-detail">최대 W: ' + r.maxW + 'mm | 여유: +' + r.margin + 'mm</div></div></div>';
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = '<div class="card"><div class="result-header">적합 모델 ' + hits.length + '종<span class="result-sub">H=' + h + 'mm · W=' + w + 'mm 기준</span></div><div class="model-list">' + rows + '</div></div>';
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => b.classList.toggle('active', (i === 0) === (tab === 'search')));
|
||||
document.getElementById('tab-search').classList.toggle('active', tab === 'search');
|
||||
document.getElementById('tab-chart').classList.toggle('active', tab === 'chart');
|
||||
document.getElementById('result').style.display = tab === 'search' ? '' : 'none';
|
||||
}
|
||||
|
||||
(function initSelect() {
|
||||
const sel = document.getElementById('modelSelect');
|
||||
MODELS.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = 'FSCAN-' + m;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
})();
|
||||
|
||||
function showModelChart() {
|
||||
const modelName = document.getElementById('modelSelect').value;
|
||||
const el = document.getElementById('modelChartResult');
|
||||
if (!modelName) { el.innerHTML = ''; return; }
|
||||
const idx = MODELS.indexOf(modelName);
|
||||
const rows = T.filter(row => row.w[idx] !== null);
|
||||
const inputH = parseFloat(document.getElementById('chartH').value);
|
||||
const inputW = parseFloat(document.getElementById('chartW').value);
|
||||
const hasH = !isNaN(inputH) && inputH >= 0;
|
||||
const hasW = !isNaN(inputW) && inputW > 0;
|
||||
|
||||
let summaryHtml = '';
|
||||
if (hasH && hasW) {
|
||||
const maxW = getMaxW(idx, inputH);
|
||||
if (maxW === null) {
|
||||
summaryHtml = '<div class="chart-summary na"><span class="sum-icon">—</span><span>H=' + inputH + 'mm 구간은 <b>FSCAN-' + modelName + '</b>이 지원하지 않습니다.</span></div>';
|
||||
} else {
|
||||
const fit = inputW <= maxW;
|
||||
const diff = Math.round(Math.abs(maxW - inputW));
|
||||
summaryHtml = '<div class="chart-summary ' + (fit ? 'ok' : 'ng') + '"><span class="sum-icon ' + (fit ? 'ok' : 'ng') + '">' + (fit ? '✓' : '✗') + '</span><span>H=' + inputH + 'mm 기준 최대 W <b>' + Math.round(maxW) + 'mm</b> → W=' + inputW + 'mm 입력 → <b>' + (fit ? '적합' : '부적합') + '</b> <span style="font-size:0.78rem;color:' + (fit ? '#16a34a' : '#dc2626') + '">(' + (fit ? '여유 +' + diff : diff + 'mm 초과') + ')</span></span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
const tbody = rows.map(row => {
|
||||
const wVal = row.w[idx];
|
||||
const isHL = hasH && row.h === inputH;
|
||||
const fitCell = hasW ? '<td><span class="' + (inputW <= wVal ? 'fit-ok' : 'fit-ng') + '">' + (inputW <= wVal ? '✓ 적합' : '✗ 부적합') + '</span></td>' : '';
|
||||
return '<tr class="' + (isHL ? 'hl' : '') + '"><td class="h-col">' + row.h + '</td><td class="w-val">' + wVal + '</td>' + fitCell + '</tr>';
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = summaryHtml + '<div class="table-scroll"><table class="chart-table"><thead><tr><th>H (mm)</th><th>최대 W (mm)</th>' + (hasW ? '<th>적합 여부</th>' : '') + '</tr></thead><tbody>' + tbody + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Enter') run(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1938
public/styles.css
429
scripts/build-menu-guide-ppt-user.py
Normal file
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Platform 메뉴 안내 PPT — 일반 임직원(관리자·특수 메뉴 제외)
|
||||
- 가이드봇·WM·대시보드·업무 체크리스트: 허용 계정만 (본 PPT 범위 외)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SHOT_DIR = ROOT / "docs" / "ppt-screenshots-user"
|
||||
OUT_PPT = ROOT / "docs" / "XAVIS-AI-Platform-메뉴안내-일반사용자.pptx"
|
||||
|
||||
# Brand palette
|
||||
NAVY = RGBColor(0x0F, 0x17, 0x2A)
|
||||
BLUE = RGBColor(0x25, 0x63, 0xEB)
|
||||
BLUE_LIGHT = RGBColor(0xDB, 0xEA, 0xFE)
|
||||
SLATE = RGBColor(0x47, 0x55, 0x69)
|
||||
SLATE_LIGHT = RGBColor(0x94, 0xA3, 0xB8)
|
||||
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
BG = RGBColor(0xF8, 0xFA, 0xFC)
|
||||
|
||||
SLIDE_W = Inches(13.333)
|
||||
SLIDE_H = Inches(7.5)
|
||||
|
||||
SLIDES: list[tuple[str, str, list[str], str | None]] = [
|
||||
(
|
||||
"cover",
|
||||
"XAVIS AI Platform",
|
||||
[
|
||||
"일반 사용자 이용 가이드",
|
||||
"https://ai.xavis.co.kr/",
|
||||
"@ncue.net 이메일 인증 후 이용 · 관리자 모드 불필요",
|
||||
],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"시작하기",
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"이메일 인증으로 접속",
|
||||
[
|
||||
"1. ai.xavis.co.kr 접속 → 회사 이메일 입력 → [검증]",
|
||||
"2. 메일함 [인증 완료하기] 클릭 (링크 15분 유효)",
|
||||
"3. 인증 완료 후 AI·학습센터 등 메뉴 이용",
|
||||
"로그아웃: 좌측 하단 · 좌측 [관리자]는 운영 담당용(일반 사용자 불필요)",
|
||||
],
|
||||
"login.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"일반 사용자 메뉴 구성",
|
||||
[
|
||||
"AI — 회의록·채팅·FSCAN 등 AI 서비스",
|
||||
"프롬프트 — 업무용 템플릿·팀 공유",
|
||||
"학습센터 — YouTube·PPT·동영상 강의",
|
||||
"과제신청 — AX 과제 온라인 신청",
|
||||
"AI 활용 사례 — 임직원 간 실무 AI 활용 노하우 공유·열람",
|
||||
"※ 가이드봇·WM·대시보드·업무 체크리스트는 허용 계정만 표시",
|
||||
],
|
||||
"menu-overview.png",
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"AI 서비스",
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"AI — 서비스 허브",
|
||||
[
|
||||
"경로: /ai-explore",
|
||||
"검색 + 타입 필터(전체 / 일반 / XScan / FScan)",
|
||||
"카드 클릭으로 각 AI 도구 실행",
|
||||
],
|
||||
"ai-explore.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"회의록 AI",
|
||||
[
|
||||
"텍스트: 회의 원문 붙여넣기 → [회의록 생성] → 수정 후 [저장]",
|
||||
"음성: mp3·m4a·wav(최대 300MB) → 업로드 → 전사 → 회의록 정리",
|
||||
"대안: 클로버노트·Claude 전사 → 텍스트 입력 탭에 붙여넣기",
|
||||
"유의: 결과 검토 필수 · 개인정보·대외비 주의",
|
||||
],
|
||||
"meeting-minutes.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"일반 채팅",
|
||||
[
|
||||
"이메일 인증 후 ChatGPT 기반 사내 채팅",
|
||||
"업무 질의·초안·아이디어 · (설정 시) 웹 검색",
|
||||
"반복 업무는 [프롬프트] 템플릿 복사 후 활용",
|
||||
],
|
||||
"chat.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"FSCAN 조사각 선정도우미",
|
||||
[
|
||||
"검사물 H/W 치수 입력 → FSCAN 모델 1차 선정",
|
||||
"영업·기술 검토용 · 최종 스펙은 공식 카탈로그·기술팀 확인",
|
||||
],
|
||||
"fscan.png",
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"업무 지원",
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"프롬프트 라이브러리",
|
||||
[
|
||||
"공식 템플릿(회의·메일·보고·OKR 등) 미리보기 → 복사",
|
||||
"워크플로: 4단계 입력으로 맞춤 지시문 초안",
|
||||
"공유하기: 팀 프롬프트 등록(로그인 필요, 기밀 제외)",
|
||||
],
|
||||
"prompts.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"학습센터",
|
||||
[
|
||||
"YouTube · PPT/PDF · 동영상 · 웹 링크 강의 검색·시청",
|
||||
"카테고리: AX 사고 전환 · AI 툴 활용 · AI Agent · 바이브 코딩",
|
||||
"캡처: 등록 강의 목록 전체(무한 스크롤 로드 후)",
|
||||
"강의 등록·수정은 운영 담당 — 일반 사용자는 시청·검색",
|
||||
],
|
||||
"learning.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"AX 과제 신청",
|
||||
[
|
||||
"Word 양식 다운로드 + 온라인 신청서 작성",
|
||||
"본인 신청 조회·수정(부서·이름·이메일)",
|
||||
"유사 사례는 AI 활용 사례 메뉴 참고",
|
||||
],
|
||||
"ax-apply.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"AI 활용 사례 — 공유 공간과 열람",
|
||||
[
|
||||
"각자의 실무 AI 활용법을 전사 임직원과 공유하는 공간(학습센터=교육, 여기=현장 검증 사례)",
|
||||
"로그인 임직원 누구나 열람·글쓰기 · 부서·태그·검색으로 유사 사례 탐색",
|
||||
"AX 과제·새 업무 전 참고 · 카드 클릭 → STAR 상세·재현 방법 확인",
|
||||
"경로: 좌측 [AI 활용 사례] · Before/After·활용 도구·성과 중심",
|
||||
],
|
||||
"ai-cases.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"AI 활용 사례 — 나의 경험 공유하기",
|
||||
[
|
||||
"[글쓰기] → STAR 본문(배경→과제→AI 활용→성과) · 활용 AI 태그 · 썸네일",
|
||||
"동료가 내일 바로 따라 할 수 있도록 도구·절차·수치를 구체적으로(과장 금지)",
|
||||
"※ 개인정보·고객·기밀 미포함 · AI 결과는 본인 검증 후 · 문의 AI혁신팀",
|
||||
],
|
||||
"ai-cases-compose.png",
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"정리",
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"업무별 Quick Reference",
|
||||
[
|
||||
"음성 회의록 → AI → 회의록 AI (또는 클로버노트 → 텍스트 입력)",
|
||||
"빠른 질문 → 일반 채팅 · 메일·보고 초안 → 프롬프트",
|
||||
"학습 → 학습센터 · AX 과제 → 과제신청 · 사례 → AI 활용 사례",
|
||||
"문의: AI혁신팀",
|
||||
],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"closing",
|
||||
"감사합니다",
|
||||
[
|
||||
"XAVIS AI Platform",
|
||||
"https://ai.xavis.co.kr/",
|
||||
"AI혁신팀",
|
||||
],
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _set_solid_fill(shape, color: RGBColor) -> None:
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = color
|
||||
|
||||
|
||||
def _add_header_bar(slide, title: str) -> None:
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, Inches(0.95))
|
||||
_set_solid_fill(bar, NAVY)
|
||||
bar.line.fill.background()
|
||||
|
||||
accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(0.06))
|
||||
_set_solid_fill(accent, BLUE)
|
||||
accent.line.fill.background()
|
||||
|
||||
tb = slide.shapes.add_textbox(Inches(0.55), Inches(0.18), Inches(10.5), Inches(0.55))
|
||||
tf = tb.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.name = "Apple SD Gothic Neo"
|
||||
p.font.size = Pt(24)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = WHITE
|
||||
|
||||
|
||||
def _add_footer(slide, page_num: int) -> None:
|
||||
line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(7.05), Inches(12.2), Inches(0.015))
|
||||
_set_solid_fill(line, BLUE_LIGHT)
|
||||
line.line.fill.background()
|
||||
|
||||
left = slide.shapes.add_textbox(Inches(0.55), Inches(7.12), Inches(5), Inches(0.28))
|
||||
ltf = left.text_frame
|
||||
ltf.text = "XAVIS AI Platform · 일반 사용자 가이드"
|
||||
lp = ltf.paragraphs[0]
|
||||
lp.font.size = Pt(9)
|
||||
lp.font.color.rgb = SLATE_LIGHT
|
||||
|
||||
right = slide.shapes.add_textbox(Inches(11.8), Inches(7.12), Inches(1.0), Inches(0.28))
|
||||
rtf = right.text_frame
|
||||
rtf.text = str(page_num)
|
||||
rp = rtf.paragraphs[0]
|
||||
rp.font.size = Pt(9)
|
||||
rp.font.color.rgb = SLATE_LIGHT
|
||||
rp.alignment = PP_ALIGN.RIGHT
|
||||
|
||||
|
||||
def _add_bullets(slide, bullets: list[str], left: float, top: float, width: float, height: float, font_size: int = 14) -> None:
|
||||
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
for i, line in enumerate(bullets):
|
||||
para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
para.text = line
|
||||
para.font.name = "Apple SD Gothic Neo"
|
||||
para.font.size = Pt(font_size)
|
||||
para.font.color.rgb = SLATE
|
||||
para.space_after = Pt(8)
|
||||
para.level = 0
|
||||
if not line.startswith("※"):
|
||||
para.text = f"• {line}"
|
||||
|
||||
|
||||
def _add_screenshot(slide, shot_name: str, left: float, top: float, width: float) -> bool:
|
||||
path = SHOT_DIR / shot_name
|
||||
if not path.is_file():
|
||||
return False
|
||||
pic = slide.shapes.add_picture(str(path), Inches(left), Inches(top), width=Inches(width))
|
||||
max_height = Inches(5.85)
|
||||
if pic.height > max_height:
|
||||
scale = max_height / pic.height
|
||||
pic.width = int(pic.width * scale)
|
||||
pic.height = int(pic.height * scale)
|
||||
frame = slide.shapes.add_shape(
|
||||
MSO_SHAPE.RECTANGLE,
|
||||
pic.left - Inches(0.04),
|
||||
pic.top - Inches(0.04),
|
||||
pic.width + Inches(0.08),
|
||||
pic.height + Inches(0.08),
|
||||
)
|
||||
frame.fill.background()
|
||||
frame.line.color.rgb = BLUE_LIGHT
|
||||
frame.line.width = Pt(1.25)
|
||||
# picture on top
|
||||
slide.shapes._spTree.remove(pic._element)
|
||||
slide.shapes._spTree.insert(-1, pic._element)
|
||||
return True
|
||||
|
||||
|
||||
def add_cover_slide(prs: Presentation, title: str, bullets: list[str]) -> None:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
|
||||
_set_solid_fill(bg, NAVY)
|
||||
bg.line.fill.background()
|
||||
|
||||
stripe = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(2.8), SLIDE_W, Inches(0.08))
|
||||
_set_solid_fill(stripe, BLUE)
|
||||
stripe.line.fill.background()
|
||||
|
||||
tb = slide.shapes.add_textbox(Inches(0.9), Inches(1.5), Inches(11.5), Inches(1.0))
|
||||
tf = tb.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(44)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = WHITE
|
||||
|
||||
sub = slide.shapes.add_textbox(Inches(0.9), Inches(3.2), Inches(11.0), Inches(2.5))
|
||||
stf = sub.text_frame
|
||||
for i, line in enumerate(bullets):
|
||||
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
|
||||
para.text = line
|
||||
para.font.size = Pt(20 if i == 0 else 16)
|
||||
para.font.bold = i == 0
|
||||
para.font.color.rgb = BLUE_LIGHT if i == 0 else SLATE_LIGHT
|
||||
para.space_after = Pt(10)
|
||||
|
||||
|
||||
def add_section_slide(prs: Presentation, title: str, page_num: int) -> None:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
|
||||
_set_solid_fill(bg, BLUE)
|
||||
bg.line.fill.background()
|
||||
|
||||
tb = slide.shapes.add_textbox(Inches(0.9), Inches(3.0), Inches(11), Inches(1.2))
|
||||
tf = tb.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(36)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = WHITE
|
||||
_add_footer(slide, page_num)
|
||||
|
||||
|
||||
def add_text_slide(prs: Presentation, title: str, bullets: list[str], page_num: int) -> None:
|
||||
"""스크린샷 없이 본문만 — 설명·가이드용"""
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(6.55))
|
||||
_set_solid_fill(bg, BG)
|
||||
bg.line.fill.background()
|
||||
|
||||
_add_header_bar(slide, title)
|
||||
|
||||
highlight = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(1.15), Inches(0.08), Inches(5.6))
|
||||
_set_solid_fill(highlight, BLUE)
|
||||
highlight.line.fill.background()
|
||||
|
||||
_add_bullets(slide, bullets, 0.75, 1.25, 11.8, 5.5, font_size=15)
|
||||
_add_footer(slide, page_num)
|
||||
|
||||
|
||||
def add_content_slide(prs: Presentation, title: str, bullets: list[str], shot_name: str | None, page_num: int) -> None:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(6.55))
|
||||
_set_solid_fill(bg, BG)
|
||||
bg.line.fill.background()
|
||||
|
||||
_add_header_bar(slide, title)
|
||||
|
||||
has_shot = shot_name and (SHOT_DIR / shot_name).is_file()
|
||||
text_w = 5.6 if has_shot else 12.0
|
||||
fs = 13 if len(bullets) >= 5 else 14
|
||||
_add_bullets(slide, bullets, 0.55, 1.25, text_w, 5.5, font_size=fs)
|
||||
|
||||
if has_shot and shot_name:
|
||||
_add_screenshot(slide, shot_name, 6.45, 1.15, 6.35)
|
||||
|
||||
_add_footer(slide, page_num)
|
||||
|
||||
|
||||
def add_closing_slide(prs: Presentation, title: str, bullets: list[str], page_num: int) -> None:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
|
||||
_set_solid_fill(bg, NAVY)
|
||||
bg.line.fill.background()
|
||||
|
||||
tb = slide.shapes.add_textbox(Inches(0.9), Inches(2.6), Inches(11.5), Inches(1.0))
|
||||
tf = tb.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(40)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = WHITE
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
|
||||
sub = slide.shapes.add_textbox(Inches(0.9), Inches(3.8), Inches(11.5), Inches(1.5))
|
||||
stf = sub.text_frame
|
||||
for i, line in enumerate(bullets):
|
||||
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
|
||||
para.text = line
|
||||
para.font.size = Pt(18)
|
||||
para.font.color.rgb = SLATE_LIGHT
|
||||
para.alignment = PP_ALIGN.CENTER
|
||||
para.space_after = Pt(6)
|
||||
|
||||
_add_footer(slide, page_num)
|
||||
|
||||
|
||||
def build() -> Path:
|
||||
prs = Presentation()
|
||||
prs.slide_width = SLIDE_W
|
||||
prs.slide_height = SLIDE_H
|
||||
|
||||
page = 0
|
||||
for kind, title, bullets, shot in SLIDES:
|
||||
page += 1
|
||||
if kind == "cover":
|
||||
add_cover_slide(prs, title, bullets)
|
||||
elif kind == "section":
|
||||
add_section_slide(prs, title, page)
|
||||
elif kind == "closing":
|
||||
add_closing_slide(prs, title, bullets, page)
|
||||
elif kind == "text":
|
||||
add_text_slide(prs, title, bullets, page)
|
||||
else:
|
||||
add_content_slide(prs, title, bullets, shot, page)
|
||||
|
||||
OUT_PPT.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(str(OUT_PPT))
|
||||
return OUT_PPT
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = build()
|
||||
print(f"PPT saved: {out}")
|
||||
285
scripts/build-menu-guide-ppt.py
Normal file
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Platform 메뉴 안내 PPT 생성 (python-pptx)
|
||||
스크린샷: docs/ppt-screenshots/*.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SHOT_DIR = ROOT / "docs" / "ppt-screenshots"
|
||||
OUT_PPT = ROOT / "docs" / "XAVIS-AI-Platform-메뉴안내.pptx"
|
||||
|
||||
# 슬라이드 정의: (제목, 불릿 목록, 스크린샷 파일명 또는 None)
|
||||
SLIDES: list[tuple[str, list[str], str | None]] = [
|
||||
(
|
||||
"XAVIS AI Platform 메뉴 안내",
|
||||
[
|
||||
"사내 AI 도구·학습·과제·활용 사례 통합 포털",
|
||||
"접속: https://ai.xavis.co.kr/",
|
||||
"최초 1회: @ncue.net 이메일 → 인증 메일 링크(15분 유효)",
|
||||
"좌측 메뉴: 회사규정 · WM · AI · 프롬프트 · 학습센터 · 과제신청 · AI 활용 사례 · 대시보드(허용자)",
|
||||
],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"1. 서비스 접속 (로그인)",
|
||||
[
|
||||
"경로: /login — 미인증 시 자동 이동",
|
||||
"회사 이메일 입력 → [검증] → 메일 [인증 완료하기] 클릭",
|
||||
"인증 후 학습센터 등 원하는 화면으로 이동",
|
||||
"유의: @ncue.net 만 가능 · 링크 만료 시 재검증 · 로그아웃은 좌측 하단",
|
||||
],
|
||||
"login.png",
|
||||
),
|
||||
(
|
||||
"2. 회사규정",
|
||||
[
|
||||
"좌측 [회사규정] 클릭 → Google NotebookLM (새 탭, 회사 Google 계정 로그인)",
|
||||
"사내 규정·지침 AI 검색·요약·질의응답",
|
||||
"캡처: 좌측 메뉴 [회사규정] 위치 (학습센터 화면)",
|
||||
"AI 답변은 원문 규정·다우오피스 공식 문서와 반드시 대조",
|
||||
],
|
||||
"company-policy.png",
|
||||
),
|
||||
(
|
||||
"3. WM",
|
||||
[
|
||||
"좌측 [WM] 클릭 → NotebookLM WM 노트북 (새 탭)",
|
||||
"WM 프로세스·용어·가이드 질의",
|
||||
"캡처: 좌측 메뉴 [WM] 위치 (AI 화면)",
|
||||
"공식 매뉴얼·담당 부서 확인 병행",
|
||||
],
|
||||
"wm.png",
|
||||
),
|
||||
(
|
||||
"4. AI (메인 허브)",
|
||||
[
|
||||
"경로: /ai-explore — AI 서비스 카드 허브",
|
||||
"검색 + 타입 필터(전체/일반/XScan/FScan)",
|
||||
"회의록 AI · 업무 체크리스트 · 일반 채팅 · FSCAN 선정도우미",
|
||||
],
|
||||
"ai-explore.png",
|
||||
),
|
||||
(
|
||||
"5. 회의록 AI",
|
||||
[
|
||||
"텍스트 입력: 회의 원문 붙여넣기 → 회의록 생성 → 저장",
|
||||
"음성 파일: mp3·m4a·wav(최대 300MB) → 업로드→전사→회의록 정리",
|
||||
"저장 시 업무 체크리스트 AI와 자동 연동",
|
||||
"대안: Claude 음성 업로드 / 클로버노트 전사 → 텍스트 입력 탭 붙여넣기",
|
||||
"유의: 생성 결과 검토 필수 · 개인정보·대외비 주의",
|
||||
],
|
||||
"meeting-minutes.png",
|
||||
),
|
||||
(
|
||||
"6. 업무 체크리스트 AI",
|
||||
[
|
||||
"회의록 AI 저장분의 액션·체크리스트 통합 관리",
|
||||
"진행/완료 필터 · 회의별 필터 · 완료 처리 메모",
|
||||
"흐름: 회의록 AI 저장 → 체크리스트에서 추적",
|
||||
],
|
||||
"task-checklist.png",
|
||||
),
|
||||
(
|
||||
"7. 일반 채팅",
|
||||
[
|
||||
"ChatGPT 기반 사내 인앱 채팅 (로그인 필요)",
|
||||
"업무 질의·초안·아이디어 · (설정 시) 웹 검색",
|
||||
"반복 프롬프트는 [프롬프트] 메뉴 템플릿 활용",
|
||||
"유의: AI 답변은 실수할 수 있음 · 기밀 입력 자제",
|
||||
],
|
||||
"chat.png",
|
||||
),
|
||||
(
|
||||
"8. FSCAN 조사각 선정도우미",
|
||||
[
|
||||
"검사 대상물 H/W 치수 입력 → FSCAN 모델 1차 선정",
|
||||
"영업·기술 검토 시 빠른 선정용",
|
||||
"최종 스펙은 공식 카탈로그·기술팀 확인 필수",
|
||||
],
|
||||
"fscan.png",
|
||||
),
|
||||
(
|
||||
"9. 프롬프트 라이브러리",
|
||||
[
|
||||
"공식 템플릿: 회의 요약·이메일·보고·OKR·코드리뷰 등 → 복사",
|
||||
"워크플로: 4단계 입력 → 맞춤 프롬프트 초안",
|
||||
"공유하기: 팀 프롬프트·참고 파일 (기밀 제외)",
|
||||
],
|
||||
"prompts.png",
|
||||
),
|
||||
(
|
||||
"10. 학습센터",
|
||||
[
|
||||
"YouTube · PPT/PDF · 동영상 · 웹·뉴스 링크 강의",
|
||||
"카테고리: AX 사고 전환 · AI 툴 활용 · AI Agent · 바이브 코딩",
|
||||
"검색 예: claude, 클로드 → 도구별 강의 찾기",
|
||||
],
|
||||
"learning.png",
|
||||
),
|
||||
(
|
||||
"11. AX 과제 신청",
|
||||
[
|
||||
"온라인 신청 + Word 양식 다운로드",
|
||||
"Pain Point · AI 기대 · 데이터·효과 등 작성",
|
||||
"조회로 본인 신청 확인·수정 · AI 활용 사례 참고",
|
||||
],
|
||||
"ax-apply.png",
|
||||
),
|
||||
(
|
||||
"12. AI 활용 사례",
|
||||
[
|
||||
"부서별 도입·성과 사례 카드 열람",
|
||||
"글쓰기: STAR 형식(1.Situation~4.Result)",
|
||||
"AX 과제·팀 공유 레퍼런스",
|
||||
],
|
||||
"ai-cases.png",
|
||||
),
|
||||
(
|
||||
"13. 대시보드 (허용 계정)",
|
||||
[
|
||||
"허용 이메일만 좌측 메뉴 표시",
|
||||
"경영성과: 연도·분기 KPI 차트 + 매출일보 엑셀 업로드",
|
||||
],
|
||||
"dashboard.png",
|
||||
),
|
||||
(
|
||||
"14. 경영성과 대시보드",
|
||||
[
|
||||
"상단: Chart.js KPI·차트 조회",
|
||||
"하단: .xlsx 매출일보 업로드 → 스냅샷 저장",
|
||||
],
|
||||
"dashboard-business-performance.png",
|
||||
),
|
||||
(
|
||||
"업무별 Quick Reference",
|
||||
[
|
||||
"음성 회의록 → AI → 회의록 AI (또는 클로버노트 → 텍스트 입력)",
|
||||
"할 일 추적 → 업무 체크리스트 AI",
|
||||
"빠른 질문 → 일반 채팅 · 메일·보고 → 프롬프트",
|
||||
"규정 → 회사규정 · 학습 → 학습센터 · AI 과제 → 과제신청",
|
||||
"문의: AI혁신팀",
|
||||
],
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
ACCENT = RGBColor(0x25, 0x63, 0xEB)
|
||||
DARK = RGBColor(0x11, 0x18, 0x27)
|
||||
GRAY = RGBColor(0x4B, 0x55, 0x63)
|
||||
|
||||
|
||||
def add_title_slide(prs: Presentation, title: str, bullets: list[str]) -> None:
|
||||
layout = prs.slide_layouts[6] # blank
|
||||
slide = prs.slides.add_slide(layout)
|
||||
slide.background.fill.solid()
|
||||
slide.background.fill.fore_color.rgb = RGBColor(0xF0, 0xF4, 0xFF)
|
||||
|
||||
box = slide.shapes.add_textbox(Inches(0.8), Inches(2.2), Inches(11.5), Inches(1.2))
|
||||
tf = box.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(40)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = DARK
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
|
||||
sub = slide.shapes.add_textbox(Inches(1.2), Inches(3.6), Inches(10.8), Inches(2.5))
|
||||
stf = sub.text_frame
|
||||
stf.word_wrap = True
|
||||
for i, line in enumerate(bullets[:4]):
|
||||
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
|
||||
para.text = line
|
||||
para.font.size = Pt(18)
|
||||
para.font.color.rgb = GRAY
|
||||
para.space_after = Pt(8)
|
||||
para.alignment = PP_ALIGN.CENTER
|
||||
|
||||
|
||||
def add_content_slide(
|
||||
prs: Presentation,
|
||||
title: str,
|
||||
bullets: list[str],
|
||||
shot_name: str | None,
|
||||
) -> None:
|
||||
layout = prs.slide_layouts[6]
|
||||
slide = prs.slides.add_slide(layout)
|
||||
|
||||
# 제목
|
||||
title_box = slide.shapes.add_textbox(Inches(0.5), Inches(0.25), Inches(12.3), Inches(0.6))
|
||||
ttf = title_box.text_frame
|
||||
ttf.text = title
|
||||
tp = ttf.paragraphs[0]
|
||||
tp.font.size = Pt(26)
|
||||
tp.font.bold = True
|
||||
tp.font.color.rgb = ACCENT
|
||||
|
||||
has_shot = shot_name and (SHOT_DIR / shot_name).is_file()
|
||||
text_left = Inches(0.5)
|
||||
text_top = Inches(0.95)
|
||||
text_w = Inches(5.8) if has_shot else Inches(12.3)
|
||||
text_h = Inches(6.2)
|
||||
|
||||
body = slide.shapes.add_textbox(text_left, text_top, text_w, text_h)
|
||||
btf = body.text_frame
|
||||
btf.word_wrap = True
|
||||
for i, line in enumerate(bullets):
|
||||
para = btf.paragraphs[0] if i == 0 else btf.add_paragraph()
|
||||
para.text = f"• {line}"
|
||||
para.font.size = Pt(14)
|
||||
para.font.color.rgb = DARK
|
||||
para.space_after = Pt(6)
|
||||
para.level = 0
|
||||
|
||||
if has_shot:
|
||||
shot_path = str(SHOT_DIR / shot_name)
|
||||
slide.shapes.add_picture(
|
||||
shot_path,
|
||||
Inches(6.6),
|
||||
Inches(0.85),
|
||||
width=Inches(6.2),
|
||||
)
|
||||
elif shot_name:
|
||||
note = slide.shapes.add_textbox(Inches(6.6), Inches(2.5), Inches(6.0), Inches(1.0))
|
||||
ntf = note.text_frame
|
||||
ntf.text = f"(캡처 없음: {shot_name})"
|
||||
ntf.paragraphs[0].font.size = Pt(12)
|
||||
ntf.paragraphs[0].font.color.rgb = GRAY
|
||||
|
||||
# 슬라이드 번호
|
||||
num = len(prs.slides)
|
||||
footer = slide.shapes.add_textbox(Inches(12.0), Inches(7.05), Inches(0.8), Inches(0.3))
|
||||
ftf = footer.text_frame
|
||||
ftf.text = str(num)
|
||||
ftf.paragraphs[0].font.size = Pt(10)
|
||||
ftf.paragraphs[0].font.color.rgb = GRAY
|
||||
ftf.paragraphs[0].alignment = PP_ALIGN.RIGHT
|
||||
|
||||
|
||||
def build() -> Path:
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
|
||||
for idx, (title, bullets, shot) in enumerate(SLIDES):
|
||||
if idx == 0:
|
||||
add_title_slide(prs, title, bullets)
|
||||
else:
|
||||
add_content_slide(prs, title, bullets, shot)
|
||||
|
||||
OUT_PPT.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(str(OUT_PPT))
|
||||
return OUT_PPT
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = build()
|
||||
print(f"PPT saved: {out}")
|
||||
293
scripts/capture-menu-screenshots.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* AI Platform 메뉴 화면 캡처 (Playwright)
|
||||
* - 로그인: 운영 URL (PROD 전용 화면)
|
||||
* - 나머지: 로컬 SUPER 모드 서버 (인증 없이 캡처)
|
||||
* - 대시보드: OPS 세션 쿠키(허용 이메일) 주입 후 캡처
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR =
|
||||
process.env.CAPTURE_OUT_DIR ||
|
||||
path.join(__dirname, "..", "docs", "ppt-screenshots");
|
||||
const LOCAL_BASE = process.env.CAPTURE_BASE_URL || "http://127.0.0.1:8030";
|
||||
const PROD_BASE = (process.env.CAPTURE_PROD_BASE || "https://ai.xavis.co.kr").replace(/\/$/, "");
|
||||
const PROD_LOGIN = `${PROD_BASE}/login`;
|
||||
/** 목록(학습·사례)은 운영 데이터·전체 로드 후 캡처 */
|
||||
const CAPTURE_LISTS_FROM_PROD = process.env.CAPTURE_LISTS_FROM_PROD !== "0";
|
||||
/** 일반 사용자 OPS 세션 (관리자·특수 메뉴 쿠키 없음) */
|
||||
const CAPTURE_USER_EMAIL = (
|
||||
process.env.CAPTURE_USER_EMAIL || "employee@ncue.net"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
/** general: 가이드봇·WM·대시보드·체크리스트 제외 캡처 */
|
||||
const CAPTURE_PROFILE = (process.env.CAPTURE_PROFILE || "general").trim().toLowerCase();
|
||||
|
||||
const LOCAL_PAGES_ALL = [
|
||||
{ id: "ai-explore", path: "/ai-explore", waitMs: 800 },
|
||||
{ id: "meeting-minutes", path: "/ai-explore/meeting-minutes", waitMs: 1200 },
|
||||
{ id: "task-checklist", path: "/ai-explore/task-checklist", waitMs: 1200 },
|
||||
{ id: "chat", path: "/ai-explore/chat", waitMs: 800 },
|
||||
{ id: "fscan", path: "/ai-explore/fscan", waitMs: 2000 },
|
||||
{ id: "prompts", path: "/ai-explore/prompts", waitMs: 1500 },
|
||||
{ id: "ax-apply", path: "/ax-apply", waitMs: 1000 },
|
||||
];
|
||||
|
||||
/** 목록 패널 전체(element) 캡처 — viewport 잘림 방지 */
|
||||
const LIST_PANEL_PAGES = [
|
||||
{
|
||||
id: "learning",
|
||||
path: "/learning",
|
||||
selector: "section.panel:has(#lecture-results-root)",
|
||||
useProd: true,
|
||||
preload: "learning",
|
||||
},
|
||||
{
|
||||
id: "ai-cases",
|
||||
path: "/ai-cases",
|
||||
selector: "section.panel:has(.success-story-grid)",
|
||||
useProd: true,
|
||||
preload: "none",
|
||||
},
|
||||
{
|
||||
id: "ai-cases-compose",
|
||||
path: "/ai-cases/compose",
|
||||
selector: "main.use-case-compose",
|
||||
useProd: true,
|
||||
preload: "none",
|
||||
},
|
||||
];
|
||||
|
||||
const LOCAL_PAGES =
|
||||
CAPTURE_PROFILE === "general"
|
||||
? LOCAL_PAGES_ALL.filter(
|
||||
(p) => !["task-checklist", "dashboard", "dashboard-business-performance"].includes(p.id)
|
||||
)
|
||||
: LOCAL_PAGES_ALL;
|
||||
|
||||
/** 일반 사용자 좌측 메뉴(가이드봇·WM·대시보드 없음) */
|
||||
const MENU_OVERVIEW = { id: "menu-overview", path: "/ai-explore", waitMs: 1000 };
|
||||
|
||||
function readEnvValue(key) {
|
||||
const envPath = path.join(__dirname, "..", ".env");
|
||||
try {
|
||||
const raw = fsSync.readFileSync(envPath, "utf8");
|
||||
const re = new RegExp(`^${key}=(.+)$`, "m");
|
||||
const m = raw.match(re);
|
||||
if (!m) return "";
|
||||
return m[1].split("#")[0].trim().replace(/^["']|["']$/g, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** ops-auth.js 와 동일한 세션 쿠키 서명 */
|
||||
function buildOpsSessionCookie(email) {
|
||||
const secret = readEnvValue("AUTH_SECRET") || readEnvValue("ADMIN_TOKEN") || "ncue-admin";
|
||||
const exp = Number.MAX_SAFE_INTEGER;
|
||||
const iat = Date.now();
|
||||
const payload = `${email}|${exp}|${iat}`;
|
||||
const sig = crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
||||
return Buffer.from(JSON.stringify({ email, exp, iat, sig })).toString("base64url");
|
||||
}
|
||||
|
||||
function captureUserEmail() {
|
||||
return CAPTURE_USER_EMAIL;
|
||||
}
|
||||
|
||||
async function capturePage(page, url, outPath, waitMs = 1000, fullPage = false) {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 90000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: outPath, fullPage });
|
||||
console.log(" saved:", outPath);
|
||||
}
|
||||
|
||||
/** 학습센터 무한 스크롤 — 모든 페이지 로드 */
|
||||
async function loadAllLearningPages(page) {
|
||||
await page.waitForSelector("#lecture-grid", { timeout: 60000 });
|
||||
let guard = 0;
|
||||
while (guard < 80) {
|
||||
guard += 1;
|
||||
const state = await page.evaluate(() => {
|
||||
const sentinel = document.getElementById("infinite-scroll-sentinel");
|
||||
const btn = document.getElementById("lecture-load-more-btn");
|
||||
const countEl = document.getElementById("lecture-total-count");
|
||||
const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card");
|
||||
return {
|
||||
hasNext: !!(sentinel && sentinel.getAttribute("data-has-next") === "true"),
|
||||
hasBtn: !!btn,
|
||||
total: countEl ? countEl.textContent.trim() : "",
|
||||
loaded: cards.length,
|
||||
};
|
||||
});
|
||||
if (!state.hasNext) break;
|
||||
if (state.hasBtn) {
|
||||
await page.locator("#lecture-load-more-btn").click({ timeout: 5000 }).catch(() => {});
|
||||
} else {
|
||||
await page.evaluate(() => {
|
||||
const s = document.getElementById("infinite-scroll-sentinel");
|
||||
if (s) s.scrollIntoView({ block: "end", behavior: "instant" });
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(900);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const el = document.getElementById("infinite-scroll-loading");
|
||||
return !el || el.style.display === "none" || el.style.display === "";
|
||||
},
|
||||
{ timeout: 45000 }
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
const finalCount = await page.evaluate(() => {
|
||||
const countEl = document.getElementById("lecture-total-count");
|
||||
const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card");
|
||||
return { total: countEl ? countEl.textContent.trim() : "?", loaded: cards.length };
|
||||
});
|
||||
console.log(" learning loaded:", finalCount.loaded, "/ total", finalCount.total);
|
||||
}
|
||||
|
||||
/** 목록 섹션(element) 캡처 — 카드 전체가 포함되도록 */
|
||||
async function captureListPanel(page, baseUrl, item, outPath) {
|
||||
const url = `${baseUrl}${item.path}`;
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 90000 });
|
||||
if (item.preload === "learning") {
|
||||
await loadAllLearningPages(page);
|
||||
}
|
||||
await page.waitForSelector(item.selector, { timeout: 60000 });
|
||||
await page.waitForTimeout(600);
|
||||
const panel = page.locator(item.selector).first();
|
||||
await panel.scrollIntoViewIfNeeded();
|
||||
await panel.screenshot({ path: outPath });
|
||||
const meta = await page.evaluate((sel) => {
|
||||
const cards =
|
||||
sel.includes("lecture")
|
||||
? document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card").length
|
||||
: document.querySelectorAll(".success-story-grid .success-story-card, .success-story-grid a").length;
|
||||
const chip = document.querySelector(".count-chip");
|
||||
return { cards, chip: chip ? chip.textContent.trim() : "" };
|
||||
}, item.selector);
|
||||
console.log(" saved:", outPath, meta.chip || "", "visible cards:", meta.cards);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
locale: "ko-KR",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const userEmail = captureUserEmail();
|
||||
const opsCookie = buildOpsSessionCookie(userEmail);
|
||||
const cookieJar = [
|
||||
{
|
||||
name: "ops_user_session",
|
||||
value: opsCookie,
|
||||
url: LOCAL_BASE,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
];
|
||||
if (CAPTURE_LISTS_FROM_PROD) {
|
||||
cookieJar.push({
|
||||
name: "ops_user_session",
|
||||
value: opsCookie,
|
||||
domain: new URL(PROD_BASE).hostname,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
}
|
||||
await context.addCookies(cookieJar);
|
||||
console.log("Capture persona:", userEmail, "(no admin cookie)", "profile:", CAPTURE_PROFILE);
|
||||
console.log("Output dir:", OUT_DIR);
|
||||
|
||||
console.log("[1/4] Login (production)...");
|
||||
try {
|
||||
await capturePage(
|
||||
page,
|
||||
PROD_LOGIN,
|
||||
path.join(OUT_DIR, "login.png"),
|
||||
1500
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(" login capture failed:", err.message);
|
||||
}
|
||||
|
||||
console.log("[2/4] Local menus (PROD + OPS cookie, no admin)...");
|
||||
for (const item of LOCAL_PAGES) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
try {
|
||||
await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[2b/4] List panels (full load + element capture)...");
|
||||
for (const item of LIST_PANEL_PAGES) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
const base = item.useProd && CAPTURE_LISTS_FROM_PROD ? PROD_BASE : LOCAL_BASE;
|
||||
try {
|
||||
await captureListPanel(page, base, item, outPath);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} list panel failed (${base}):`, err.message);
|
||||
if (base !== LOCAL_BASE) {
|
||||
try {
|
||||
await captureListPanel(page, LOCAL_BASE, item, outPath);
|
||||
} catch (err2) {
|
||||
console.warn(` ${item.id} local fallback failed:`, err2.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[3/4] Menu overview...");
|
||||
try {
|
||||
await capturePage(
|
||||
page,
|
||||
`${LOCAL_BASE}${MENU_OVERVIEW.path}`,
|
||||
path.join(OUT_DIR, `${MENU_OVERVIEW.id}.png`),
|
||||
MENU_OVERVIEW.waitMs
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(" menu-overview failed:", err.message);
|
||||
}
|
||||
|
||||
if (CAPTURE_PROFILE !== "general") {
|
||||
console.log("[4/4] Dashboard (허용 계정:", userEmail, ")...");
|
||||
for (const item of [
|
||||
{ id: "dashboard", path: "/dashboard", waitMs: 1000 },
|
||||
{ id: "dashboard-business-performance", path: "/dashboard/business-performance", waitMs: 2500 },
|
||||
]) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
try {
|
||||
await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("[4/4] Dashboard skip (general profile)");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("Done. Output:", OUT_DIR);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
24
scripts/lib/load-env.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# 프로젝트 루트 .env에서 KEY=VALUE 줄만 export (섹션 헤더 [..]·주석 무시)
|
||||
load_project_env() {
|
||||
local env_file="$1"
|
||||
if [[ ! -f "$env_file" ]]; then
|
||||
echo "load_project_env: .env not found: $env_file" >&2
|
||||
return 1
|
||||
fi
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
[[ "$line" == \[* ]] && continue
|
||||
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
local key="${BASH_REMATCH[1]}"
|
||||
local val="${BASH_REMATCH[2]}"
|
||||
if [[ "$val" =~ ^\"(.*)\"$ ]]; then
|
||||
val="${BASH_REMATCH[1]}"
|
||||
elif [[ "$val" =~ ^\'(.*)\'$ ]]; then
|
||||
val="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
export "$key=$val"
|
||||
fi
|
||||
done < "$env_file"
|
||||
}
|
||||
122
scripts/merge-orphan-ai-success-stories.js
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* data/ai-success-stories.json 에는 없는데 data/ai-success-stories/*.md 만 남은 경우( Git pull로 json만 예전으로 돌아간 경우 등 )
|
||||
* 본문 파일을 스캔해 메타 항목을 자동 추가합니다.
|
||||
*
|
||||
* 사용: 프로젝트 루트에서
|
||||
* node scripts/merge-orphan-ai-success-stories.js
|
||||
*
|
||||
* 동작: json 백업(data/ai-success-stories.json.bak.<시간>) 후, 등록되지 않은 .md 마다 항목 추가.
|
||||
* 제목은 md 첫 줄의 # 제목을 쓰고, 없으면 슬러그에서 추정한 임시 제목.
|
||||
* pdfUrl은 비어 있음 → 관리자 /ai-cases/write 에서 해당 사례 편집 후 PDF 경로를 다시 넣으세요.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const DATA_DIR = path.join(ROOT, "data");
|
||||
const META_PATH = path.join(DATA_DIR, "ai-success-stories.json");
|
||||
const CONTENT_DIR = path.join(DATA_DIR, "ai-success-stories");
|
||||
|
||||
function parseSlugFromMdName(basename) {
|
||||
const noExt = basename.replace(/\.md$/i, "");
|
||||
const m = noExt.match(/^(.+)-(\d{10,20})$/);
|
||||
if (m) return m[1].toLowerCase();
|
||||
return noExt.toLowerCase().replace(/[^a-z0-9\-]/g, "") || "story";
|
||||
}
|
||||
|
||||
function firstHeadingOrTitle(md, slug) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith("# ")) return t.slice(2).trim();
|
||||
}
|
||||
return (
|
||||
slug
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ") || "사례 (제목 확인)"
|
||||
);
|
||||
}
|
||||
|
||||
function excerptFromMd(md) {
|
||||
const lines = md.split(/\r?\n/).map((l) => l.trim());
|
||||
const paras = [];
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
if (line.startsWith("```")) break;
|
||||
paras.push(line);
|
||||
if (paras.join(" ").length > 200) break;
|
||||
}
|
||||
const e = paras.join(" ").slice(0, 280);
|
||||
return e || "";
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(META_PATH)) {
|
||||
console.error("없음:", META_PATH);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(CONTENT_DIR)) {
|
||||
console.error("없음:", CONTENT_DIR);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(META_PATH, "utf8");
|
||||
const meta = JSON.parse(raw);
|
||||
if (!Array.isArray(meta)) {
|
||||
console.error("ai-success-stories.json 형식이 배열이 아닙니다.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const used = new Set(meta.map((m) => (m.contentFile || "").split("/").pop()).filter(Boolean));
|
||||
const files = fs.readdirSync(CONTENT_DIR).filter((f) => /\.md$/i.test(f));
|
||||
|
||||
const orphans = files.filter((f) => !used.has(f));
|
||||
if (orphans.length === 0) {
|
||||
console.log("추가할 고아 .md 없음. 종료.");
|
||||
return;
|
||||
}
|
||||
|
||||
const bak = `${META_PATH}.bak.${Date.now()}`;
|
||||
fs.copyFileSync(META_PATH, bak);
|
||||
console.log("백업:", bak);
|
||||
|
||||
for (const file of orphans) {
|
||||
const full = path.join(CONTENT_DIR, file);
|
||||
const md = fs.readFileSync(full, "utf8");
|
||||
const slug = parseSlugFromMdName(file);
|
||||
if (meta.some((m) => m.slug === slug)) {
|
||||
console.warn("슬러그 충돌 건너뜀 (이미 다른 항목에 동일 slug):", slug, file);
|
||||
continue;
|
||||
}
|
||||
const title = firstHeadingOrTitle(md, slug);
|
||||
const excerpt = excerptFromMd(md) || title.slice(0, 140);
|
||||
const tsMatch = file.match(/-(\d{10,20})\.md$/i);
|
||||
const id = tsMatch ? `story-${tsMatch[1]}` : `story-${Date.now()}`;
|
||||
const row = {
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
excerpt,
|
||||
author: "",
|
||||
department: "",
|
||||
publishedAt: new Date().toISOString().slice(0, 10),
|
||||
tags: [],
|
||||
contentFile: file,
|
||||
pdfUrl: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
meta.push(row);
|
||||
console.log("추가:", file, "→ slug:", slug);
|
||||
}
|
||||
|
||||
fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2), "utf8");
|
||||
console.log("저장 완료:", META_PATH, "총", meta.length, "건");
|
||||
console.log("pdfUrl이 비어 있으면 관리자 화면에서 PDF 경로를 지정하세요.");
|
||||
}
|
||||
|
||||
main();
|
||||
181
scripts/pg-backup.sh
Executable file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostgreSQL 논리 백업 (pg_dump -Fc). .env의 DB_* 및 PG_BACKUP_* 사용.
|
||||
# PG_BACKUP_SCOPE=all 이면 서버의 연결 가능·비템플릿 DB 전체를 각각 .dump 로 저장.
|
||||
# 운영 서버 cron 예: 0 2 * * * cd /home/xavis/workspace/ai_platform && bash scripts/pg-backup.sh >> /var/log/pg-backup.log 2>&1
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=scripts/lib/load-env.sh
|
||||
source "$REPO_ROOT/scripts/lib/load-env.sh"
|
||||
|
||||
load_project_env "$REPO_ROOT/.env"
|
||||
|
||||
: "${DB_HOST:?DB_HOST is required in .env}"
|
||||
: "${DB_DATABASE:?DB_DATABASE is required in .env}"
|
||||
: "${DB_USERNAME:?DB_USERNAME is required in .env}"
|
||||
: "${DB_PASSWORD:?DB_PASSWORD is required in .env}"
|
||||
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
PG_BACKUP_DIR="${PG_BACKUP_DIR:-/home/xavis/workspace/backup/ai_platform}"
|
||||
PG_BACKUP_RETENTION_DAYS="${PG_BACKUP_RETENTION_DAYS:-30}"
|
||||
PG_BACKUP_SCOPE="${PG_BACKUP_SCOPE:-all}"
|
||||
PG_BACKUP_GLOBALS="${PG_BACKUP_GLOBALS:-1}"
|
||||
|
||||
export PGHOST="$DB_HOST"
|
||||
export PGPORT="$DB_PORT"
|
||||
|
||||
if ! command -v pg_dump >/dev/null 2>&1; then
|
||||
echo "pg-backup: pg_dump not found. Install postgresql-client (apt) or postgresql (brew)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "pg-backup: psql not found. Install postgresql-client." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
|
||||
|
||||
set_backup_credentials() {
|
||||
if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
|
||||
export PGUSER="${PG_BACKUP_SUPERUSER:-postgres}"
|
||||
export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD"
|
||||
else
|
||||
export PGUSER="$DB_USERNAME"
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
fi
|
||||
}
|
||||
|
||||
list_all_databases() {
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 -tAc \
|
||||
"SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname;"
|
||||
}
|
||||
|
||||
dump_database() {
|
||||
local db_name="$1"
|
||||
local dump_file="$2"
|
||||
pg_dump \
|
||||
-h "$PGHOST" \
|
||||
-p "$PGPORT" \
|
||||
-U "$PGUSER" \
|
||||
-d "$db_name" \
|
||||
-Fc \
|
||||
--no-password \
|
||||
-f "$dump_file"
|
||||
}
|
||||
|
||||
backup_globals() {
|
||||
if [[ "$PG_BACKUP_GLOBALS" != "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
|
||||
echo "[$(log_ts)] globals skipped: set PG_BACKUP_SUPERUSER_PASSWORD for role/global backup." >&2
|
||||
return 0
|
||||
fi
|
||||
local prev_user="$PGUSER" prev_pass="$PGPASSWORD"
|
||||
export PGUSER="${PG_BACKUP_SUPERUSER:-postgres}"
|
||||
export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD"
|
||||
pg_dumpall \
|
||||
-h "$PGHOST" \
|
||||
-p "$PGPORT" \
|
||||
-U "$PGUSER" \
|
||||
--globals-only \
|
||||
--no-password \
|
||||
-f "$RUN_DIR/00_globals.sql"
|
||||
export PGUSER="$prev_user"
|
||||
export PGPASSWORD="$prev_pass"
|
||||
echo "[$(log_ts)] globals saved: $RUN_DIR/00_globals.sql"
|
||||
}
|
||||
|
||||
set_backup_credentials
|
||||
|
||||
# cron 일 1회 기준: YYYYMMDD 폴더에 당일 백업 저장
|
||||
STAMP="$(date +%Y%m%d)"
|
||||
RUN_DIR="$PG_BACKUP_DIR/$STAMP"
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
echo "[$(log_ts)] pg-backup start → $RUN_DIR (scope=${PG_BACKUP_SCOPE}, retention ${PG_BACKUP_RETENTION_DAYS} days)"
|
||||
|
||||
if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" && "$PG_BACKUP_SCOPE" == "all" ]]; then
|
||||
echo "[$(log_ts)] note: PG_BACKUP_SUPERUSER_PASSWORD not set; backing up only databases visible to ${DB_USERNAME}." >&2
|
||||
fi
|
||||
|
||||
backup_globals
|
||||
|
||||
declare -a TARGET_DBS=()
|
||||
if [[ "$PG_BACKUP_SCOPE" == "single" ]]; then
|
||||
TARGET_DBS=("$DB_DATABASE")
|
||||
else
|
||||
while IFS= read -r db; do
|
||||
[[ -n "$db" ]] && TARGET_DBS+=("$db")
|
||||
done < <(list_all_databases)
|
||||
if [[ ${#TARGET_DBS[@]} -eq 0 ]]; then
|
||||
echo "pg-backup: no databases found to backup." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
MANIFEST="$RUN_DIR/00_manifest.txt"
|
||||
{
|
||||
echo "# pg-backup manifest $(log_ts)"
|
||||
echo "scope=${PG_BACKUP_SCOPE}"
|
||||
echo "host=${PGHOST}:${PGPORT}"
|
||||
echo "user=${PGUSER}"
|
||||
} > "$MANIFEST"
|
||||
|
||||
dump_ok=0
|
||||
dump_fail=0
|
||||
for db_name in "${TARGET_DBS[@]}"; do
|
||||
dump_file="$RUN_DIR/${db_name}.dump"
|
||||
echo "[$(log_ts)] dumping database: $db_name"
|
||||
if dump_database "$db_name" "$dump_file"; then
|
||||
bytes="$(wc -c < "$dump_file" | tr -d ' ')"
|
||||
echo "[$(log_ts)] dump saved: $dump_file (${bytes} bytes)"
|
||||
echo "${db_name}.dump ${bytes}" >> "$MANIFEST"
|
||||
dump_ok=$((dump_ok + 1))
|
||||
else
|
||||
echo "[$(log_ts)] dump failed: $db_name" >&2
|
||||
dump_fail=$((dump_fail + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$dump_ok" -eq 0 ]]; then
|
||||
echo "pg-backup: all database dumps failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$dump_fail" -gt 0 ]]; then
|
||||
echo "[$(log_ts)] warning: ${dump_fail} database dump(s) failed, ${dump_ok} succeeded." >&2
|
||||
fi
|
||||
|
||||
ln -sfn "$RUN_DIR" "$PG_BACKUP_DIR/latest"
|
||||
|
||||
prune_old_backups() {
|
||||
local cutoff removed=0 base day entry
|
||||
if date -d "1 day ago" +%Y%m%d >/dev/null 2>&1; then
|
||||
cutoff=$(date -d "${PG_BACKUP_RETENTION_DAYS} days ago" +%Y%m%d)
|
||||
else
|
||||
cutoff=$(date -v-"${PG_BACKUP_RETENTION_DAYS}"d +%Y%m%d)
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
for entry in "$PG_BACKUP_DIR"/*; do
|
||||
[[ -d "$entry" ]] || continue
|
||||
base=$(basename "$entry")
|
||||
[[ "$base" == "latest" ]] && continue
|
||||
if [[ "$base" =~ ^([0-9]{8}) ]]; then
|
||||
day="${BASH_REMATCH[1]}"
|
||||
if [[ "$day" < "$cutoff" ]]; then
|
||||
rm -rf "$entry"
|
||||
removed=$((removed + 1))
|
||||
echo "[$(log_ts)] retention: removed $entry (backup date $day < cutoff $cutoff)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
echo "[$(log_ts)] retention: pruned ${removed} dir(s); keeping backups from ${cutoff} onward (${PG_BACKUP_RETENTION_DAYS} days)"
|
||||
}
|
||||
|
||||
if [[ "$PG_BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] && [[ "$PG_BACKUP_RETENTION_DAYS" -gt 0 ]]; then
|
||||
prune_old_backups
|
||||
fi
|
||||
|
||||
echo "[$(log_ts)] pg-backup done: ${dump_ok} database(s), latest → $PG_BACKUP_DIR/latest"
|
||||
225
scripts/pg-restore.sh
Executable file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostgreSQL 논리 복원 (pg_restore). custom format(.dump) 전용.
|
||||
# 사용 예:
|
||||
# bash scripts/pg-restore.sh /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
|
||||
# bash scripts/pg-restore.sh --all --globals --confirm /home/xavis/workspace/backup/ai_platform/latest/
|
||||
# bash scripts/pg-restore.sh --test --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash scripts/pg-restore.sh [options] <path-to.dump | backup-dir>
|
||||
|
||||
Options:
|
||||
--all 백업 디렉터리 안의 *.dump 전체 복원 (00_globals.sql 있으면 --globals 권장)
|
||||
--test 복원 대상 DB를 {dbname}_restore_test 로 생성 (단일 dump 또는 --all)
|
||||
--clean pg_restore --clean --if-exists (기존 객체 삭제 후 복원, --test 미사용 시 위험)
|
||||
--confirm 확인 없이 실행 (cron·자동화용; --test 없이 --clean 시 필수)
|
||||
--globals 같은 디렉터리의 00_globals.sql 을 먼저 적용 (슈퍼유저 env 필요)
|
||||
-h, --help 도움말
|
||||
|
||||
환경 변수 (.env):
|
||||
DB_* 연결 정보
|
||||
PG_BACKUP_SUPERUSER --globals 시 사용 (기본 postgres)
|
||||
PG_BACKUP_SUPERUSER_PASSWORD
|
||||
EOF
|
||||
}
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=scripts/lib/load-env.sh
|
||||
source "$REPO_ROOT/scripts/lib/load-env.sh"
|
||||
|
||||
MODE="prod"
|
||||
CLEAN=0
|
||||
CONFIRM=0
|
||||
GLOBALS=0
|
||||
RESTORE_ALL=0
|
||||
TARGET_PATH=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--test) MODE="test"; shift ;;
|
||||
--clean) CLEAN=1; shift ;;
|
||||
--confirm) CONFIRM=1; shift ;;
|
||||
--globals) GLOBALS=1; shift ;;
|
||||
--all) RESTORE_ALL=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
|
||||
*)
|
||||
if [[ -n "$TARGET_PATH" ]]; then
|
||||
echo "Unexpected argument: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
TARGET_PATH="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TARGET_PATH" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
load_project_env "$REPO_ROOT/.env"
|
||||
|
||||
: "${DB_HOST:?DB_HOST is required in .env}"
|
||||
: "${DB_DATABASE:?DB_DATABASE is required in .env}"
|
||||
: "${DB_USERNAME:?DB_USERNAME is required in .env}"
|
||||
: "${DB_PASSWORD:?DB_PASSWORD is required in .env}"
|
||||
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
|
||||
export PGHOST="$DB_HOST"
|
||||
export PGPORT="$DB_PORT"
|
||||
export PGUSER="$DB_USERNAME"
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
if ! command -v pg_restore >/dev/null 2>&1; then
|
||||
echo "pg-restore: pg_restore not found. Install postgresql-client." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
|
||||
|
||||
resolve_superuser_creds() {
|
||||
SUPERUSER="${PG_BACKUP_SUPERUSER:-postgres}"
|
||||
if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
|
||||
SU_USER="$SUPERUSER"
|
||||
SU_PASS="$PG_BACKUP_SUPERUSER_PASSWORD"
|
||||
else
|
||||
SU_USER="$DB_USERNAME"
|
||||
SU_PASS="$DB_PASSWORD"
|
||||
fi
|
||||
}
|
||||
|
||||
apply_globals() {
|
||||
local globals_file="$1"
|
||||
if [[ ! -f "$globals_file" ]]; then
|
||||
echo "pg-restore: globals file not found: $globals_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
: "${PG_BACKUP_SUPERUSER_PASSWORD:?PG_BACKUP_SUPERUSER_PASSWORD required for --globals}"
|
||||
resolve_superuser_creds
|
||||
export PGUSER="$SU_USER"
|
||||
export PGPASSWORD="$SU_PASS"
|
||||
echo "[$(log_ts)] applying globals: $globals_file"
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -f "$globals_file"
|
||||
export PGUSER="$DB_USERNAME"
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
}
|
||||
|
||||
ensure_database() {
|
||||
local db_name="$1"
|
||||
resolve_superuser_creds
|
||||
export PGUSER="$SU_USER"
|
||||
export PGPASSWORD="$SU_PASS"
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -tc \
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" \
|
||||
>/dev/null 2>&1 || true
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"$db_name\";"
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -c \
|
||||
"CREATE DATABASE \"$db_name\" OWNER \"$DB_USERNAME\";"
|
||||
fi
|
||||
export PGUSER="$DB_USERNAME"
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
}
|
||||
|
||||
restore_one_dump() {
|
||||
local dump_path="$1"
|
||||
local source_db
|
||||
source_db="$(basename "$dump_path" .dump)"
|
||||
local target_db="$source_db"
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
target_db="${source_db}_restore_test"
|
||||
ensure_database "$target_db"
|
||||
fi
|
||||
|
||||
local restore_args=(
|
||||
-h "$PGHOST"
|
||||
-p "$PGPORT"
|
||||
-U "$PGUSER"
|
||||
-d "$target_db"
|
||||
--no-owner
|
||||
--role="$DB_USERNAME"
|
||||
-v
|
||||
)
|
||||
if [[ "$CLEAN" -eq 1 ]]; then
|
||||
restore_args+=(--clean --if-exists)
|
||||
fi
|
||||
|
||||
echo "[$(log_ts)] pg_restore → $target_db ($dump_path)"
|
||||
pg_restore "${restore_args[@]}" "$dump_path"
|
||||
echo "[$(log_ts)] pg-restore done: $target_db"
|
||||
}
|
||||
|
||||
confirm_restore() {
|
||||
local message="$1"
|
||||
if [[ "$MODE" == "prod" && "$CLEAN" -eq 1 && "$CONFIRM" -ne 1 ]]; then
|
||||
echo "pg-restore: --clean on production DB requires --confirm" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CONFIRM" -eq 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo "$message"
|
||||
read -r -p "Continue? [y/N] " ans
|
||||
[[ "$ans" == "y" || "$ans" == "Y" ]] || exit 0
|
||||
}
|
||||
|
||||
if [[ -d "$TARGET_PATH" ]]; then
|
||||
BACKUP_DIR="${TARGET_PATH%/}"
|
||||
if [[ "$RESTORE_ALL" -ne 1 ]]; then
|
||||
echo "pg-restore: path is a directory. Use --all to restore every *.dump inside." >&2
|
||||
exit 1
|
||||
fi
|
||||
confirm_restore "About to restore ALL dumps in: $BACKUP_DIR on $DB_HOST:$PGPORT"
|
||||
if [[ "$GLOBALS" -eq 1 ]]; then
|
||||
apply_globals "$BACKUP_DIR/00_globals.sql"
|
||||
fi
|
||||
shopt -s nullglob
|
||||
dumps=( "$BACKUP_DIR"/*.dump )
|
||||
shopt -u nullglob
|
||||
if [[ ${#dumps[@]} -eq 0 ]]; then
|
||||
echo "pg-restore: no .dump files in $BACKUP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
for dump in "${dumps[@]}"; do
|
||||
restore_one_dump "$dump"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$TARGET_PATH" ]]; then
|
||||
echo "pg-restore: path not found: $TARGET_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$RESTORE_ALL" -eq 1 ]]; then
|
||||
echo "pg-restore: --all requires a backup directory path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_DB="$DB_DATABASE"
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
TARGET_DB="${DB_DATABASE}_restore_test"
|
||||
fi
|
||||
|
||||
confirm_restore "About to restore into database: $TARGET_DB on $DB_HOST:$PGPORT
|
||||
Dump: $TARGET_PATH"
|
||||
|
||||
if [[ "$GLOBALS" -eq 1 ]]; then
|
||||
apply_globals "$(dirname "$TARGET_PATH")/00_globals.sql"
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
ensure_database "$TARGET_DB"
|
||||
fi
|
||||
|
||||
restore_one_dump "$TARGET_PATH"
|
||||
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
echo "Verify with: psql -h $DB_HOST -U $DB_USERNAME -d $TARGET_DB -c '\\dt'"
|
||||
echo "Drop test DB: psql ... -c 'DROP DATABASE \"$TARGET_DB\";'"
|
||||
fi
|
||||
49
scripts/safe-git-pull.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# 배포 서버에서 런타임 data 파일의 **로컬 수정** 때문에
|
||||
# "Your local changes to the following files would be overwritten by merge" 가 나와
|
||||
# git pull 이 막힐 때 사용합니다.
|
||||
# 동작: 지정 경로를 백업 → 인덱스/작업트리를 리셃해 pull을 허용 → 백업을 다시 복사합니다.
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# pull 전에 Git이 덮어쓰기를 거부하는 런타임 전용 경로(필요 시 추가)
|
||||
PULL_SAFE_PATHS=(
|
||||
"data/ai-success-stories.json"
|
||||
)
|
||||
|
||||
STAMP="$(date +%Y%m%d%H%M%S)"
|
||||
BAK_DIR="${REPO_ROOT}/.tmp-safe-pull-backup-${STAMP}"
|
||||
mkdir -p "$BAK_DIR"
|
||||
|
||||
backed=()
|
||||
for relpath in "${PULL_SAFE_PATHS[@]}"; do
|
||||
if [[ -f "$relpath" ]]; then
|
||||
dest="${BAK_DIR}/$(echo "$relpath" | tr / _)"
|
||||
cp -a "$relpath" "$dest"
|
||||
backed+=("$relpath|$dest")
|
||||
fi
|
||||
done
|
||||
|
||||
# 추적 중이면 머지/체크아웃이 막힘 — 먼저 백업했으므로 작업트리·스테이징만 맞춤
|
||||
for relpath in "${PULL_SAFE_PATHS[@]}"; do
|
||||
if git ls-files --error-unmatch -- "$relpath" >/dev/null 2>&1; then
|
||||
if ! git restore --worktree --staged -- "$relpath" 2>/dev/null; then
|
||||
git reset -q HEAD -- "$relpath" 2>/dev/null || true
|
||||
git checkout -- "$relpath" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
git pull "$@"
|
||||
|
||||
for ent in "${backed[@]}"; do
|
||||
relpath="${ent%%|*}"
|
||||
src="${ent#*|}"
|
||||
if [[ -f "$src" ]]; then
|
||||
cp -a "$src" "$relpath"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -rf "$BAK_DIR"
|
||||
echo "safe-git-pull: 완료. 런타임 파일은 백업에서 복원되었습니다."
|
||||
@@ -18,8 +18,12 @@
|
||||
<main class="container">
|
||||
<section class="panel">
|
||||
<p class="subtitle" style="margin-bottom: 16px">
|
||||
OPS 이메일(<strong>@xavis.co.kr</strong>) 매직 링크로 <strong>로그인에 성공한</strong> 사용자 목록입니다. 이메일과 최근 접속일(마지막 로그인 시각)을 표시합니다.
|
||||
OPS 이메일(<strong>@ncue.net</strong>) 매직 링크로 <strong>로그인에 성공한</strong> 사용자 목록입니다. 이메일과 최근 접속일(마지막 로그인 시각)을 표시합니다.
|
||||
</p>
|
||||
<p class="admin-hint" style="margin-bottom: 16px">
|
||||
<strong>전체 로그아웃</strong>을 실행하면 해당 사용자의 모든 디바이스에서 OPS 세션이 즉시 무효화됩니다. 이후 다시 매직 링크로 로그인해야 합니다.
|
||||
</p>
|
||||
<p id="adminUsersFlash" class="form-message" hidden role="status"></p>
|
||||
<% if (typeof dbError !== 'undefined' && dbError) { %>
|
||||
<p class="admin-error">목록을 불러오지 못했습니다: <%= dbError %></p>
|
||||
<% } else if (!pgConnected) { %>
|
||||
@@ -33,11 +37,13 @@
|
||||
<tr>
|
||||
<th scope="col">이메일</th>
|
||||
<th scope="col">최근 접속일</th>
|
||||
<th scope="col">세션 무효화</th>
|
||||
<th scope="col">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% users.forEach(function (u) { %>
|
||||
<tr>
|
||||
<tr data-email="<%= u.email %>">
|
||||
<td><%= u.email %></td>
|
||||
<td>
|
||||
<% if (u.lastLoginAt) { %>
|
||||
@@ -46,6 +52,22 @@
|
||||
—
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="js-sessions-revoked-at">
|
||||
<% if (u.sessionsRevokedAt) { %>
|
||||
<%= new Date(u.sessionsRevokedAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) %>
|
||||
<% } else { %>
|
||||
—
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="danger btn-sm js-revoke-sessions-btn"
|
||||
data-email="<%= u.email %>"
|
||||
>
|
||||
전체 로그아웃
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
@@ -57,5 +79,54 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const flash = document.getElementById("adminUsersFlash");
|
||||
function showFlash(text, isError) {
|
||||
if (!flash) return;
|
||||
flash.textContent = text;
|
||||
flash.hidden = false;
|
||||
flash.style.color = isError ? "#b91c1c" : "#059669";
|
||||
}
|
||||
|
||||
document.querySelectorAll(".js-revoke-sessions-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", async function () {
|
||||
const email = btn.getAttribute("data-email") || "";
|
||||
if (!email) return;
|
||||
const ok = window.confirm(
|
||||
email + " 사용자의 모든 디바이스에서 OPS 세션을 만료(전체 로그아웃)시키겠습니까?"
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/admin/users/revoke-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email }),
|
||||
});
|
||||
const data = await res.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "요청 실패");
|
||||
}
|
||||
const row = btn.closest("tr");
|
||||
const cell = row && row.querySelector(".js-sessions-revoked-at");
|
||||
if (cell && data.revokedAt) {
|
||||
cell.textContent = new Date(data.revokedAt).toLocaleString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
});
|
||||
}
|
||||
showFlash(email + " 사용자의 세션이 무효화되었습니다.");
|
||||
} catch (err) {
|
||||
showFlash(err.message || "세션 만료 처리 실패", true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<%- include('partials/favicon') %>
|
||||
<title><%= story.title %> - AI 성공 사례 - XAVIS</title>
|
||||
<title><%= story.title %> - AI 활용 사례 - XAVIS</title>
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="content-area">
|
||||
<% if (showPdfViewer) { %>
|
||||
<main class="viewer-wrap ai-case-viewer">
|
||||
<a href="/ai-cases" class="back-link">← AI 성공 사례로 돌아가기</a>
|
||||
<a href="/ai-cases" class="back-link">← AI 활용 사례로 돌아가기</a>
|
||||
<h1><%= story.title %></h1>
|
||||
<p class="description"><%= story.excerpt || (story.department + ' · ' + story.author) %></p>
|
||||
<div class="ppt-tools ai-case-ppt-tools ppt-tools-row">
|
||||
@@ -69,7 +69,7 @@
|
||||
</main>
|
||||
<% } else { %>
|
||||
<main class="viewer-wrap ai-case-viewer">
|
||||
<a href="/ai-cases" class="back-link">← AI 성공 사례로 돌아가기</a>
|
||||
<a href="/ai-cases" class="back-link">← AI 활용 사례로 돌아가기</a>
|
||||
<h1><%= story.title %></h1>
|
||||
<p class="description"><%= story.excerpt || (story.department + ' · ' + story.author) %></p>
|
||||
<div class="ppt-tools ai-case-ppt-tools">
|
||||
|
||||
771
views/ai-cases-compose.ejs
Normal file
@@ -0,0 +1,771 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<%- include('partials/favicon') %>
|
||||
<title>AI 활용 사례 > 작성하기 - XAVIS</title>
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.2.2/toastui-editor.min.css" />
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
|
||||
<div class="content-area">
|
||||
<header class="topbar">
|
||||
<h1>AI 활용 사례 > <%= editPayload ? "수정" : "작성" %></h1>
|
||||
<a class="top-action-link top-action-link--ghost" href="/ai-cases">목록</a>
|
||||
</header>
|
||||
<main class="container container-narrow use-case-compose">
|
||||
<p class="use-case-compose__hint">
|
||||
<strong>작성자: </strong> <%= typeof submitterEmail !== "undefined" ? submitterEmail : "" %>
|
||||
</p>
|
||||
<form id="use-case-form" class="use-case-compose__form" novalidate>
|
||||
<div class="use-case-field">
|
||||
<label for="uctitle">제목 <span class="req">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="uctitle"
|
||||
name="title"
|
||||
class="use-case-title-input"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="제목을 입력하세요"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="use-case-section use-case-section--rich use-case-section--merged">
|
||||
<label class="label-block" for="edUseCaseBody">본문 (STAR: 1~4번) <span class="req">*</span></label>
|
||||
<p class="field-hint use-case-merge-hint">한 편집기 안에서 1.Situation → 4.Result 순서로 작성합니다.<br>구역을 나누는 제목·블록을 삭제하면 저장이 실패할 수 있습니다.</p>
|
||||
<div id="edUseCaseBody" class="use-case-editor-host use-case-editor-host--merged" aria-label="STAR 본문"></div>
|
||||
</div>
|
||||
<p class="use-case-charcount" id="ucCharLine" aria-live="polite">0 / <%= maxBodyTotal %>자</p>
|
||||
<div class="use-case-compose__meta">
|
||||
<div class="use-case-field use-case-field--tags">
|
||||
<label for="uctags">활용 AI</label>
|
||||
<input
|
||||
type="text"
|
||||
id="uctags"
|
||||
name="tags"
|
||||
class="use-case-tag-input"
|
||||
placeholder="쉼표로 구분 (예, Claude, Slack, Canvas)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="field-hint">활용한 AI 툴을 입력해주세요.</p>
|
||||
</div>
|
||||
<div class="use-case-field" id="ucThumbField">
|
||||
<span class="label-block">썸네일 이미지 <% if (!editPayload) { %><span class="req">*</span><% } else { %><span class="opt">(변경·추가 시에만)</span><% } %></span>
|
||||
<p class="field-hint" id="ucThumbFieldHint">최대 <%= maxThumbCount %>개, 개당 5MB, 가급 1:1 비율 권장</p>
|
||||
<% if (typeof editPayload !== "undefined" && editPayload) { %>
|
||||
<p class="field-hint use-case-attach-hint">이미지 우측 상단 <strong>X</strong>로 삭제할 수 있습니다. 새 이미지는 아래에서 고르면 목록 끝에 추가됩니다(합계 최대 <%= maxThumbCount %>개).</p>
|
||||
<% } %>
|
||||
<div class="use-case-dropzone" id="ucThumbZone">
|
||||
<input type="file" id="ucThumb" name="thumbnail" accept="image/*" class="use-case-file-input" multiple />
|
||||
<label for="ucThumb" class="use-case-dropzone__label"
|
||||
>클릭하거나 드래그하여 썸네일을 선택하세요 (최대 <%= maxThumbCount %>개)</label
|
||||
>
|
||||
</div>
|
||||
<div class="use-case-thumb-grid" id="ucThumbPreviewGrid" hidden>
|
||||
<% if (typeof editPayload !== "undefined" && editPayload && editPayload.existingThumbnails && editPayload.existingThumbnails.length) { %>
|
||||
<% editPayload.existingThumbnails.forEach(function (t) { var fn = (t.originalName || "썸네일").replace(/[<>"]/g, "") || (t.relativePath && t.relativePath.split("/").pop()) || "썸네일"; %>
|
||||
<figure class="use-case-thumb-tile" data-existing-rp="<%- encodeURIComponent(t.relativePath) %>">
|
||||
<img src="<%= t.relativePath %>" alt="<%= fn %>" loading="lazy" decoding="async" />
|
||||
<button type="button" class="use-case-thumb-remove" aria-label="<%= fn %> 제거" title="제거">×</button>
|
||||
</figure>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="use-case-field">
|
||||
<span class="label-block">첨부 파일 <span class="opt">(선택, 최대 <%= maxAttachCount %>개·20MB)</span></span>
|
||||
<% if (typeof editPayload !== "undefined" && editPayload) { %>
|
||||
<p class="field-hint use-case-attach-hint">수정 시: 아래 <strong>기존 첨부</strong>를 체크해 삭제한 뒤 새 파일을 선택할 수 있습니다(최대 <%= maxAttachCount %>개).</p>
|
||||
<% } %>
|
||||
<input type="file" id="ucFiles" name="attachments" class="use-case-file-multi" />
|
||||
<% if (typeof editPayload !== "undefined" && editPayload && editPayload.existingAttachments && editPayload.existingAttachments.length) { %>
|
||||
<p class="field-hint use-case-existing-attach__title">기존 첨부 — 제거하려면 체크</p>
|
||||
<ul class="use-case-existing-attach">
|
||||
<% editPayload.existingAttachments.forEach(function (a) { var fn = (a.originalName || "").replace(/[<>"]/g, "") || (a.relativePath && a.relativePath.split("/").pop()) || "첨부"; %>
|
||||
<li>
|
||||
<label class="use-case-cb"
|
||||
><input type="checkbox" class="js-uc-remove-attach" data-rp="<%- encodeURIComponent(a.relativePath) %>" /> <%= fn %></label
|
||||
>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
<% } %>
|
||||
<ul class="use-case-file-list" id="ucFileList" hidden></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="use-case-actions">
|
||||
<a href="/ai-cases" class="btn-ghost use-case-btn-cancel">취소</a>
|
||||
<button type="submit" class="top-action use-case-btn-save" id="ucSubmit"><%= editPayload ? "수정 저장" : "저장하기" %></button>
|
||||
</div>
|
||||
<p class="form-message use-case-form-msg" id="ucFormMsg" hidden></p>
|
||||
<% if (editPayload) { %>
|
||||
<script type="application/json" id="uc-edit-json">
|
||||
<%- JSON.stringify(editPayload).replace(/</g, "\\u003c") %>
|
||||
</script>
|
||||
<% } %>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://uicdn.toast.com/editor/3.2.2/toastui-editor-all.min.js"></script>
|
||||
<script src="https://uicdn.toast.com/editor/3.2.2/i18n/ko-kr.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var maxTotal = <%= maxBodyTotal %>;
|
||||
var maxThumbCount = <%= maxThumbCount %>;
|
||||
var maxAttachCount = <%= maxAttachCount %>;
|
||||
var ph = {
|
||||
situation: <%- JSON.stringify(placeholders.situation) %>,
|
||||
task: <%- JSON.stringify(placeholders.task) %>,
|
||||
action: <%- JSON.stringify(placeholders.action) %>,
|
||||
result: <%- JSON.stringify(placeholders.result) %>,
|
||||
};
|
||||
var charLine = document.getElementById("ucCharLine");
|
||||
var editJson = document.getElementById("uc-edit-json");
|
||||
var editPayload = null;
|
||||
if (editJson) {
|
||||
try {
|
||||
editPayload = JSON.parse(editJson.textContent);
|
||||
} catch (e) {
|
||||
editPayload = null;
|
||||
}
|
||||
}
|
||||
var form = document.getElementById("use-case-form");
|
||||
var msg = document.getElementById("ucFormMsg");
|
||||
var submitBtn = document.getElementById("ucSubmit");
|
||||
var thumb = document.getElementById("ucThumb");
|
||||
var thumbGrid = document.getElementById("ucThumbPreviewGrid");
|
||||
var pendingThumbFiles = [];
|
||||
var pendingThumbUrls = [];
|
||||
var filesIn = document.getElementById("ucFiles");
|
||||
var fileList = document.getElementById("ucFileList");
|
||||
var sectionIds = ["uc-situation", "uc-task", "uc-action", "uc-result"];
|
||||
var fieldKeys = ["situation", "taskGoal", "actionTaken", "resultOutcome"];
|
||||
var phKeysByField = {
|
||||
situation: "situation",
|
||||
taskGoal: "task",
|
||||
actionTaken: "action",
|
||||
resultOutcome: "result",
|
||||
};
|
||||
var placeholdersActive = !editPayload;
|
||||
var examplesComposeMode = !editPayload;
|
||||
if (typeof toastui === "undefined" || !toastui.Editor) {
|
||||
if (form) {
|
||||
var p = document.createElement("p");
|
||||
p.className = "form-message use-case-form-msg";
|
||||
p.style.color = "#b91c1c";
|
||||
p.textContent = "에디터를 불러오지 못했습니다. 네트워크·방화벽을 확인하거나 uicdn.toast.com 접속을 허용해 주세요.";
|
||||
form.insertBefore(p, form.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
function esc(s) {
|
||||
if (s == null) return "";
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
function paraFromPh(text) {
|
||||
if (!text) return "<p><br></p>";
|
||||
return (
|
||||
'<p class="uc-example-text" data-uc-example="1">' +
|
||||
esc(text).replace(/\r\n/g, "\n").split("\n").join("<br>") +
|
||||
"</p>"
|
||||
);
|
||||
}
|
||||
function buildHtmlFromSections(sec) {
|
||||
sec = sec || {};
|
||||
return (
|
||||
'<div class="uc-doc">' +
|
||||
'<div id="uc-situation" class="uc-section"><h2>1. Situation (배경)</h2>' +
|
||||
(sec.situation || "<p><br></p>") +
|
||||
"</div>" +
|
||||
'<div id="uc-task" class="uc-section"><h2>2. Task (과제/목표)</h2>' +
|
||||
(sec.taskGoal || "<p><br></p>") +
|
||||
"</div>" +
|
||||
'<div id="uc-action" class="uc-section"><h2>3. Action (행동)</h2>' +
|
||||
(sec.actionTaken || "<p><br></p>") +
|
||||
"</div>" +
|
||||
'<div id="uc-result" class="uc-section"><h2>4. Result (결과)</h2>' +
|
||||
(sec.resultOutcome || "<p><br></p>") +
|
||||
"</div>" +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
function buildInitialHtml() {
|
||||
return buildHtmlFromSections({
|
||||
situation: paraFromPh(ph.situation),
|
||||
taskGoal: paraFromPh(ph.task),
|
||||
actionTaken: paraFromPh(ph.action),
|
||||
resultOutcome: paraFromPh(ph.result),
|
||||
});
|
||||
}
|
||||
function normalizePlain(s) {
|
||||
return String(s || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
function isPlaceholderBody(html, phKey) {
|
||||
var phPlain = normalizePlain(ph[phKey]);
|
||||
if (!phPlain) return false;
|
||||
return normalizePlain(stripForCount(html)) === phPlain;
|
||||
}
|
||||
function sectionHasExampleMarker(html) {
|
||||
return /uc-example-text|data-uc-example/.test(String(html || ""));
|
||||
}
|
||||
function isSectionBodyEmpty(html) {
|
||||
return stripForCount(html).length === 0;
|
||||
}
|
||||
function hasUserBodyInput() {
|
||||
var ex = extractSections(editor.getHTML());
|
||||
for (var i = 0; i < fieldKeys.length; i++) {
|
||||
var body = ex[fieldKeys[i]] || "";
|
||||
if (sectionHasExampleMarker(body)) return false;
|
||||
if (!isSectionBodyEmpty(body) && !isPlaceholderBody(body, phKeysByField[fieldKeys[i]])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function restoreExamplesIfEmptyDraft() {
|
||||
if (!examplesComposeMode || placeholdersActive) return;
|
||||
if (hasUserBodyInput()) return;
|
||||
placeholdersActive = true;
|
||||
editor.setHTML(buildInitialHtml());
|
||||
count();
|
||||
}
|
||||
/** 본문 영역 첫 클릭 시 STAR 1~4 예시 문단만 제거(제목은 유지) */
|
||||
function clearExampleContentOnActivate() {
|
||||
if (!placeholdersActive) return;
|
||||
placeholdersActive = false;
|
||||
var empty = "<p><br></p>";
|
||||
var wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = editor.getHTML();
|
||||
var clearedById = false;
|
||||
for (var i = 0; i < sectionIds.length; i++) {
|
||||
var sec = wrapper.querySelector("#" + sectionIds[i]);
|
||||
if (!sec) continue;
|
||||
var head = sec.querySelector(":scope > h1, :scope > h2, :scope > h3, :scope > h4");
|
||||
sec.innerHTML = (head ? head.outerHTML : "") + empty;
|
||||
clearedById = true;
|
||||
}
|
||||
setTimeout(function () {
|
||||
if (clearedById) {
|
||||
editor.setHTML(wrapper.innerHTML);
|
||||
} else {
|
||||
editor.setHTML(
|
||||
buildHtmlFromSections({
|
||||
situation: empty,
|
||||
taskGoal: empty,
|
||||
actionTaken: empty,
|
||||
resultOutcome: empty,
|
||||
})
|
||||
);
|
||||
}
|
||||
count();
|
||||
}, 0);
|
||||
}
|
||||
/** @returns {{ situation: string, taskGoal: string, actionTaken: string, resultOutcome: string, ok: boolean, err?: string }} */
|
||||
function extractSections(html) {
|
||||
var out = { situation: "", taskGoal: "", actionTaken: "", resultOutcome: "", ok: true };
|
||||
var w = document.createElement("div");
|
||||
w.innerHTML = html;
|
||||
var got = 0;
|
||||
for (var i = 0; i < sectionIds.length; i++) {
|
||||
var sec = w.querySelector("#" + sectionIds[i]);
|
||||
if (sec) {
|
||||
var c = sec.cloneNode(true);
|
||||
var hx = c.querySelector(":scope > h1, :scope > h2, :scope > h3, :scope > h4");
|
||||
if (hx) hx.remove();
|
||||
out[fieldKeys[i]] = c.innerHTML.trim();
|
||||
got++;
|
||||
}
|
||||
}
|
||||
if (got === 4) {
|
||||
out.ok = true;
|
||||
return out;
|
||||
}
|
||||
var byH = extractByHeadings(w);
|
||||
if (byH) {
|
||||
byH.ok = true;
|
||||
return byH;
|
||||
}
|
||||
return { situation: out.situation, taskGoal: out.taskGoal, actionTaken: out.actionTaken, resultOutcome: out.resultOutcome, ok: false, err: "split" };
|
||||
}
|
||||
function extractByHeadings(wrap) {
|
||||
var patterns = [
|
||||
{ key: "situation", re: /1\.\s*Situation/i },
|
||||
{ key: "taskGoal", re: /2\.\s*Task/i },
|
||||
{ key: "actionTaken", re: /3\.\s*Action/i },
|
||||
{ key: "resultOutcome", re: /4\.\s*Result/i },
|
||||
];
|
||||
var heads = wrap.querySelectorAll("h1, h2, h3, h4");
|
||||
var found = [];
|
||||
for (var h = 0; h < heads.length; h++) {
|
||||
var t = (heads[h].textContent || "").replace(/\s+/g, " ").trim();
|
||||
for (var p = 0; p < patterns.length; p++) {
|
||||
if (patterns[p].re.test(t)) {
|
||||
found.push({ key: patterns[p].key, el: heads[h] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found.length < 4) {
|
||||
return null;
|
||||
}
|
||||
var seen = {};
|
||||
for (var f = 0; f < found.length; f++) {
|
||||
if (seen[found[f].key]) return null;
|
||||
seen[found[f].key] = true;
|
||||
}
|
||||
if (Object.keys(seen).length !== 4) return null;
|
||||
found.sort(function (a, b) {
|
||||
var po = a.el.compareDocumentPosition(b.el);
|
||||
if (po & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
|
||||
if (po & Node.DOCUMENT_POSITION_PRECEDING) return 1;
|
||||
return 0;
|
||||
});
|
||||
var res = { situation: "", taskGoal: "", actionTaken: "", resultOutcome: "" };
|
||||
for (var s = 0; s < found.length; s++) {
|
||||
var head = found[s].el;
|
||||
var boundary = s + 1 < found.length ? found[s + 1].el : null;
|
||||
var n = head.nextSibling;
|
||||
var parts = [];
|
||||
while (n) {
|
||||
if (boundary && n === boundary) break;
|
||||
if (n.nodeType === 1) {
|
||||
parts.push(n.outerHTML);
|
||||
}
|
||||
n = n.nextSibling;
|
||||
}
|
||||
res[found[s].key] = parts.join("").trim();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
function stripForCount(html) {
|
||||
if (!html) return "";
|
||||
return String(html)
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&#[0-9]+;/g, " ")
|
||||
.replace(/&[a-zA-Z0-9]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
var edHost = document.getElementById("edUseCaseBody");
|
||||
var editor = new toastui.Editor({
|
||||
el: edHost,
|
||||
height: "720px",
|
||||
initialEditType: "wysiwyg",
|
||||
previewStyle: "vertical",
|
||||
hideModeSwitch: true,
|
||||
useCommandShortcut: true,
|
||||
usageStatistics: false,
|
||||
language: "ko",
|
||||
placeholder: "STAR 4구역(1~4번)이 보이지 않으면 페이지를 새로고침하세요.",
|
||||
toolbarItems: [
|
||||
["heading", "bold", "italic", "strike"],
|
||||
["hr", "quote"],
|
||||
["ul", "ol", "task", "indent", "outdent"],
|
||||
["table", "link"],
|
||||
["code", "codeblock"],
|
||||
],
|
||||
});
|
||||
if (editPayload && editPayload.html) {
|
||||
document.getElementById("uctitle").value = editPayload.title || "";
|
||||
document.getElementById("uctags").value = editPayload.tags || "";
|
||||
editor.setHTML(editPayload.html);
|
||||
if (editPayload.existingThumbnails && editPayload.existingThumbnails.length) {
|
||||
var h = document.getElementById("ucThumbFieldHint");
|
||||
if (h) h.textContent = "새로 선택하면 목록 끝에 추가됩니다. (최대 " + maxThumbCount + "개, 개당 5MB, 1:1 권장)";
|
||||
if (thumbGrid) thumbGrid.hidden = false;
|
||||
}
|
||||
} else {
|
||||
editor.setHTML(buildInitialHtml());
|
||||
}
|
||||
var titleInput = document.getElementById("uctitle");
|
||||
function focusTitleInput() {
|
||||
if (titleInput) titleInput.focus({ preventScroll: true });
|
||||
}
|
||||
if (examplesComposeMode) {
|
||||
edHost.addEventListener(
|
||||
"mousedown",
|
||||
function (ev) {
|
||||
if (!placeholdersActive || ev.button !== 0) return;
|
||||
if (!ev.target.closest(".toastui-editor-contents")) return;
|
||||
clearExampleContentOnActivate();
|
||||
},
|
||||
true
|
||||
);
|
||||
document.addEventListener(
|
||||
"mousedown",
|
||||
function (ev) {
|
||||
if (ev.button !== 0 || placeholdersActive) return;
|
||||
if (edHost.contains(ev.target)) return;
|
||||
restoreExamplesIfEmptyDraft();
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
focusTitleInput();
|
||||
setTimeout(focusTitleInput, 0);
|
||||
setTimeout(focusTitleInput, 100);
|
||||
function count() {
|
||||
var ex = extractSections(editor.getHTML());
|
||||
var n = 0;
|
||||
for (var i = 0; i < fieldKeys.length; i++) {
|
||||
n += stripForCount(ex[fieldKeys[i]] || "").length;
|
||||
}
|
||||
if (charLine) {
|
||||
charLine.textContent = n + " / " + maxTotal + "자";
|
||||
}
|
||||
return n;
|
||||
}
|
||||
editor.on("change", function () {
|
||||
count();
|
||||
});
|
||||
count();
|
||||
function countKeptExistingThumbs() {
|
||||
if (!thumbGrid) return 0;
|
||||
return thumbGrid.querySelectorAll(".use-case-thumb-tile[data-existing-rp]:not(.is-removed)").length;
|
||||
}
|
||||
function countPendingThumbs() {
|
||||
return pendingThumbFiles.length;
|
||||
}
|
||||
function countRemainingThumbsOnSubmit() {
|
||||
return countKeptExistingThumbs() + countPendingThumbs();
|
||||
}
|
||||
function revokePendingThumbUrls() {
|
||||
for (var ui = 0; ui < pendingThumbUrls.length; ui++) {
|
||||
try {
|
||||
URL.revokeObjectURL(pendingThumbUrls[ui]);
|
||||
} catch (e) {}
|
||||
}
|
||||
pendingThumbUrls = [];
|
||||
}
|
||||
function updateThumbGridVisibility() {
|
||||
if (!thumbGrid) return;
|
||||
var visible =
|
||||
thumbGrid.querySelectorAll(".use-case-thumb-tile:not(.is-removed)").length > 0 ||
|
||||
countPendingThumbs() > 0;
|
||||
thumbGrid.hidden = !visible;
|
||||
}
|
||||
function renderPendingThumbTiles() {
|
||||
if (!thumbGrid) return;
|
||||
var oldPending = thumbGrid.querySelectorAll(".use-case-thumb-tile[data-pending-index]");
|
||||
for (var oi = 0; oi < oldPending.length; oi++) {
|
||||
oldPending[oi].remove();
|
||||
}
|
||||
revokePendingThumbUrls();
|
||||
for (var pi = 0; pi < pendingThumbFiles.length; pi++) {
|
||||
var file = pendingThumbFiles[pi];
|
||||
var url = URL.createObjectURL(file);
|
||||
pendingThumbUrls.push(url);
|
||||
var fig = document.createElement("figure");
|
||||
fig.className = "use-case-thumb-tile";
|
||||
fig.setAttribute("data-pending-index", String(pi));
|
||||
var img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = file.name || "썸네일";
|
||||
img.loading = "lazy";
|
||||
img.decoding = "async";
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "use-case-thumb-remove";
|
||||
btn.setAttribute("aria-label", (file.name || "썸네일") + " 제거");
|
||||
btn.title = "제거";
|
||||
btn.textContent = "×";
|
||||
fig.appendChild(img);
|
||||
fig.appendChild(btn);
|
||||
thumbGrid.appendChild(fig);
|
||||
}
|
||||
updateThumbGridVisibility();
|
||||
}
|
||||
function notifyThumbLimitPopup(addedCount, selectedCount) {
|
||||
var base = "썸네일은 최대 " + maxThumbCount + "개까지 업로드할 수 있습니다.";
|
||||
if (typeof addedCount === "number" && typeof selectedCount === "number" && selectedCount > addedCount && addedCount > 0) {
|
||||
window.alert(base + "\n\n선택하신 " + selectedCount + "개 중 " + addedCount + "개만 추가되었습니다.");
|
||||
return;
|
||||
}
|
||||
if (typeof addedCount === "number" && addedCount === 0) {
|
||||
window.alert(base + "\n\n더 이상 추가할 수 없습니다. 기존 썸네일을 삭제한 뒤 다시 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
window.alert(base);
|
||||
}
|
||||
function addPendingThumbFiles(fileList) {
|
||||
if (!fileList || !fileList.length) return true;
|
||||
var keptExisting = countKeptExistingThumbs();
|
||||
var remaining = maxThumbCount - keptExisting - pendingThumbFiles.length;
|
||||
if (remaining <= 0) {
|
||||
notifyThumbLimitPopup(0, fileList.length);
|
||||
return false;
|
||||
}
|
||||
var selectedCount = fileList.length;
|
||||
var addedCount = 0;
|
||||
for (var ai = 0; ai < fileList.length; ai++) {
|
||||
if (addedCount >= remaining) break;
|
||||
var f = fileList[ai];
|
||||
if (!/^image\//.test(f.type || "")) {
|
||||
msg.textContent = "썸네일은 이미지 파일만 업로드할 수 있습니다.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return false;
|
||||
}
|
||||
if (f.size > 5 * 1024 * 1024) {
|
||||
msg.textContent = '"' + (f.name || "파일") + '"은 5MB 이하여야 합니다.';
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return false;
|
||||
}
|
||||
pendingThumbFiles.push(f);
|
||||
addedCount++;
|
||||
}
|
||||
if (selectedCount > remaining) {
|
||||
notifyThumbLimitPopup(addedCount, selectedCount);
|
||||
}
|
||||
msg.hidden = true;
|
||||
renderPendingThumbTiles();
|
||||
return true;
|
||||
}
|
||||
if (thumbGrid) {
|
||||
thumbGrid.addEventListener("click", function (ev) {
|
||||
var btn = ev.target.closest(".use-case-thumb-remove");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
var tile = btn.closest(".use-case-thumb-tile");
|
||||
if (!tile) return;
|
||||
if (tile.hasAttribute("data-existing-rp")) {
|
||||
tile.classList.add("is-removed");
|
||||
updateThumbGridVisibility();
|
||||
return;
|
||||
}
|
||||
if (tile.hasAttribute("data-pending-index")) {
|
||||
var idx = parseInt(tile.getAttribute("data-pending-index"), 10);
|
||||
if (!Number.isNaN(idx) && idx >= 0 && idx < pendingThumbFiles.length) {
|
||||
pendingThumbFiles.splice(idx, 1);
|
||||
renderPendingThumbTiles();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (thumb) {
|
||||
thumb.addEventListener("change", function () {
|
||||
if (!thumb.files || !thumb.files.length) return;
|
||||
addPendingThumbFiles(thumb.files);
|
||||
thumb.value = "";
|
||||
});
|
||||
}
|
||||
if (filesIn && fileList) {
|
||||
filesIn.addEventListener("change", function () {
|
||||
fileList.innerHTML = "";
|
||||
if (!filesIn.files || !filesIn.files.length) {
|
||||
fileList.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (filesIn.files.length > maxAttachCount) {
|
||||
msg.textContent = "첨부 파일은 최대 " + maxAttachCount + "개만 선택할 수 있습니다.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
filesIn.value = "";
|
||||
fileList.hidden = true;
|
||||
return;
|
||||
}
|
||||
var keptAttach = 0;
|
||||
var attachCbs = document.querySelectorAll(".js-uc-remove-attach");
|
||||
for (var ai = 0; ai < attachCbs.length; ai++) {
|
||||
if (!attachCbs[ai].checked) keptAttach++;
|
||||
}
|
||||
if (keptAttach + filesIn.files.length > maxAttachCount) {
|
||||
msg.textContent =
|
||||
"첨부 파일은 최대 " +
|
||||
maxAttachCount +
|
||||
"개입니다. 기존 첨부를 제거한 뒤 새 파일을 선택해 주세요.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
filesIn.value = "";
|
||||
fileList.hidden = true;
|
||||
return;
|
||||
}
|
||||
msg.hidden = true;
|
||||
fileList.hidden = false;
|
||||
for (var k = 0; k < filesIn.files.length; k++) {
|
||||
var li = document.createElement("li");
|
||||
li.textContent = filesIn.files[k].name;
|
||||
fileList.appendChild(li);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (form) {
|
||||
form.addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
var ex = extractSections(editor.getHTML());
|
||||
if (ex && ex.ok === false) {
|
||||
msg.textContent =
|
||||
"1~4번 구역(#uc-situation 등)을 찾을 수 없습니다. 구역을 삭제하지 말고, 제목(1. Situation …)이 보이는지 확인해 주세요.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (count() > maxTotal) {
|
||||
msg.textContent = "본문(4개 섹션 합계)은 " + maxTotal + "자 이하여야 합니다. (보이는 텍스트 기준)";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
for (var v = 0; v < fieldKeys.length; v++) {
|
||||
var fk = fieldKeys[v];
|
||||
var body = ex[fk];
|
||||
if (
|
||||
isSectionBodyEmpty(body) ||
|
||||
isPlaceholderBody(body, phKeysByField[fk]) ||
|
||||
sectionHasExampleMarker(body)
|
||||
) {
|
||||
msg.textContent =
|
||||
(v + 1) +
|
||||
"번 구역에 내용이 없습니다. 예시는 본문 영역 클릭 시 자동으로 지워집니다. 본인 내용을 입력해 주세요. (빈 줄·서식만이면 제출할 수 없습니다.)";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!editPayload) {
|
||||
if (countPendingThumbs() < 1) {
|
||||
msg.textContent = "썸네일 이미지를 1개 이상 선택해 주세요.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
} else if (countRemainingThumbsOnSubmit() < 1) {
|
||||
msg.textContent = "썸네일은 1개 이상 남겨야 합니다.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (countRemainingThumbsOnSubmit() > maxThumbCount) {
|
||||
notifyThumbLimitPopup();
|
||||
msg.textContent = "썸네일은 최대 " + maxThumbCount + "개까지 등록할 수 있습니다.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
for (var tv = 0; tv < pendingThumbFiles.length; tv++) {
|
||||
if (pendingThumbFiles[tv].size > 5 * 1024 * 1024) {
|
||||
msg.textContent = "썸네일 " + (tv + 1) + "번째 파일은 5MB 이하여야 합니다.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
var isEdit = editPayload && editPayload.id;
|
||||
var fd = new FormData();
|
||||
fd.append("title", document.getElementById("uctitle").value.trim());
|
||||
fd.append("situation", ex.situation);
|
||||
fd.append("taskGoal", ex.taskGoal);
|
||||
fd.append("actionTaken", ex.actionTaken);
|
||||
fd.append("resultOutcome", ex.resultOutcome);
|
||||
fd.append("tags", document.getElementById("uctags").value);
|
||||
for (var t = 0; t < pendingThumbFiles.length; t++) {
|
||||
fd.append("thumbnail", pendingThumbFiles[t]);
|
||||
}
|
||||
var keptAttachSubmit = 0;
|
||||
var attachCbsSubmit = document.querySelectorAll(".js-uc-remove-attach");
|
||||
for (var asi = 0; asi < attachCbsSubmit.length; asi++) {
|
||||
if (!attachCbsSubmit[asi].checked) keptAttachSubmit++;
|
||||
}
|
||||
var newAttachCount = filesIn && filesIn.files ? filesIn.files.length : 0;
|
||||
if (keptAttachSubmit + newAttachCount > maxAttachCount) {
|
||||
msg.textContent =
|
||||
"첨부 파일은 최대 " +
|
||||
maxAttachCount +
|
||||
"개입니다. 기존 첨부를 제거한 뒤 새 파일을 선택해 주세요.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (filesIn && filesIn.files) {
|
||||
for (var f = 0; f < filesIn.files.length; f++) {
|
||||
if (filesIn.files[f].size > 20 * 1024 * 1024) {
|
||||
msg.textContent = "첨부 파일은 20MB 이하여야 합니다.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
return;
|
||||
}
|
||||
fd.append("attachments", filesIn.files[f]);
|
||||
}
|
||||
}
|
||||
if (isEdit) {
|
||||
var rmThumb = [];
|
||||
if (thumbGrid) {
|
||||
var removedTiles = thumbGrid.querySelectorAll(".use-case-thumb-tile[data-existing-rp].is-removed");
|
||||
for (var ti = 0; ti < removedTiles.length; ti++) {
|
||||
try {
|
||||
var trp = decodeURIComponent(removedTiles[ti].getAttribute("data-existing-rp") || "");
|
||||
if (trp) rmThumb.push(trp);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
if (rmThumb.length) {
|
||||
fd.append("removeThumbnailPaths", JSON.stringify(rmThumb));
|
||||
}
|
||||
var rm = [];
|
||||
var cbs = document.querySelectorAll(".js-uc-remove-attach");
|
||||
for (var ci = 0; ci < cbs.length; ci++) {
|
||||
if (cbs[ci].checked) {
|
||||
try {
|
||||
var rp = decodeURIComponent(cbs[ci].getAttribute("data-rp") || "");
|
||||
if (rp) rm.push(rp);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
if (rm.length) {
|
||||
fd.append("removeAttachmentPaths", JSON.stringify(rm));
|
||||
}
|
||||
}
|
||||
msg.hidden = true;
|
||||
submitBtn.disabled = true;
|
||||
var url = isEdit ? "/api/ai-use-case-submissions/" + encodeURIComponent(editPayload.id) : "/api/ai-use-case-submissions";
|
||||
var method = isEdit ? "PUT" : "POST";
|
||||
fetch(url, { method: method, body: fd, credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
return { ok: r.ok, j: j, status: r.status };
|
||||
});
|
||||
})
|
||||
.then(function (o) {
|
||||
if (o.ok && o.j && o.j.ok) {
|
||||
if (isEdit && o.j && o.j.id) {
|
||||
window.location.href = "/ai-cases/submit/" + encodeURIComponent(o.j.id) + "?updated=1";
|
||||
} else {
|
||||
window.location.href = "/ai-cases?submitted=1";
|
||||
}
|
||||
return;
|
||||
}
|
||||
msg.textContent = (o.j && o.j.error) || "저장에 실패했습니다.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
})
|
||||
.catch(function () {
|
||||
msg.textContent = "요청에 실패했습니다.";
|
||||
msg.style.color = "#b91c1c";
|
||||
msg.hidden = false;
|
||||
})
|
||||
.finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<%- include('partials/favicon') %>
|
||||
<title>AI 성공 사례 관리 - XAVIS</title>
|
||||
<title>AI 활용 사례 관리 - XAVIS</title>
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
<style>
|
||||
.visually-hidden {
|
||||
@@ -37,12 +37,12 @@
|
||||
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
|
||||
<div class="content-area">
|
||||
<header class="topbar">
|
||||
<h1>AI 성공 사례 관리</h1>
|
||||
<h1>AI 활용 사례 관리</h1>
|
||||
<a class="top-action-link" href="/ai-cases">목록(사용자 화면)</a>
|
||||
</header>
|
||||
<main class="container">
|
||||
<section class="panel">
|
||||
<p class="breadcrumb"><a href="/ai-cases">AI 성공 사례</a> > 관리자</p>
|
||||
<p class="breadcrumb"><a href="/ai-cases">AI 활용 사례</a> > 관리자</p>
|
||||
<p class="muted admin-hint">슬러그는 URL에 쓰이므로 영문·숫자·하이픈만 사용하세요. <strong>원문 PDF 경로</strong>(<code>/public/...</code>)가 있으면 상세는 PDF 페이지 이미지로 보여 주며, 이때 <strong>본문(Markdown)은 비워도 됩니다</strong>. PDF가 없을 때는 본문이 필수입니다.</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<%- include('partials/favicon') %>
|
||||
<title>AI 성공 사례 - XAVIS</title>
|
||||
<title>AI 활용 사례 - XAVIS</title>
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,12 +16,22 @@
|
||||
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
|
||||
<div class="content-area">
|
||||
<header class="topbar">
|
||||
<h1>AI 성공 사례</h1>
|
||||
<h1>AI 활용 사례</h1>
|
||||
<div class="topbar-actions">
|
||||
<% if (typeof canComposeUseCase !== 'undefined' && canComposeUseCase) { %>
|
||||
<a class="top-action-link" href="/ai-cases/compose" title="글쓰기"
|
||||
><span class="top-action-icon" aria-hidden="true">✎</span> 글쓰기</a
|
||||
>
|
||||
<% } %>
|
||||
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
||||
<a class="top-action-link" href="/ai-cases/write">사례 등록·관리</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
<% if (typeof submittedOk !== 'undefined' && submittedOk) { %>
|
||||
<p class="form-message" style="margin-bottom: 12px; color: #047857">제출이 저장되었습니다.</p>
|
||||
<% } %>
|
||||
<% if (typeof successStoryDetailAllowed !== 'undefined' && !successStoryDetailAllowed) { %>
|
||||
<p class="chat-api-warning" style="margin-bottom: 16px">
|
||||
로그인 후 이용 가능합니다.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
class="<%= (typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted) ? 'ai-explore-page ai-explore-dev-guest' : 'ai-explore-page' %>"
|
||||
>
|
||||
<% var aiGuestDev = typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted; %>
|
||||
<% var _tclOk = typeof taskChecklistMenuAllowed !== 'undefined' && taskChecklistMenuAllowed; %>
|
||||
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
|
||||
<div class="app-shell">
|
||||
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||||
@@ -42,21 +43,31 @@
|
||||
aria-label="AI 서비스 제목·설명 검색"
|
||||
<% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %>
|
||||
/>
|
||||
<div class="ai-type-filters" role="radiogroup" aria-label="AI 타입 필터">
|
||||
<label class="ai-type-filter-option">
|
||||
<input type="radio" name="aiTypeFilter" value="all" checked <% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %> />
|
||||
<span>전체</span>
|
||||
</label>
|
||||
<label class="ai-type-filter-option">
|
||||
<input type="radio" name="aiTypeFilter" value="general" <% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %> />
|
||||
<span>일반</span>
|
||||
</label>
|
||||
<label class="ai-type-filter-option">
|
||||
<input type="radio" name="aiTypeFilter" value="xscan" <% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %> />
|
||||
<span>XScan</span>
|
||||
</label>
|
||||
<label class="ai-type-filter-option">
|
||||
<input type="radio" name="aiTypeFilter" value="fscan" <% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %> />
|
||||
<span>FScan</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>AI 서비스</h2>
|
||||
<div class="ai-card-grid">
|
||||
<a href="/ai-explore/prompts" class="ai-card ai-card-link">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
<h3>프롬프트</h3>
|
||||
<p>업무별 기본 프롬프트를 모아 두고, 복사해 바로 활용할 수 있는 라이브러리입니다.</p>
|
||||
<div class="tag-row"><span class="tag-chip">#프롬프트</span></div>
|
||||
</a>
|
||||
<% if (aiGuestDev) { %>
|
||||
<article class="ai-card ai-card-disabled" aria-disabled="true">
|
||||
<article class="ai-card ai-card-disabled" aria-disabled="true" data-ai-type="general">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
@@ -65,7 +76,7 @@
|
||||
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#회의록</span></div>
|
||||
</article>
|
||||
<% } else { %>
|
||||
<a href="/ai-explore/meeting-minutes" class="ai-card ai-card-link">
|
||||
<a href="/ai-explore/meeting-minutes" class="ai-card ai-card-link" data-ai-type="general">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
@@ -74,8 +85,9 @@
|
||||
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#회의록</span></div>
|
||||
</a>
|
||||
<% } %>
|
||||
<% if (_tclOk) { %>
|
||||
<% if (aiGuestDev) { %>
|
||||
<article class="ai-card ai-card-disabled" aria-disabled="true">
|
||||
<article class="ai-card ai-card-disabled" aria-disabled="true" data-ai-type="general">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
@@ -84,7 +96,7 @@
|
||||
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#체크리스트</span></div>
|
||||
</article>
|
||||
<% } else { %>
|
||||
<a href="/ai-explore/task-checklist" class="ai-card ai-card-link">
|
||||
<a href="/ai-explore/task-checklist" class="ai-card ai-card-link" data-ai-type="general">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
@@ -93,6 +105,45 @@
|
||||
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#체크리스트</span></div>
|
||||
</a>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% if (aiGuestDev) { %>
|
||||
<article class="ai-card ai-card-disabled" aria-disabled="true" data-ai-type="general">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
<h3>일반 채팅</h3>
|
||||
<p>ChatGPT를 이용한 채팅 서비스입니다.</p>
|
||||
<div class="tag-row"><span class="tag-chip">#질의응답.</span><span class="tag-chip">ChatGPT</span></div>
|
||||
</article>
|
||||
<% } else { %>
|
||||
<a href="/ai-explore/chat" class="ai-card ai-card-link" data-ai-type="general">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
<h3>일반 채팅</h3>
|
||||
<p>ChatGPT를 이용한 채팅 서비스입니다.</p>
|
||||
<div class="tag-row"><span class="tag-chip">#질의응답.</span><span class="tag-chip">ChatGPT</span></div>
|
||||
</a>
|
||||
<% } %>
|
||||
<% if (aiGuestDev) { %>
|
||||
<article class="ai-card ai-card-disabled" aria-disabled="true" data-ai-type="fscan">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
<h3>FSCAN 조사각 선정도우미</h3>
|
||||
<p>검사 대상물 치수(H/W) 기반으로 FSCAN 시리즈 모델 선정을 돕는 도구입니다.</p>
|
||||
<div class="tag-row"><span class="tag-chip">#FSCAN</span><span class="tag-chip">#선정도우미</span></div>
|
||||
</article>
|
||||
<% } else { %>
|
||||
<a href="/ai-explore/fscan" class="ai-card ai-card-link" data-ai-type="fscan">
|
||||
<div class="ai-card-header">
|
||||
<span class="status-chip public">공개중</span>
|
||||
</div>
|
||||
<h3>FSCAN 조사각 선정도우미</h3>
|
||||
<p>검사 대상물 치수(H/W) 기반으로 FSCAN 시리즈 모델 선정을 돕는 도구입니다.</p>
|
||||
<div class="tag-row"><span class="tag-chip">#FSCAN</span><span class="tag-chip">#선정도우미</span></div>
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -109,6 +160,7 @@
|
||||
if (devGuest) return;
|
||||
|
||||
var cards = grid.querySelectorAll(".ai-card");
|
||||
var typeInputs = form.querySelectorAll('input[name="aiTypeFilter"]');
|
||||
|
||||
function cardTitleDescriptionText(el) {
|
||||
var parts = [];
|
||||
@@ -122,9 +174,14 @@
|
||||
|
||||
function applyFilter() {
|
||||
var q = (input.value || "").trim().toLowerCase();
|
||||
var checkedType = form.querySelector('input[name="aiTypeFilter"]:checked');
|
||||
var selectedType = checkedType ? String(checkedType.value || "all") : "all";
|
||||
cards.forEach(function (el) {
|
||||
var text = cardTitleDescriptionText(el);
|
||||
var show = !q || text.indexOf(q) !== -1;
|
||||
var cardType = String(el.getAttribute("data-ai-type") || "general").toLowerCase();
|
||||
var textMatched = !q || text.indexOf(q) !== -1;
|
||||
var typeMatched = selectedType === "all" || cardType === selectedType;
|
||||
var show = textMatched && typeMatched;
|
||||
el.hidden = !show;
|
||||
el.setAttribute("aria-hidden", show ? "false" : "true");
|
||||
});
|
||||
@@ -132,11 +189,16 @@
|
||||
|
||||
input.addEventListener("input", applyFilter);
|
||||
input.addEventListener("search", applyFilter);
|
||||
typeInputs.forEach(function (el) {
|
||||
el.addEventListener("change", applyFilter);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
applyFilter();
|
||||
});
|
||||
|
||||
applyFilter();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
33
views/ai-fscan.ejs
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<%- include('partials/favicon') %>
|
||||
<title>FSCAN 조사각 선정도우미 - XAVIS</title>
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
</head>
|
||||
<body class="ai-fscan-page">
|
||||
<div class="app-shell">
|
||||
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||||
<div class="content-area">
|
||||
<header class="topbar">
|
||||
<h1>FSCAN 조사각 선정도우미</h1>
|
||||
<a class="top-action-link" href="/ai-explore">AI 목록</a>
|
||||
</header>
|
||||
<main class="container container-ai-full">
|
||||
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
|
||||
<section class="panel">
|
||||
<p class="subtitle">검사 대상물의 치수(H/W) 기반으로 FSCAN 시리즈 모델을 빠르게 선정합니다.</p>
|
||||
<iframe
|
||||
class="fscan-embed-frame"
|
||||
src="/public/resources/fscan/fscan-selector-v1.html"
|
||||
title="FSCAN 조사각 선정도우미"
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
308
views/ai-use-case-submission-detail.ejs
Normal file
@@ -0,0 +1,308 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<%- include('partials/favicon') %>
|
||||
<title><%= title %> - AI 활용 사례 - XAVIS</title>
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
|
||||
<div class="content-area">
|
||||
<main class="viewer-wrap ai-case-viewer">
|
||||
<a href="/ai-cases" class="back-link">← AI 활용 사례로 돌아가기</a>
|
||||
<% if (typeof updatedOk !== "undefined" && updatedOk) { %>
|
||||
<p class="form-message" style="margin: 0 0 12px; color: #047857">수정이 저장되었습니다.</p>
|
||||
<% } %>
|
||||
<h1><%= title %></h1>
|
||||
<p class="description">일반 제출 사례 · <%= author %><% if (submitterEmail) { %> (<%= submitterEmail %>)<% } %></p>
|
||||
<div class="ppt-tools ai-case-ppt-tools">
|
||||
<div class="ai-case-tools-meta">
|
||||
<span>조회 <b id="submissionViewCount"><%= typeof viewCount !== "undefined" ? viewCount : 0 %></b></span>
|
||||
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-lib-like ai-case-submission-like<%= typeof myLike !== "undefined" && myLike ? " is-liked" : "" %>"
|
||||
id="submissionLikeBtn"
|
||||
title="<%= typeof opsUserEmail !== "undefined" && opsUserEmail ? "좋아요" : "로그인 후 사용" %>"
|
||||
<% if (typeof opsUserEmail === "undefined" || !opsUserEmail) { %>disabled<% } %>
|
||||
>
|
||||
<span class="prompt-lib-like-icon" aria-hidden="true">♥</span>
|
||||
<span id="submissionLikeCount"><%= typeof likeCount !== "undefined" ? likeCount : 0 %></span>
|
||||
</button>
|
||||
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||
<span><%= typeof department !== "undefined" ? department : "일반 제출" %> · <%= author %><% if (publishedAt) { %> · <%= publishedAt %><% } %></span>
|
||||
<% if (typeof canEditSubmission !== "undefined" && canEditSubmission && typeof submissionId !== "undefined" && submissionId) { %>
|
||||
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||
<a href="/ai-cases/compose?edit=<%= encodeURIComponent(submissionId) %>" class="ai-case-inline-link">수정하기</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<% if ((tags || []).length) { %>
|
||||
<div class="tag-row ai-case-tag-row">
|
||||
<% (tags || []).forEach((oneTag) => { %>
|
||||
<span class="tag-chip">#<%= oneTag %></span>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
<section class="slide-list">
|
||||
<article class="slide-card">
|
||||
<header>
|
||||
<h2>1. Situation (배경)</h2>
|
||||
</header>
|
||||
<div class="chat-md-body success-detail-body-in-card success-submission-html"><%- situationHtml %></div>
|
||||
</article>
|
||||
<article class="slide-card">
|
||||
<header>
|
||||
<h2>2. Task (과제/목표)</h2>
|
||||
</header>
|
||||
<div class="chat-md-body success-detail-body-in-card success-submission-html"><%- taskHtml %></div>
|
||||
</article>
|
||||
<article class="slide-card">
|
||||
<header>
|
||||
<h2>3. Action (행동)</h2>
|
||||
</header>
|
||||
<div class="chat-md-body success-detail-body-in-card success-submission-html"><%- actionHtml %></div>
|
||||
</article>
|
||||
<article class="slide-card">
|
||||
<header>
|
||||
<h2>4. Result (결과)</h2>
|
||||
</header>
|
||||
<div class="chat-md-body success-detail-body-in-card success-submission-html"><%- resultHtml %></div>
|
||||
</article>
|
||||
</section>
|
||||
<% if (typeof thumbnailImages !== "undefined" && thumbnailImages && thumbnailImages.length) { %>
|
||||
<section class="success-submission-cover-section" id="submissionScreenshots">
|
||||
<div class="success-submission-cover-header">
|
||||
<div>
|
||||
<h2 class="success-submission-cover-title">실행 화면</h2>
|
||||
<p class="success-submission-cover-hint">이미지를 클릭하면 원본 크기로 자세히 볼 수 있습니다.</p>
|
||||
</div>
|
||||
<% if (thumbnailImages.length > 1) { %>
|
||||
<div class="slide-layout-toggle" role="group" aria-label="실행 화면 단 수">
|
||||
<span class="slide-layout-toggle-label">보기</span>
|
||||
<button type="button" class="slide-layout-btn js-screenshot-cols-btn" data-cols="1" aria-pressed="false">1단</button>
|
||||
<button type="button" class="slide-layout-btn js-screenshot-cols-btn is-active" data-cols="2" aria-pressed="true">2단</button>
|
||||
<button type="button" class="slide-layout-btn js-screenshot-cols-btn" data-cols="4" aria-pressed="false">4단</button>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="success-submission-cover-grid success-submission-cover-grid--cols-2" id="submissionScreenshotGrid">
|
||||
<% thumbnailImages.forEach(function (t, idx) { var tfn = (t.originalName || "실행 화면").replace(/[<>"]/g, ""); %>
|
||||
<figure class="success-submission-cover-item">
|
||||
<button
|
||||
type="button"
|
||||
class="success-submission-screenshot-btn js-screenshot-open"
|
||||
data-screenshot-index="<%= idx %>"
|
||||
aria-label="<%= tfn %> 크게 보기"
|
||||
>
|
||||
<img src="<%= t.relativePath %>" alt="<%= tfn %>" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
<figcaption class="success-submission-screenshot-caption"><%= tfn %></figcaption>
|
||||
</figure>
|
||||
<% }); %>
|
||||
</div>
|
||||
</section>
|
||||
<% } else if (coverImageUrl) { %>
|
||||
<section class="success-submission-cover-section success-submission-cover--single" id="submissionScreenshots">
|
||||
<h2 class="success-submission-cover-title">실행 화면</h2>
|
||||
<p class="success-submission-cover-hint">이미지를 클릭하면 원본 크기로 자세히 볼 수 있습니다.</p>
|
||||
<figure class="success-submission-cover-item">
|
||||
<button type="button" class="success-submission-screenshot-btn js-screenshot-open" data-screenshot-index="0" aria-label="실행 화면 크게 보기">
|
||||
<img src="<%= coverImageUrl %>" alt="실행 화면" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
</figure>
|
||||
</section>
|
||||
<% } %>
|
||||
<% if (attachments && attachments.length) { %>
|
||||
<section class="slide-card" style="margin-top: 12px">
|
||||
<header>
|
||||
<h2>첨부 파일</h2>
|
||||
</header>
|
||||
<ul class="success-submission-attach-list">
|
||||
<% attachments.forEach((a) => { %>
|
||||
<li>
|
||||
<a href="<%= a.relativePath %>" target="_blank" rel="noopener noreferrer"><%= a.originalName || "파일" %></a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</section>
|
||||
<% } %>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-case-lightbox" id="aiCaseLightbox" hidden role="dialog" aria-modal="true" aria-labelledby="aiCaseLightboxCaption">
|
||||
<button type="button" class="ai-case-lightbox__close" id="aiCaseLightboxClose" aria-label="닫기">×</button>
|
||||
<button type="button" class="ai-case-lightbox__nav ai-case-lightbox__nav--prev" id="aiCaseLightboxPrev" aria-label="이전 이미지">‹</button>
|
||||
<button type="button" class="ai-case-lightbox__nav ai-case-lightbox__nav--next" id="aiCaseLightboxNext" aria-label="다음 이미지">›</button>
|
||||
<div class="ai-case-lightbox__dialog">
|
||||
<img class="ai-case-lightbox__img" id="aiCaseLightboxImg" alt="" />
|
||||
<p class="ai-case-lightbox__caption" id="aiCaseLightboxCaption"></p>
|
||||
<p class="ai-case-lightbox__meta" id="aiCaseLightboxMeta"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var screenshotSection = document.getElementById("submissionScreenshots");
|
||||
if (screenshotSection) {
|
||||
var openButtons = screenshotSection.querySelectorAll(".js-screenshot-open");
|
||||
var lightbox = document.getElementById("aiCaseLightbox");
|
||||
var lightboxImg = document.getElementById("aiCaseLightboxImg");
|
||||
var lightboxCaption = document.getElementById("aiCaseLightboxCaption");
|
||||
var lightboxMeta = document.getElementById("aiCaseLightboxMeta");
|
||||
var lightboxClose = document.getElementById("aiCaseLightboxClose");
|
||||
var lightboxPrev = document.getElementById("aiCaseLightboxPrev");
|
||||
var lightboxNext = document.getElementById("aiCaseLightboxNext");
|
||||
var slides = [];
|
||||
var activeIndex = 0;
|
||||
var lastFocus = null;
|
||||
|
||||
openButtons.forEach(function (btn) {
|
||||
var img = btn.querySelector("img");
|
||||
if (!img) return;
|
||||
slides.push({
|
||||
src: img.getAttribute("src") || "",
|
||||
alt: img.getAttribute("alt") || "실행 화면",
|
||||
});
|
||||
});
|
||||
|
||||
var screenshotGrid = document.getElementById("submissionScreenshotGrid");
|
||||
var colButtons = screenshotSection.querySelectorAll(".js-screenshot-cols-btn");
|
||||
var colsStorageKey = "aiCaseScreenshotColumns";
|
||||
|
||||
function applyScreenshotCols(n) {
|
||||
if (!screenshotGrid) return;
|
||||
screenshotGrid.classList.remove(
|
||||
"success-submission-cover-grid--cols-1",
|
||||
"success-submission-cover-grid--cols-2",
|
||||
"success-submission-cover-grid--cols-4",
|
||||
);
|
||||
screenshotGrid.classList.add("success-submission-cover-grid--cols-" + n);
|
||||
colButtons.forEach(function (btn) {
|
||||
var on = btn.getAttribute("data-cols") === String(n);
|
||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
btn.classList.toggle("is-active", on);
|
||||
});
|
||||
try {
|
||||
localStorage.setItem(colsStorageKey, String(n));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (screenshotGrid && colButtons.length) {
|
||||
var savedCols = null;
|
||||
try {
|
||||
savedCols = localStorage.getItem(colsStorageKey);
|
||||
} catch (e) {}
|
||||
var initialCols = savedCols === "1" || savedCols === "2" || savedCols === "4" ? savedCols : "2";
|
||||
applyScreenshotCols(initialCols);
|
||||
colButtons.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var n = btn.getAttribute("data-cols");
|
||||
if (n === "1" || n === "2" || n === "4") applyScreenshotCols(n);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderLightbox(index) {
|
||||
if (!slides.length || !lightboxImg) return;
|
||||
activeIndex = (index + slides.length) % slides.length;
|
||||
var slide = slides[activeIndex];
|
||||
lightboxImg.src = slide.src;
|
||||
lightboxImg.alt = slide.alt;
|
||||
if (lightboxCaption) lightboxCaption.textContent = slide.alt;
|
||||
if (lightboxMeta) {
|
||||
lightboxMeta.textContent = slides.length > 1 ? activeIndex + 1 + " / " + slides.length : "";
|
||||
}
|
||||
if (lightboxPrev) lightboxPrev.hidden = slides.length <= 1;
|
||||
if (lightboxNext) lightboxNext.hidden = slides.length <= 1;
|
||||
}
|
||||
|
||||
function openLightbox(index) {
|
||||
if (!lightbox || !slides.length) return;
|
||||
lastFocus = document.activeElement;
|
||||
renderLightbox(index);
|
||||
lightbox.hidden = false;
|
||||
document.body.style.overflow = "hidden";
|
||||
if (lightboxClose) lightboxClose.focus();
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
if (!lightbox) return;
|
||||
lightbox.hidden = true;
|
||||
document.body.style.overflow = "";
|
||||
if (lightboxImg) lightboxImg.removeAttribute("src");
|
||||
if (lastFocus && typeof lastFocus.focus === "function") lastFocus.focus();
|
||||
}
|
||||
|
||||
openButtons.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var idx = parseInt(btn.getAttribute("data-screenshot-index") || "0", 10);
|
||||
openLightbox(Number.isFinite(idx) ? idx : 0);
|
||||
});
|
||||
});
|
||||
|
||||
if (lightboxClose) lightboxClose.addEventListener("click", closeLightbox);
|
||||
if (lightboxPrev) {
|
||||
lightboxPrev.addEventListener("click", function () {
|
||||
renderLightbox(activeIndex - 1);
|
||||
});
|
||||
}
|
||||
if (lightboxNext) {
|
||||
lightboxNext.addEventListener("click", function () {
|
||||
renderLightbox(activeIndex + 1);
|
||||
});
|
||||
}
|
||||
if (lightbox) {
|
||||
lightbox.addEventListener("click", function (ev) {
|
||||
if (ev.target === lightbox) closeLightbox();
|
||||
});
|
||||
}
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (!lightbox || lightbox.hidden) return;
|
||||
if (ev.key === "Escape") closeLightbox();
|
||||
if (ev.key === "ArrowLeft" && lightboxPrev && !lightboxPrev.hidden) renderLightbox(activeIndex - 1);
|
||||
if (ev.key === "ArrowRight" && lightboxNext && !lightboxNext.hidden) renderLightbox(activeIndex + 1);
|
||||
});
|
||||
}
|
||||
|
||||
var likeBtn = document.getElementById("submissionLikeBtn");
|
||||
var likeCountEl = document.getElementById("submissionLikeCount");
|
||||
var submissionId = <%- JSON.stringify(typeof submissionId !== "undefined" ? submissionId : null) %>;
|
||||
if (!likeBtn || !submissionId) return;
|
||||
likeBtn.addEventListener("click", function () {
|
||||
if (likeBtn.disabled) return;
|
||||
likeBtn.disabled = true;
|
||||
fetch("/api/ai-use-case-submissions/" + encodeURIComponent(submissionId) + "/like/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: "{}",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
return { ok: r.ok, j: j };
|
||||
});
|
||||
})
|
||||
.then(function (o) {
|
||||
if (o.ok && o.j && o.j.ok) {
|
||||
if (likeCountEl) likeCountEl.textContent = String(o.j.likeCount || 0);
|
||||
likeBtn.classList.toggle("is-liked", !!o.j.liked);
|
||||
} else if (o.j && o.j.error) {
|
||||
alert(o.j.error);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
alert("좋아요 처리에 실패했습니다.");
|
||||
})
|
||||
.finally(function () {
|
||||
likeBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
138
views/chat.ejs
@@ -9,51 +9,52 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<%- include('partials/nav', { activeMenu: 'chat' }) %>
|
||||
<%- include('partials/nav', { activeMenu: (typeof activeMenu !== 'undefined' ? activeMenu : 'ai-explore') }) %>
|
||||
<div class="content-area chat-area">
|
||||
<header class="topbar">
|
||||
<h1>채팅</h1>
|
||||
</header>
|
||||
<main class="chat-main">
|
||||
<p id="chatApiWarning" class="chat-api-warning" hidden>
|
||||
<strong>API 키 없음.</strong> 프로젝트 루트 <code>.env</code>에 <code>OPENAI_API_KEY</code>를 설정한 뒤 서버를 재시작하세요.
|
||||
</p>
|
||||
<main
|
||||
id="chatMain"
|
||||
class="chat-main"
|
||||
data-chat-gpt-allowed="<%= chatGptAllowed ? '1' : '0' %>"
|
||||
data-ops-state="<%= (typeof opsState !== 'undefined' ? String(opsState) : 'DEV') %>"
|
||||
data-admin-mode="<%= adminMode ? '1' : '0' %>"
|
||||
data-ops-user-email="<%= (typeof opsUserEmail !== 'undefined' && opsUserEmail) ? '1' : '0' %>"
|
||||
>
|
||||
<p id="chatApiWarning" class="chat-api-warning" hidden></p>
|
||||
<p id="chatGateNotice" class="chat-api-warning" hidden></p>
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
<div class="chat-welcome" id="chatWelcome">
|
||||
<h2>안녕하세요, 오늘 무엇을 도와드릴까요?</h2>
|
||||
<p>AI와 대화하며 업무를 효율적으로 처리해보세요.</p>
|
||||
<p>사내 AI 챗봇으로 답변합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-wrap">
|
||||
<form class="chat-form" id="chatForm">
|
||||
<select id="chatModel" class="chat-model-select" title="채팅 모델">
|
||||
<option value="gpt-5-mini" selected>gpt-5-mini</option>
|
||||
<option value="gpt-5.4">gpt-5.4</option>
|
||||
</select>
|
||||
<textarea id="chatInput" placeholder="무엇이든 물어보세요" rows="1"></textarea>
|
||||
<button type="submit" id="sendBtn" class="chat-send-btn" title="전송">↑</button>
|
||||
</form>
|
||||
<p class="chat-disclaimer">자비스는 실수를 할 수 있습니다. 중요한 정보는 재차 확인하세요.</p>
|
||||
<p class="chat-disclaimer">AI 답변은 실수할 수 있습니다. 중요한 정보는 원문 규정과 함께 확인하세요.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/vendor/marked/marked.umd.js"></script>
|
||||
<script src="/vendor/dompurify/purify.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var chatGptAllowed = <%= JSON.stringify(!!chatGptAllowed) %>;
|
||||
var opsState = <%- JSON.stringify(typeof opsState !== 'undefined' ? opsState : 'DEV') %>;
|
||||
var adminMode = <%= JSON.stringify(!!adminMode) %>;
|
||||
var opsUserEmail = <%= JSON.stringify(!!(typeof opsUserEmail !== 'undefined' && opsUserEmail)) %>;
|
||||
|
||||
(function () {
|
||||
const chatMain = document.getElementById('chatMain');
|
||||
var chatGptAllowed = !!(chatMain && chatMain.dataset.chatGptAllowed === '1');
|
||||
var opsState = (chatMain && chatMain.dataset.opsState) || 'DEV';
|
||||
var adminMode = !!(chatMain && chatMain.dataset.adminMode === '1');
|
||||
var opsUserEmail = !!(chatMain && chatMain.dataset.opsUserEmail === '1');
|
||||
const messagesEl = document.getElementById('chatMessages');
|
||||
const welcomeEl = document.getElementById('chatWelcome');
|
||||
const form = document.getElementById('chatForm');
|
||||
const input = document.getElementById('chatInput');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const modelSelect = document.getElementById('chatModel');
|
||||
let conversationHistory = [];
|
||||
|
||||
function applyChatGptGate(allowed) {
|
||||
@@ -65,7 +66,6 @@
|
||||
}
|
||||
if (input) input.disabled = true;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
if (modelSelect) modelSelect.disabled = true;
|
||||
if (notice) {
|
||||
notice.hidden = false;
|
||||
if (opsState === 'DEV' && !adminMode) {
|
||||
@@ -78,19 +78,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
applyChatGptGate(chatGptAllowed);
|
||||
|
||||
fetch('/api/chat/config')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(cfg) {
|
||||
var w = document.getElementById('chatApiWarning');
|
||||
if (w && cfg && !cfg.configured) w.hidden = false;
|
||||
if (cfg && typeof cfg.chatGptAllowed === 'boolean') {
|
||||
applyChatGptGate(cfg.chatGptAllowed);
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
@@ -126,7 +113,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 마크다운 → HTML 후 DOMPurify로 정제 (marked 출력은 신뢰하지 않음) */
|
||||
function renderAssistantMarkdown(text) {
|
||||
configureChatMarkdown();
|
||||
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
||||
@@ -162,7 +148,11 @@
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'chat-msg-content chat-md-body';
|
||||
inner.innerHTML =
|
||||
'<span class="chat-typing-dots" aria-label="응답 생성 중"><span class="chat-typing-dot">·</span><span class="chat-typing-dot">·</span><span class="chat-typing-dot">·</span></span>';
|
||||
'<span class="chat-progress-indicator" role="status" aria-live="polite" aria-label="응답 중">' +
|
||||
'<span class="chat-progress-text">응답 중</span>' +
|
||||
'<span class="chat-progress-dots" aria-hidden="true"><span></span><span></span><span></span></span>' +
|
||||
'<span class="chat-progress-track" aria-hidden="true"><span class="chat-progress-fill"></span></span>' +
|
||||
'</span>';
|
||||
div.appendChild(inner);
|
||||
const statusEl = document.createElement('div');
|
||||
statusEl.className = 'chat-status-line';
|
||||
@@ -189,24 +179,35 @@
|
||||
ol.className = 'chat-sources-list';
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const url = item && item.url;
|
||||
if (!url || typeof url !== 'string') continue;
|
||||
let href;
|
||||
const li = document.createElement('li');
|
||||
const t = item.title && String(item.title).trim();
|
||||
const ex = item.excerpt && String(item.excerpt).trim();
|
||||
const url = item && typeof item.url === 'string' ? item.url.trim() : '';
|
||||
let href = '';
|
||||
if (url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
|
||||
if (u.protocol === 'http:' || u.protocol === 'https:') {
|
||||
href = u.href;
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
const li = document.createElement('li');
|
||||
} catch (e) {}
|
||||
}
|
||||
if (href) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
const t = item.title && String(item.title).trim();
|
||||
a.textContent = t || href;
|
||||
li.appendChild(a);
|
||||
} else {
|
||||
li.textContent = t || '출처';
|
||||
}
|
||||
if (ex) {
|
||||
const exDiv = document.createElement('div');
|
||||
exDiv.className = 'chat-source-excerpt';
|
||||
exDiv.textContent = ex;
|
||||
li.appendChild(exDiv);
|
||||
}
|
||||
ol.appendChild(li);
|
||||
}
|
||||
if (ol.children.length) {
|
||||
@@ -220,12 +221,27 @@
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function extractApiErrorMessage(payload, fallback) {
|
||||
if (!payload) return fallback || '요청 실패';
|
||||
if (typeof payload === 'string') return payload;
|
||||
if (typeof payload.error === 'string' && payload.error.trim()) return payload.error.trim();
|
||||
if (payload.error && typeof payload.error === 'object') {
|
||||
if (typeof payload.error.message === 'string' && payload.error.message.trim()) {
|
||||
return payload.error.message.trim();
|
||||
}
|
||||
if (typeof payload.error.error === 'string' && payload.error.error.trim()) {
|
||||
return payload.error.error.trim();
|
||||
}
|
||||
}
|
||||
if (typeof payload.message === 'string' && payload.message.trim()) return payload.message.trim();
|
||||
return fallback || '요청 실패';
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
sendBtn.disabled = loading;
|
||||
sendBtn.textContent = loading ? '...' : '↑';
|
||||
sendBtn.textContent = loading ? '↻' : '↑';
|
||||
sendBtn.classList.toggle('is-busy', loading);
|
||||
sendBtn.setAttribute('aria-busy', loading ? 'true' : 'false');
|
||||
if (modelSelect) modelSelect.disabled = loading;
|
||||
if (input) input.disabled = loading;
|
||||
}
|
||||
|
||||
@@ -257,7 +273,7 @@
|
||||
throw new Error(obj.error || '스트림 오류');
|
||||
}
|
||||
if (obj.type === 'status' && obj.phase === 'web_search' && body.statusEl) {
|
||||
body.statusEl.textContent = '웹 검색 중…';
|
||||
body.statusEl.textContent = '웹 검색 중...';
|
||||
body.statusEl.hidden = false;
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
@@ -283,6 +299,31 @@
|
||||
return fullText;
|
||||
}
|
||||
|
||||
applyChatGptGate(chatGptAllowed);
|
||||
|
||||
fetch('/api/chat/config')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(cfg) {
|
||||
var w = document.getElementById('chatApiWarning');
|
||||
if (!cfg || !cfg.configured) {
|
||||
if (w) {
|
||||
w.hidden = false;
|
||||
w.innerHTML = '<strong>OpenAI API 연결 없음.</strong> 서버의 <code>OPENAI_API_KEY</code> 설정을 확인해 주세요.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cfg.error && w) {
|
||||
w.hidden = false;
|
||||
w.innerHTML = '<strong>채팅 오류.</strong> ' + escapeHtml(cfg.error);
|
||||
} else if (w) {
|
||||
w.hidden = true;
|
||||
}
|
||||
if (cfg && typeof cfg.chatGptAllowed === 'boolean') {
|
||||
applyChatGptGate(cfg.chatGptAllowed);
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
if (!chatGptAllowed) return;
|
||||
@@ -298,20 +339,17 @@
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const model = document.getElementById('chatModel').value;
|
||||
const res = await fetch('/api/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: model, messages: conversationHistory.slice(-20) })
|
||||
body: JSON.stringify({ messages: conversationHistory.slice(-20) })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(function() { return {}; });
|
||||
body.bubble.remove();
|
||||
addMessage('assistant', '오류: ' + (errData.error || res.statusText));
|
||||
addMessage('assistant', '오류: ' + extractApiErrorMessage(errData, res.statusText || '요청 실패'));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullReply = await readSseStream(res, body);
|
||||
body.bubble.classList.remove('chat-msg-streaming');
|
||||
if (!fullReply.trim()) {
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
<p class="login-lead">회사 이메일로 본인 확인 후 서비스를 이용할 수 있습니다.</p>
|
||||
<div class="login-steps">
|
||||
<ol>
|
||||
<li>아래에 <strong>@xavis.co.kr</strong> 이메일을 입력하고 <strong>검증</strong>을 누릅니다.</li>
|
||||
<li>아래에 <strong>@ncue.net</strong> 이메일을 입력하고 <strong>검증</strong>을 누릅니다.</li>
|
||||
<li>해당 메일함으로 전송된 <strong>인증 링크</strong>를 엽니다.</li>
|
||||
<li>인증이 완료되면 바로 서비스 화면으로 이동합니다.</li>
|
||||
</ol>
|
||||
@@ -159,12 +159,12 @@
|
||||
name="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="name@xavis.co.kr"
|
||||
placeholder="name@ncue.net"
|
||||
inputmode="email"
|
||||
/>
|
||||
<button type="submit" class="btn-verify" id="opsVerifyBtn">검증</button>
|
||||
<p class="login-msg" id="opsLoginMsg" role="status" aria-live="polite"></p>
|
||||
<p class="login-hint">허용 도메인: @xavis.co.kr 만 가능합니다.</p>
|
||||
<p class="login-hint">허용 도메인: @ncue.net 만 가능합니다.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,18 +8,6 @@
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
</head>
|
||||
<body class="meeting-minutes-page">
|
||||
<% var mmDefaultCustomInstructions = `아래 회의 내용(또는 녹취/메모)을 바탕으로 다음 형식으로 정리해 주세요.
|
||||
|
||||
원문·전사 전체를 회의록에 다시 붙여 넣지 마세요.
|
||||
‘스크립트’·‘스크랩트’(오타)·‘원문 전사’ 같은 섹션은 만들지 말고, 요약·결정·액션만 작성하세요.
|
||||
회의 제목·참석자·요약 등은 ## 마크다운 제목으로 구분하세요.
|
||||
|
||||
1) 회의 개요: 일시, 참석자(알 수 있는 경우), 목적
|
||||
2) 논의 안건별 요약
|
||||
3) 결정 사항 (명확한 문장으로)
|
||||
4) 액션 아이템: 별도 섹션. 각 항목에 할 일·담당자·기한을 구체적으로 적어주세요. 만약 담당자와 기한을 알 수 없으면 안적어도 무방합니다.
|
||||
|
||||
‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 템플릿·추적 체크리스트 제안 등은 넣지 마세요.`; %>
|
||||
<% var mmNow = new Date(); var mmTodayIso = mmNow.getFullYear() + '-' + ('0' + (mmNow.getMonth() + 1)).slice(-2) + '-' + ('0' + mmNow.getDate()).slice(-2); %>
|
||||
<div class="app-shell">
|
||||
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||||
@@ -52,17 +40,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="mm-prompt-body" id="mmPromptBody" hidden>
|
||||
<p class="subtitle">회의록에 포함할 항목을 선택하고, 추가 지시를 입력할 수 있습니다. 형식·섹션 구성은 <strong>추가 지시</strong>가 시스템 기본보다 우선합니다. 회의 체크리스트 섹션은 DB에 별도로 켜지 않은 경우 생성하지 않으며, 저장 후 업무 체크리스트 자동 연동은 액션·후속 항목에서 추출합니다.</p>
|
||||
<p class="subtitle">회의록 작성 시스템 프롬프트를 수정할 수 있습니다. 저장 후 업무 체크리스트 자동 연동은 액션·후속 항목에서 추출합니다.</p>
|
||||
<div class="mm-prompt-form" id="mmPromptForm">
|
||||
<div class="mm-checkbox-row" role="group" aria-label="회의록에 포함할 항목">
|
||||
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncTitle" checked /> <span>제목 한 줄</span></label>
|
||||
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAtt" checked /> <span>참석자</span></label>
|
||||
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncSum" checked /> <span>요약</span></label>
|
||||
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAct" checked /> <span>Action Item</span></label>
|
||||
</div>
|
||||
<div class="mm-custom-block">
|
||||
<label class="mm-field-label" for="mmCustomInstr">추가 지시</label>
|
||||
<textarea id="mmCustomInstr" class="mm-textarea mm-custom-instr-textarea" rows="9" placeholder=""><%= mmDefaultCustomInstructions %></textarea>
|
||||
<textarea id="mmCustomInstr" class="mm-textarea mm-custom-instr-textarea" rows="9" placeholder="비워두면 시스템 기본 구조(6개 섹션)로 회의록을 작성합니다. 추가로 지시할 내용이 있을 때만 입력하세요."></textarea>
|
||||
</div>
|
||||
<div class="mm-prompt-actions">
|
||||
<button type="button" class="top-action" id="mmSavePrompt" <%= hasEmail ? '' : 'disabled' %>>프롬프트 저장</button>
|
||||
@@ -91,15 +73,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mm-field">
|
||||
<span class="mm-field-label">회의 원문</span>
|
||||
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %>></textarea>
|
||||
</label>
|
||||
<div class="mm-field">
|
||||
<div class="mm-minutes-header mm-source-text-header">
|
||||
<label class="mm-field-label" for="mmSourceText">회의 원문</label>
|
||||
<div class="mm-minutes-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="mm-minutes-copy"
|
||||
id="mmSourceTextCopy"
|
||||
title="회의 원문을 클립보드에 복사합니다."
|
||||
aria-label="회의 원문을 클립보드에 복사합니다."
|
||||
<%= hasEmail ? '' : 'disabled' %>
|
||||
>복사</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %> aria-label="회의 원문"></textarea>
|
||||
</div>
|
||||
<label class="mm-field mm-field-narrow">
|
||||
<span class="mm-field-label">회의록 생성 모델</span>
|
||||
<select id="mmModelText" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
|
||||
<option value="gpt-5-mini" selected>gpt-5-mini</option>
|
||||
<option value="gpt-5.4">gpt-5.4</option>
|
||||
<option value="gpt-5-mini">gpt-5-mini</option>
|
||||
<option value="gpt-5.4" selected>gpt-5.4</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="form-actions mm-form-actions">
|
||||
@@ -118,10 +112,9 @@
|
||||
<label class="mm-field mm-transcribe-model-only">
|
||||
<span class="mm-field-label">전사 모델 (OpenAI)</span>
|
||||
<select id="mmWhisperModel" class="mm-select" <%= hasEmail ? '' : 'disabled' %> title="음성→텍스트 API 모델">
|
||||
<option value="gpt-4o-mini-transcribe" selected>gpt-4o-mini-transcribe (기본)</option>
|
||||
<option value="gpt-4o-transcribe">gpt-4o-transcribe (고성능)</option>
|
||||
<option value="gpt-4o-mini-transcribe">gpt-4o-mini-transcribe (경량·더 빠를 수 있음)</option>
|
||||
<option value="gpt-4o-transcribe" selected>gpt-4o-transcribe (기본)</option>
|
||||
</select>
|
||||
<span class="mm-field-help">OpenAI 전사 API 공식 모델 ID와 동일합니다. 기본은 mini, 더 높은 인식 품질이 필요하면 gpt-4o-transcribe를 선택하세요.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mm-audio-phase">
|
||||
@@ -142,8 +135,8 @@
|
||||
<label class="mm-field mm-field-narrow">
|
||||
<span class="mm-field-label">회의록 생성 모델</span>
|
||||
<select id="mmModelAudio" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
|
||||
<option value="gpt-5-mini" selected>gpt-5-mini</option>
|
||||
<option value="gpt-5.4">gpt-5.4</option>
|
||||
<option value="gpt-5-mini">gpt-5-mini</option>
|
||||
<option value="gpt-5.4" selected>gpt-5.4</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="mm-audio-summary-hint">전사된 텍스트를 아래 프롬프트(출력 형식)에 맞게 요약·정리합니다.</p>
|
||||
@@ -155,9 +148,39 @@
|
||||
</section>
|
||||
|
||||
<div id="mmGenProgress" class="mm-gen-progress" hidden role="status" aria-live="polite" aria-busy="false">
|
||||
<div id="mmGenAudioPipeline" class="mm-gen-audio-pipeline" hidden aria-label="음성 회의록 처리 단계">
|
||||
<div class="mm-gen-pipeline-steps" role="list">
|
||||
<span
|
||||
class="mm-gen-pipeline-step"
|
||||
id="mmGenStepUpload"
|
||||
data-mm-step-name="업로드"
|
||||
role="listitem"
|
||||
><strong class="mm-gen-pipeline-step-num">1</strong> 업로드</span>
|
||||
<span class="mm-gen-pipeline-join" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="mm-gen-pipeline-step"
|
||||
id="mmGenStepTranscribe"
|
||||
data-mm-step-name="전사"
|
||||
role="listitem"
|
||||
><strong class="mm-gen-pipeline-step-num">2</strong> 전사</span>
|
||||
<span class="mm-gen-pipeline-join" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="mm-gen-pipeline-step"
|
||||
id="mmGenStepTranslate"
|
||||
data-mm-step-name="번역"
|
||||
role="listitem"
|
||||
><strong class="mm-gen-pipeline-step-num">3</strong> 번역</span>
|
||||
</div>
|
||||
<p class="mm-gen-pipeline-note" id="mmGenTranslateNote" hidden>
|
||||
「번역」단계에서는 전문 템플릿에 맞게 회의 내용 전체를 LLM으로 서술 형식 회의록으로 작성합니다 (속도 표시 아님).
|
||||
</p>
|
||||
</div>
|
||||
<div class="mm-gen-progress-track-row">
|
||||
<div class="mm-gen-progress-track" aria-hidden="true">
|
||||
<div class="mm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span class="mm-gen-progress-pct" id="mmGenProgressPct" hidden></span>
|
||||
</div>
|
||||
<p class="mm-gen-progress-msg" id="mmGenProgressMsg">처리 중…</p>
|
||||
</div>
|
||||
|
||||
@@ -171,8 +194,20 @@
|
||||
</p>
|
||||
<div class="mm-result-split">
|
||||
<div id="mmTranscriptPane" class="mm-result-transcript-pane" hidden aria-hidden="true">
|
||||
<label class="mm-result-field">
|
||||
<div class="mm-result-field">
|
||||
<div class="mm-minutes-header">
|
||||
<span class="mm-result-field-label" id="mmTranscriptLabel">전사 기록</span>
|
||||
<div class="mm-minutes-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="mm-minutes-copy mm-transcript-copy"
|
||||
id="mmTranscriptCopy"
|
||||
title="전사 기록을 클립보드에 복사합니다."
|
||||
aria-label="전사 기록을 클립보드에 복사합니다."
|
||||
>복사</button>
|
||||
<button type="button" class="top-action mm-minutes-apply" id="mmTranscriptSave" disabled>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="mmTranscriptBody"
|
||||
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-transcript-textarea"
|
||||
@@ -180,26 +215,21 @@
|
||||
spellcheck="false"
|
||||
placeholder="음성 전사 텍스트가 여기에 표시됩니다."
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-result-field">
|
||||
<div class="mm-minutes-header">
|
||||
<span class="mm-result-field-label">회의록</span>
|
||||
<div class="mm-minutes-actions" id="mmMinutesActionsView" role="toolbar" aria-label="회의록 보기">
|
||||
<button type="button" class="btn-ghost mm-minutes-edit" id="mmMinutesEdit">마크다운 편집</button>
|
||||
</div>
|
||||
<div class="mm-minutes-actions" id="mmMinutesActionsEdit" role="toolbar" aria-label="회의록 편집" hidden>
|
||||
<div class="mm-minutes-actions" id="mmMinutesActions" role="toolbar" aria-label="회의록 액션">
|
||||
<button
|
||||
type="button"
|
||||
class="mm-minutes-copy"
|
||||
id="mmMinutesCopy"
|
||||
title="회의록을 복사합니다."
|
||||
aria-label="회의록을 복사합니다."
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
<button type="button" class="top-action mm-minutes-apply" id="mmMinutesApply">저장</button>
|
||||
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel">취소</button>
|
||||
>복사</button>
|
||||
<button type="button" class="btn-ghost mm-minutes-edit" id="mmMinutesEdit">마크다운 편집</button>
|
||||
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel" hidden>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-minutes-editor-wrap">
|
||||
@@ -241,21 +271,30 @@
|
||||
var resultSection = document.getElementById('mmResultSection');
|
||||
var resultBody = document.getElementById('mmResultBody');
|
||||
var minutesRenderedEl = document.getElementById('mmMinutesRendered');
|
||||
var minutesActionsView = document.getElementById('mmMinutesActionsView');
|
||||
var minutesActionsEdit = document.getElementById('mmMinutesActionsEdit');
|
||||
var minutesEditBtn = document.getElementById('mmMinutesEdit');
|
||||
var minutesCopyBtn = document.getElementById('mmMinutesCopy');
|
||||
var minutesApplyBtn = document.getElementById('mmMinutesApply');
|
||||
var minutesCancelBtn = document.getElementById('mmMinutesCancel');
|
||||
/** 편집 모드 진입 시점의 회의록 원문(취소 시 복원) */
|
||||
var minutesEditSnapshot = '';
|
||||
var minutesInEditMode = false;
|
||||
var transcriptBody = document.getElementById('mmTranscriptBody');
|
||||
var transcriptCopyBtn = document.getElementById('mmTranscriptCopy');
|
||||
var sourceTextCopyBtn = document.getElementById('mmSourceTextCopy');
|
||||
var mmSourceTextEl = document.getElementById('mmSourceText');
|
||||
var transcriptSaveBtn = document.getElementById('mmTranscriptSave');
|
||||
var transcriptLabel = document.getElementById('mmTranscriptLabel');
|
||||
/** true: 음성 전사(transcript_text) 편집, false: 텍스트 회의 원문(source_text) 편집 */
|
||||
var lastMeetingTranscriptIsAudio = false;
|
||||
var saveResultBtn = document.getElementById('mmSaveResult');
|
||||
var genProgressEl = document.getElementById('mmGenProgress');
|
||||
var genProgressMsg = document.getElementById('mmGenProgressMsg');
|
||||
var genProgressBar = genProgressEl ? genProgressEl.querySelector('.mm-gen-progress-bar') : null;
|
||||
var genProgressPct = document.getElementById('mmGenProgressPct');
|
||||
var mmAudioPipeEl = document.getElementById('mmGenAudioPipeline');
|
||||
var mmGenTranslateNoteEl = document.getElementById('mmGenTranslateNote');
|
||||
/** 음성 「전사 및 회의록 생성」 진행 중 디버그 스냅샷 — 콘솔: getMeetingMinutesAudioDiag() */
|
||||
var mmAudioJobDiag = { active: false };
|
||||
var mmAudioLastUiPhase = 'upload';
|
||||
var currentMeetingId = null;
|
||||
|
||||
function escapeHtmlMm(s) {
|
||||
@@ -334,8 +373,9 @@
|
||||
minutesRenderedEl.innerHTML = renderMinutesMarkdown(raw);
|
||||
}
|
||||
function setMinutesToolbarMode(editing) {
|
||||
if (minutesActionsView) minutesActionsView.hidden = !!editing;
|
||||
if (minutesActionsEdit) minutesActionsEdit.hidden = !editing;
|
||||
if (minutesCancelBtn) minutesCancelBtn.hidden = !editing;
|
||||
if (minutesEditBtn) minutesEditBtn.textContent = editing ? '편집 완료' : '마크다운 편집';
|
||||
minutesInEditMode = !!editing;
|
||||
}
|
||||
function setMinutesViewMode(showSource) {
|
||||
if (!resultBody || !minutesRenderedEl) return;
|
||||
@@ -395,16 +435,57 @@
|
||||
});
|
||||
});
|
||||
}
|
||||
if (transcriptCopyBtn) {
|
||||
transcriptCopyBtn.addEventListener('click', function () {
|
||||
if (!transcriptBody) return;
|
||||
var txt = String(transcriptBody.value || '');
|
||||
if (!txt.trim()) {
|
||||
alert('복사할 전사 기록이 없습니다.');
|
||||
return;
|
||||
}
|
||||
var btn = transcriptCopyBtn;
|
||||
var prev = btn.textContent;
|
||||
copyTextToClipboard(txt, function () {
|
||||
btn.textContent = '복사됨';
|
||||
window.setTimeout(function () {
|
||||
btn.textContent = prev;
|
||||
}, 1600);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (sourceTextCopyBtn && mmSourceTextEl) {
|
||||
sourceTextCopyBtn.addEventListener('click', function () {
|
||||
var txt = String(mmSourceTextEl.value || '');
|
||||
if (!txt.trim()) {
|
||||
alert('복사할 회의 원문이 없습니다.');
|
||||
return;
|
||||
}
|
||||
var btn = sourceTextCopyBtn;
|
||||
var prev = btn.textContent;
|
||||
copyTextToClipboard(txt, function () {
|
||||
btn.textContent = '복사됨';
|
||||
window.setTimeout(function () {
|
||||
btn.textContent = prev;
|
||||
}, 1600);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (transcriptSaveBtn) {
|
||||
transcriptSaveBtn.addEventListener('click', function () {
|
||||
if (saveResultBtn && !saveResultBtn.disabled) {
|
||||
saveResultBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (minutesEditBtn) {
|
||||
minutesEditBtn.addEventListener('click', function () {
|
||||
if (!resultBody) return;
|
||||
if (!minutesInEditMode) {
|
||||
minutesEditSnapshot = resultBody.value;
|
||||
setMinutesViewMode(true);
|
||||
});
|
||||
}
|
||||
if (minutesApplyBtn) {
|
||||
minutesApplyBtn.addEventListener('click', function () {
|
||||
} else {
|
||||
setMinutesViewMode(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (minutesCancelBtn) {
|
||||
@@ -427,6 +508,7 @@
|
||||
function setCurrentMeetingId(id) {
|
||||
currentMeetingId = id || null;
|
||||
if (saveResultBtn) saveResultBtn.disabled = !currentMeetingId;
|
||||
if (transcriptSaveBtn) transcriptSaveBtn.disabled = !currentMeetingId;
|
||||
}
|
||||
|
||||
function setMeetingGenerating(on, msg) {
|
||||
@@ -435,7 +517,17 @@
|
||||
genProgressEl.setAttribute('aria-busy', on ? 'true' : 'false');
|
||||
if (on) genProgressEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
if (genProgressMsg && msg) genProgressMsg.textContent = msg;
|
||||
if (!on) mmSetAudioPipelineVisible(false);
|
||||
if (!on) showIndeterminateProgress();
|
||||
if (genProgressMsg && on) {
|
||||
if (arguments.length >= 2) {
|
||||
genProgressMsg.textContent =
|
||||
msg === undefined || msg === null ? '' : String(msg);
|
||||
} else {
|
||||
/** 진행 시작 시 초기 HTML「처리 중…」 등이 다음 업데이트 전까지 보이지 않도록 비움 */
|
||||
genProgressMsg.textContent = '';
|
||||
}
|
||||
}
|
||||
var gText = document.getElementById('mmGenText');
|
||||
var gAudio = document.getElementById('mmGenAudio');
|
||||
if (gText) gText.disabled = !!on;
|
||||
@@ -445,7 +537,6 @@
|
||||
if (transcriptBody) transcriptBody.disabled = !!on;
|
||||
if (minutesEditBtn) minutesEditBtn.disabled = !!on;
|
||||
if (minutesCopyBtn) minutesCopyBtn.disabled = !!on;
|
||||
if (minutesApplyBtn) minutesApplyBtn.disabled = !!on;
|
||||
if (minutesCancelBtn) minutesCancelBtn.disabled = !!on;
|
||||
if (on && resultBody && minutesRenderedEl) {
|
||||
if (!resultBody.hidden) {
|
||||
@@ -455,6 +546,248 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* fetch ReadableStream SSE 파싱. onStreamHeaders / onUploaded 둘 중 하나로 응답 헤더 직후 1회 콜백.
|
||||
*/
|
||||
function consumeSseFromFetch(response, handlers) {
|
||||
var hdr = handlers.onStreamHeaders || handlers.onUploaded;
|
||||
hdr && hdr();
|
||||
if (!response.body) {
|
||||
handlers.onError && handlers.onError(new Error('응답 스트림을 읽을 수 없습니다.'));
|
||||
return;
|
||||
}
|
||||
var reader = response.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var sseBuffer = '';
|
||||
var sseEvent = 'message';
|
||||
|
||||
function parseSseChunk(text) {
|
||||
sseBuffer += text;
|
||||
var lines = sseBuffer.split('\n');
|
||||
sseBuffer = lines.pop();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
|
||||
if (line.startsWith('event: ')) {
|
||||
sseEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
try {
|
||||
var data = JSON.parse(line.slice(6));
|
||||
handlers.onSseEvent && handlers.onSseEvent(sseEvent, data);
|
||||
} catch (_) {}
|
||||
sseEvent = 'message';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pump() {
|
||||
return reader.read().then(function (result) {
|
||||
if (result.done) {
|
||||
handlers.onEnd && handlers.onEnd();
|
||||
return;
|
||||
}
|
||||
parseSseChunk(decoder.decode(result.value, { stream: true }));
|
||||
return pump();
|
||||
});
|
||||
}
|
||||
return pump();
|
||||
}
|
||||
|
||||
/** 1단계: multipart 업로드 → JSON { jobId }. onUploadProgress, onJobReady(jobId), onError */
|
||||
function postPrepareMeetingAudioJob(url, formData, callbacks) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url);
|
||||
xhr.withCredentials = true;
|
||||
xhr.responseType = 'text';
|
||||
xhr.upload.onprogress = function (ev) {
|
||||
callbacks.onUploadProgress &&
|
||||
callbacks.onUploadProgress(ev.lengthComputable, ev.loaded, ev.total || 0);
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
callbacks.onError && callbacks.onError(new Error('네트워크 오류(업로드)'));
|
||||
};
|
||||
xhr.onload = function () {
|
||||
try {
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
var jFail = null;
|
||||
try {
|
||||
jFail = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
||||
} catch (_) {
|
||||
jFail = null;
|
||||
}
|
||||
var em =
|
||||
(jFail && jFail.error && String(jFail.error).slice(0, 400)) ||
|
||||
(xhr.responseText || '').replace(/\s+/g, ' ').trim().slice(0, 200) ||
|
||||
'HTTP ' + xhr.status;
|
||||
callbacks.onError && callbacks.onError(new Error(em));
|
||||
return;
|
||||
}
|
||||
var j = null;
|
||||
try {
|
||||
j = JSON.parse(xhr.responseText || '');
|
||||
} catch (_) {
|
||||
callbacks.onError && callbacks.onError(new Error('서버 응답 형식 오류(JSON)'));
|
||||
return;
|
||||
}
|
||||
if (!j || !j.jobId) {
|
||||
callbacks.onError && callbacks.onError(new Error('jobId가 응답에 없습니다.'));
|
||||
return;
|
||||
}
|
||||
callbacks.onJobReady && callbacks.onJobReady(typeof j.jobId === 'string' ? j.jobId : String(j.jobId));
|
||||
} catch (e) {
|
||||
callbacks.onError &&
|
||||
callbacks.onError(new Error(e && e.message ? e.message : String(e)));
|
||||
}
|
||||
};
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
/** 2단계: GET SSE (fetch 스트림 — 단일 업로드+SSE 연결보다 브라우저/프록시에서 증분 수신 안정적) */
|
||||
function streamMeetingAudioJobSse(jobId, handlers) {
|
||||
var streamUrl =
|
||||
'/api/meeting-minutes/stream-audio/' + encodeURIComponent(jobId);
|
||||
return fetch(streamUrl, { method: 'GET', credentials: 'same-origin' })
|
||||
.then(function (response) {
|
||||
var ct = (response.headers.get('content-type') || '').toLowerCase();
|
||||
if (!response.ok) {
|
||||
return response
|
||||
.json()
|
||||
.catch(function () {
|
||||
return {};
|
||||
})
|
||||
.then(function (jo) {
|
||||
var msg =
|
||||
(jo && jo.error && String(jo.error).slice(0, 400)) ||
|
||||
'HTTP ' + response.status;
|
||||
handlers.onError && handlers.onError(new Error(msg || 'SSE 연결 실패'));
|
||||
});
|
||||
}
|
||||
if (ct.indexOf('text/event-stream') === -1) {
|
||||
return response.text().then(function (t) {
|
||||
var msg = (t || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
handlers.onError &&
|
||||
handlers.onError(new Error(msg || 'SSE가 아닙니다.'));
|
||||
});
|
||||
}
|
||||
return consumeSseFromFetch(response, handlers);
|
||||
})
|
||||
.catch(function (e) {
|
||||
handlers.onError && handlers.onError(e);
|
||||
});
|
||||
}
|
||||
|
||||
/** 진행 바를 실제 퍼센트 모드로 설정 (인라인 스타일로 CSS 캐시 무관하게 동작) */
|
||||
function showDeterminedProgress(pct) {
|
||||
pct = Math.min(100, Math.max(0, Math.round(pct)));
|
||||
if (genProgressBar) {
|
||||
genProgressBar.classList.add('mm-gen-progress-bar--determined');
|
||||
genProgressBar.style.animation = 'none';
|
||||
genProgressBar.style.transform = 'none';
|
||||
genProgressBar.style.width = pct + '%';
|
||||
}
|
||||
if (genProgressPct) {
|
||||
genProgressPct.removeAttribute('hidden');
|
||||
genProgressPct.hidden = false;
|
||||
genProgressPct.textContent = pct + '%';
|
||||
}
|
||||
}
|
||||
|
||||
/** 진행 바를 인디터미넌트(슬라이딩) 모드로 복귀 */
|
||||
function showIndeterminateProgress() {
|
||||
if (genProgressBar) {
|
||||
genProgressBar.classList.remove('mm-gen-progress-bar--determined');
|
||||
genProgressBar.style.animation = '';
|
||||
genProgressBar.style.transform = '';
|
||||
genProgressBar.style.width = '';
|
||||
}
|
||||
if (genProgressPct) {
|
||||
genProgressPct.hidden = true;
|
||||
genProgressPct.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function mmPipelineStepNodes() {
|
||||
return [
|
||||
document.getElementById('mmGenStepUpload'),
|
||||
document.getElementById('mmGenStepTranscribe'),
|
||||
document.getElementById('mmGenStepTranslate'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
/** 음성 파이프라인 UI 표시 여부 — 텍스트 회의 원문 생성과 구분 */
|
||||
function mmSetAudioPipelineVisible(on) {
|
||||
if (mmAudioPipeEl) mmAudioPipeEl.hidden = !on;
|
||||
if (!on) {
|
||||
mmPipelineStepNodes().forEach(function (el) {
|
||||
el.classList.remove('is-done', 'is-active', 'is-pending');
|
||||
el.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
if (!on && mmGenTranslateNoteEl) mmGenTranslateNoteEl.hidden = true;
|
||||
}
|
||||
|
||||
function mmUpdatePipelineStepHighlight(phaseKey) {
|
||||
var order = ['upload', 'transcribe', 'translate'];
|
||||
var ix = order.indexOf(phaseKey);
|
||||
if (ix < 0) ix = 0;
|
||||
mmPipelineStepNodes().forEach(function (el, i) {
|
||||
el.classList.remove('is-done', 'is-active', 'is-pending');
|
||||
el.removeAttribute('aria-current');
|
||||
if (i < ix) el.classList.add('is-done');
|
||||
else if (i === ix) {
|
||||
el.classList.add('is-active');
|
||||
el.setAttribute('aria-current', 'step');
|
||||
} else el.classList.add('is-pending');
|
||||
});
|
||||
}
|
||||
|
||||
/** 순차 단계 배지(up/trans/translate)·막대·본문 메시지. pct가 null이면 막대 인디터미넌트. */
|
||||
function mmSetAudioGenerationPhase(phaseKey, pct, detailMsg) {
|
||||
if (phaseKey) mmAudioLastUiPhase = phaseKey;
|
||||
mmUpdatePipelineStepHighlight(phaseKey);
|
||||
var pctRounded = null;
|
||||
if (
|
||||
pct !== null &&
|
||||
pct !== undefined &&
|
||||
typeof pct === 'number' &&
|
||||
!isNaN(pct)
|
||||
) {
|
||||
pctRounded = Math.min(100, Math.max(0, Math.round(pct)));
|
||||
}
|
||||
if (pct === null) {
|
||||
showIndeterminateProgress();
|
||||
} else if (pctRounded !== null) {
|
||||
showDeterminedProgress(pctRounded);
|
||||
}
|
||||
if (mmGenTranslateNoteEl) mmGenTranslateNoteEl.hidden = phaseKey !== 'translate';
|
||||
if (!genProgressMsg) return;
|
||||
if (detailMsg !== undefined && detailMsg !== null && detailMsg !== '') {
|
||||
genProgressMsg.textContent =
|
||||
pctRounded !== null ? pctRounded + '% · ' + detailMsg : detailMsg;
|
||||
} else if (pctRounded !== null && phaseKey === 'transcribe') {
|
||||
genProgressMsg.textContent =
|
||||
pctRounded + '% · 서버 처리 및 전사를 기다리는 중입니다…';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.getMeetingMinutesAudioDiag = function () {
|
||||
if (!mmAudioJobDiag || !mmAudioJobDiag.active) {
|
||||
return {
|
||||
active: false,
|
||||
hintKo:
|
||||
'진행 중인 음성 회의 생성이 없거나 이미 종료되었습니다. 「전사 및 회의록 생성」을 누른 뒤 이 함수를 실행하세요.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(mmAudioJobDiag));
|
||||
} catch (e2) {
|
||||
return { active: true, cloneError: String(e2 && e2.message) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function api(path, opts) {
|
||||
return fetch(path, Object.assign({ credentials: 'same-origin' }, opts || {})).then(function (r) {
|
||||
var ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||
@@ -471,17 +804,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
var MM_DEFAULT_CUSTOM_INSTRUCTIONS = <%- JSON.stringify(mmDefaultCustomInstructions) %>;
|
||||
|
||||
function loadPrompt() {
|
||||
return api('/api/meeting-minutes/prompt').then(function (d) {
|
||||
var p = d.prompt || {};
|
||||
document.getElementById('mmIncTitle').checked = p.includeTitleLine !== false;
|
||||
document.getElementById('mmIncAtt').checked = p.includeAttendees !== false;
|
||||
document.getElementById('mmIncSum').checked = p.includeSummary !== false;
|
||||
document.getElementById('mmIncAct').checked = p.includeActionItems !== false;
|
||||
var saved = (p.customInstructions && String(p.customInstructions).trim()) || '';
|
||||
document.getElementById('mmCustomInstr').value = saved || MM_DEFAULT_CUSTOM_INSTRUCTIONS;
|
||||
document.getElementById('mmCustomInstr').value = saved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -595,10 +922,10 @@
|
||||
|
||||
document.getElementById('mmSavePrompt').addEventListener('click', function () {
|
||||
var body = {
|
||||
includeTitleLine: document.getElementById('mmIncTitle').checked,
|
||||
includeAttendees: document.getElementById('mmIncAtt').checked,
|
||||
includeSummary: document.getElementById('mmIncSum').checked,
|
||||
includeActionItems: document.getElementById('mmIncAct').checked,
|
||||
includeTitleLine: true,
|
||||
includeAttendees: true,
|
||||
includeSummary: true,
|
||||
includeActionItems: true,
|
||||
includeChecklist: false,
|
||||
customInstructions: document.getElementById('mmCustomInstr').value
|
||||
};
|
||||
@@ -766,21 +1093,190 @@
|
||||
alert('제목을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
var fd = new FormData();
|
||||
fd.append('audio', fileInput.files[0]);
|
||||
fd.append('title', audioTitle);
|
||||
fd.append('meetingDate', document.getElementById('mmDateAudio').value);
|
||||
fd.append('model', document.getElementById('mmModelAudio').value);
|
||||
fd.append('whisperModel', document.getElementById('mmWhisperModel').value);
|
||||
setMeetingGenerating(true, '음성 파일을 업로드하고 전사합니다. 길이에 따라 1분 이상 걸릴 수 있습니다…');
|
||||
fetch('/api/meeting-minutes/generate-audio', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
return j;
|
||||
});
|
||||
})
|
||||
.then(function (d) {
|
||||
var audioFile = fileInput.files[0];
|
||||
|
||||
mmSetAudioPipelineVisible(true);
|
||||
mmAudioLastUiPhase = 'upload';
|
||||
mmUpdatePipelineStepHighlight('upload');
|
||||
|
||||
var fileSize = audioFile.size || 0;
|
||||
/** multipart 경계·헤더 등 보정 분모(multipart 업로드 total 미제공 시) */
|
||||
var mpSlop =
|
||||
fileSize > 0
|
||||
? Math.min(262144, Math.max(6144, Math.floor(fileSize / 50) + 2048))
|
||||
: 0;
|
||||
var xhrLoadedBytes = 0;
|
||||
var xhrEstimatedTotalBytes =
|
||||
fileSize > 0 ? fileSize + mpSlop : 0;
|
||||
var xhrKnownUploadTotal = fileSize > 0;
|
||||
var uploadStartTime = Date.now();
|
||||
var uploadPhase = true;
|
||||
var transcribePrepPct = 0;
|
||||
var segmentMode = false;
|
||||
|
||||
var lastSseName = '';
|
||||
var lastSseAtMs = 0;
|
||||
var lastProgressStage = null;
|
||||
var lastSegDone = 0;
|
||||
var lastSegTotal = 0;
|
||||
|
||||
function mmAudioDiagRefresh() {
|
||||
var hintKo = '';
|
||||
if (uploadPhase) {
|
||||
hintKo =
|
||||
'① POST prepare-audio: XHR 업로드·파일 디스크 저장 후 jobId JSON · ② GET stream-audio: fetch SSE로 증분 수신 (단일 업로드+SSE 연결은 일부 브라우저/프록시가 본문을 버퍼링할 수 있습니다).';
|
||||
} else if (mmAudioLastUiPhase === 'translate') {
|
||||
hintKo = '번역 단계: 전사 결과를 회의록 형식으로 LLM이 작성 중입니다.';
|
||||
} else if (segmentMode) {
|
||||
hintKo =
|
||||
'전사 단계: 세그먼트 처리 중 (' +
|
||||
lastSegDone +
|
||||
' / ' +
|
||||
lastSegTotal +
|
||||
')';
|
||||
} else {
|
||||
hintKo =
|
||||
'전사 준비: 서버에서 설정·분할 등 — 세그먼트 init 이벤트 전일 수 있습니다.';
|
||||
}
|
||||
mmAudioJobDiag = {
|
||||
active: true,
|
||||
startedAtMs: uploadStartTime,
|
||||
elapsedMs: Date.now() - uploadStartTime,
|
||||
audioFileBytes: fileSize,
|
||||
uploadPhase: uploadPhase,
|
||||
xhrKnownTotal: xhrKnownUploadTotal,
|
||||
xhrUploadedBytes: xhrLoadedBytes,
|
||||
xhrEstimatedTotalBytes: xhrEstimatedTotalBytes,
|
||||
uiPhase: mmAudioLastUiPhase,
|
||||
transcribePrepPct: transcribePrepPct,
|
||||
segmentMode: segmentMode,
|
||||
transcribeProgress: { done: lastSegDone, total: lastSegTotal },
|
||||
lastProgressStage: lastProgressStage,
|
||||
lastSseEvent: lastSseName,
|
||||
lastSseAtMs: lastSseAtMs || null,
|
||||
hintKo: hintKo,
|
||||
};
|
||||
}
|
||||
|
||||
mmAudioDiagRefresh();
|
||||
|
||||
setMeetingGenerating(true);
|
||||
mmSetAudioGenerationPhase(
|
||||
'upload',
|
||||
0,
|
||||
'파일 업로드를 시작합니다(XHR)·전송률은 바이트 기준입니다.'
|
||||
);
|
||||
|
||||
var sseError = null;
|
||||
|
||||
function warmTranscriptionAfterPrepare() {
|
||||
window.setTimeout(function () {
|
||||
transcribePrepPct = 6;
|
||||
segmentMode = false;
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
transcribePrepPct,
|
||||
'전사: DB 및 프롬프트 로드·음성 준비 중 (이후 세그먼트별 진행률).'
|
||||
);
|
||||
mmAudioDiagRefresh();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function mmMeetingAudioFatal(e) {
|
||||
uploadPhase = false;
|
||||
mmAudioJobDiag = {
|
||||
active: false,
|
||||
endedAtMs: Date.now(),
|
||||
hintKo: '요청 실패: ' + (e && e.message ? e.message : String(e)),
|
||||
};
|
||||
alert(e.message || '생성 실패');
|
||||
setMeetingGenerating(false);
|
||||
}
|
||||
|
||||
var mmAudioStreamHandlers = {
|
||||
onSseEvent: function (event, data) {
|
||||
try {
|
||||
lastSseName = event;
|
||||
lastSseAtMs = Date.now();
|
||||
if (event === 'progress' && data && data.stage) lastProgressStage = data.stage;
|
||||
if (event === 'progress' && data && data.stage === 'transcribe') {
|
||||
lastSegDone = data.done != null ? data.done : 0;
|
||||
lastSegTotal = data.total != null ? data.total : 1;
|
||||
}
|
||||
if (event === 'accepted') {
|
||||
transcribePrepPct = Math.max(transcribePrepPct, 22);
|
||||
var am = data && data.message ? String(data.message) : '';
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
transcribePrepPct,
|
||||
am || '서버 접수 후 설정 로드 중입니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event === 'heartbeat') {
|
||||
if (!segmentMode) {
|
||||
transcribePrepPct = Math.min(94, transcribePrepPct + 3);
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
transcribePrepPct,
|
||||
'서버 처리 및 전사를 기다리는 중입니다…'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === 'progress') {
|
||||
var stage = data.stage;
|
||||
var done = data.done || 0;
|
||||
var total = data.total || 1;
|
||||
if (stage === 'prep') {
|
||||
transcribePrepPct = Math.max(transcribePrepPct, 52);
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
transcribePrepPct,
|
||||
(data.message && String(data.message)) ||
|
||||
'전사 준비(음성 구간 처리·전사 엔진 연결) 중입니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (stage === 'init') {
|
||||
segmentMode = true;
|
||||
lastSegDone = 0;
|
||||
lastSegTotal = total;
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
0,
|
||||
total > 1
|
||||
? '전사 시작: 구간별 API 호출 (0 / ' +
|
||||
total +
|
||||
' 완료) — 이 단계만 100% 기준입니다.'
|
||||
: '전사 API 호출이 시작되었습니다. 이 단계만 100% 기준입니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (stage === 'transcribe') {
|
||||
var segPct = Math.min(100, Math.round((done / total) * 100));
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
segPct,
|
||||
total > 1
|
||||
? '구간별 전사 중 (' + done + ' / ' + total + ' 완료)…'
|
||||
: '전사 API 응답 처리 중…'
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (event === 'generating') {
|
||||
mmSetAudioGenerationPhase(
|
||||
'translate',
|
||||
null,
|
||||
'전사 결과를 회의록 형식(LLM 작성)으로 정리합니다.'
|
||||
);
|
||||
} else if (event === 'done') {
|
||||
mmSetAudioGenerationPhase(
|
||||
'translate',
|
||||
100,
|
||||
'회의록 작성·저장 및 체크리스트 후처리까지 완료되었습니다.'
|
||||
);
|
||||
var d = data;
|
||||
resultSection.hidden = false;
|
||||
setCurrentMeetingId(d.meeting && d.meeting.id);
|
||||
lastMeetingTranscriptIsAudio = true;
|
||||
@@ -793,16 +1289,79 @@
|
||||
loadMeetings();
|
||||
var cs = d.checklistSync;
|
||||
if (cs && cs.imported > 0) {
|
||||
alert('업무 체크리스트에 ' + cs.imported + '건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)');
|
||||
alert(
|
||||
'업무 체크리스트에 ' +
|
||||
cs.imported +
|
||||
'건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)'
|
||||
);
|
||||
} else if (cs && cs.extractError && !cs.disabled) {
|
||||
console.warn('체크리스트 자동 추출:', cs.extractError);
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
alert(e.message || '생성 실패');
|
||||
})
|
||||
.finally(function () {
|
||||
} else if (event === 'error') {
|
||||
sseError = (data && data.message) || '생성 실패';
|
||||
}
|
||||
} finally {
|
||||
mmAudioDiagRefresh();
|
||||
}
|
||||
},
|
||||
onEnd: function () {
|
||||
mmAudioJobDiag = {
|
||||
active: false,
|
||||
endedAtMs: Date.now(),
|
||||
hintKo: sseError
|
||||
? '오류로 종료: ' + sseError
|
||||
: '스트림 종료(정상 완료 또는 서버 연결 종료).',
|
||||
};
|
||||
if (sseError) alert(sseError);
|
||||
setMeetingGenerating(false);
|
||||
},
|
||||
onError: mmMeetingAudioFatal,
|
||||
};
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('audio', audioFile);
|
||||
fd.append('title', audioTitle);
|
||||
fd.append('meetingDate', document.getElementById('mmDateAudio').value);
|
||||
fd.append('model', document.getElementById('mmModelAudio').value);
|
||||
fd.append('whisperModel', document.getElementById('mmWhisperModel').value);
|
||||
|
||||
postPrepareMeetingAudioJob('/api/meeting-minutes/prepare-audio', fd, {
|
||||
onUploadProgress: function (computable, loaded, total) {
|
||||
xhrLoadedBytes = loaded;
|
||||
var effTotal = 0;
|
||||
if (computable && total > 0) {
|
||||
effTotal = total;
|
||||
} else if (fileSize > 0) {
|
||||
effTotal = fileSize + mpSlop;
|
||||
}
|
||||
xhrEstimatedTotalBytes = effTotal;
|
||||
xhrKnownUploadTotal = !!(effTotal > 0);
|
||||
if (!uploadPhase || effTotal <= 0) {
|
||||
mmAudioDiagRefresh();
|
||||
return;
|
||||
}
|
||||
var pct = Math.min(99, Math.round((loaded * 100) / Math.max(effTotal, 1)));
|
||||
var detail =
|
||||
'업로드 중… ' +
|
||||
loaded.toLocaleString() +
|
||||
' / 약 ' +
|
||||
effTotal.toLocaleString() +
|
||||
' 바이트';
|
||||
mmSetAudioGenerationPhase('upload', pct, detail);
|
||||
mmAudioDiagRefresh();
|
||||
},
|
||||
onJobReady: function (jobId) {
|
||||
uploadPhase = false;
|
||||
mmSetAudioGenerationPhase(
|
||||
'upload',
|
||||
100,
|
||||
'업로드를 마쳤습니다. 전사 스트림에 연결합니다…'
|
||||
);
|
||||
mmAudioDiagRefresh();
|
||||
warmTranscriptionAfterPrepare();
|
||||
streamMeetingAudioJobSse(jobId, mmAudioStreamHandlers);
|
||||
},
|
||||
onError: mmMeetingAudioFatal,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,11 +23,17 @@
|
||||
/>
|
||||
<div class="nav-logo-divider" role="presentation" aria-hidden="true"></div>
|
||||
</div>
|
||||
<a href="/chat" class="nav-item <%= activeMenu === 'chat' ? 'active' : '' %>">채팅</a>
|
||||
<% var _guideBotOk = typeof guideBotMenuAllowed !== "undefined" && guideBotMenuAllowed; %>
|
||||
<% if (_guideBotOk) { %>
|
||||
<a href="/guide-bot" class="nav-item <%= activeMenu === 'guide-bot' ? 'active' : '' %>">가이드봇</a>
|
||||
<a href="/wm" class="nav-item <%= activeMenu === 'wm' ? 'active' : '' %>">WM</a>
|
||||
<% } %>
|
||||
<div class="nav-separator"></div>
|
||||
<a href="/ai-explore" class="nav-item <%= activeMenu === 'ai-explore' ? 'active' : '' %>">AI</a>
|
||||
<a href="/ai-explore/prompts" class="nav-item <%= activeMenu === 'prompts' ? 'active' : '' %>">프롬프트</a>
|
||||
<a href="/learning" class="nav-item <%= activeMenu === 'learning' ? 'active' : '' %>">학습센터</a>
|
||||
<a href="/ax-apply" class="nav-item <%= activeMenu === 'ax-apply' ? 'active' : '' %>">과제신청</a>
|
||||
<a href="/ai-cases" class="nav-item <%= activeMenu === 'ai-cases' ? 'active' : '' %>">성공사례</a>
|
||||
<a href="/ai-cases" class="nav-item <%= activeMenu === 'ai-cases' ? 'active' : '' %>">AI 활용 사례</a>
|
||||
<% var _dashOk = typeof dashboardMenuAllowed !== 'undefined' && dashboardMenuAllowed; %>
|
||||
<% if (_dashOk) { %>
|
||||
<div class="nav-separator"></div>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<% var detailAllowed = typeof successStoryDetailAllowed !== 'undefined' ? successStoryDetailAllowed : true; %>
|
||||
<% var _cover = story.coverImageUrl && String(story.coverImageUrl).trim(); %>
|
||||
<% var _detailHref = (story.submissionId) ? ("/ai-cases/submit/" + encodeURIComponent(String(story.submissionId))) : ("/ai-cases/" + encodeURIComponent(String(story.slug || ""))); %>
|
||||
<% var _isSubmission = !!story.submissionId || story._source === "submission"; %>
|
||||
<% var _viewCount = typeof story.viewCount !== "undefined" ? story.viewCount : 0; %>
|
||||
<% var _likeCount = typeof story.likeCount !== "undefined" ? story.likeCount : 0; %>
|
||||
<article class="success-story-card<%= detailAllowed ? '' : ' success-story-card--locked' %>">
|
||||
<% if (detailAllowed) { %>
|
||||
<a class="success-story-link" href="/ai-cases/<%= story.slug %>">
|
||||
<a class="success-story-link" href="<%= _detailHref %>">
|
||||
<div class="success-thumb<%= _cover ? ' success-thumb--cover' : ' success-thumb--gradient' %>" aria-hidden="true">
|
||||
<% if (_cover) { %>
|
||||
<div class="success-thumb-media">
|
||||
@@ -25,7 +29,7 @@
|
||||
<% }) %>
|
||||
</div>
|
||||
<small class="success-meta">
|
||||
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
|
||||
<% if (_isSubmission) { %>조회 <%= _viewCount %> · ♥ <%= _likeCount %><% if (story.publishedAt) { %> · <% } %><% } %><% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
|
||||
</small>
|
||||
</div>
|
||||
</a>
|
||||
@@ -56,7 +60,7 @@
|
||||
<% }) %>
|
||||
</div>
|
||||
<small class="success-meta">
|
||||
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
|
||||
<% if (_isSubmission) { %>조회 <%= _viewCount %> · ♥ <%= _likeCount %><% if (story.publishedAt) { %> · <% } %><% } %><% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||