diff --git a/ai-service/main.py b/ai-service/main.py index 52850eb..9a1091d 100644 --- a/ai-service/main.py +++ b/ai-service/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse -from routers import health, embeddings, classification, daily_report, rag +from routers import health, embeddings, classification, daily_report, rag, chatbot from db.vector_store import vector_store from db.metadata_store import metadata_store from services.ollama_client import ollama_client @@ -64,6 +64,7 @@ app.include_router(embeddings.router, prefix="/api/ai") app.include_router(classification.router, prefix="/api/ai") app.include_router(daily_report.router, prefix="/api/ai") app.include_router(rag.router, prefix="/api/ai") +app.include_router(chatbot.router, prefix="/api/ai") @app.get("/") diff --git a/ai-service/routers/chatbot.py b/ai-service/routers/chatbot.py new file mode 100644 index 0000000..e2914bf --- /dev/null +++ b/ai-service/routers/chatbot.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from services.chatbot_service import analyze_user_input, summarize_report + +router = APIRouter(tags=["chatbot"]) + + +class AnalyzeRequest(BaseModel): + user_text: str + categories: dict = {} + + +class SummarizeRequest(BaseModel): + description: str = "" + type: str = "" + category: str = "" + item: str = "" + location: str = "" + project: str = "" + + +@router.post("/chatbot/analyze") +async def chatbot_analyze(req: AnalyzeRequest): + try: + result = await analyze_user_input(req.user_text, req.categories) + return {"success": True, **result} + except Exception as e: + raise HTTPException(status_code=500, detail="AI 분석 중 오류가 발생했습니다") + + +@router.post("/chatbot/summarize") +async def chatbot_summarize(req: SummarizeRequest): + try: + result = await summarize_report(req.model_dump()) + return {"success": True, **result} + except Exception as e: + raise HTTPException(status_code=500, detail="AI 요약 중 오류가 발생했습니다") diff --git a/ai-service/services/chatbot_service.py b/ai-service/services/chatbot_service.py new file mode 100644 index 0000000..80c3cc7 --- /dev/null +++ b/ai-service/services/chatbot_service.py @@ -0,0 +1,110 @@ +import json +from services.ollama_client import ollama_client + + +ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다. +사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다. + +신고 유형: +- nonconformity (부적합): 제품/작업 품질 관련 문제 +- facility (시설설비): 시설, 설비, 장비 관련 문제 +- safety (안전): 안전 위험, 위험 요소 관련 문제 + +반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요: +{ + "organized_description": "정리된 설명 (1-2문장)", + "suggested_type": "nonconformity 또는 facility 또는 safety", + "suggested_category_id": 카테고리ID(숫자) 또는 null, + "confidence": 0.0~1.0 사이의 확신도 +}""" + +SUMMARIZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 내용을 요약하는 AI 도우미입니다. +주어진 신고 정보를 보기 좋게 정리하여 한국어로 요약해주세요. + +반드시 아래 JSON 형식으로만 응답하세요: +{ + "summary": "요약 텍스트" +}""" + + +async def analyze_user_input(user_text: str, categories: dict) -> dict: + """사용자 초기 입력을 분석하여 유형 제안 + 설명 정리""" + category_context = "" + for type_key, cats in categories.items(): + type_label = {"nonconformity": "부적합", "facility": "시설설비", "safety": "안전"}.get(type_key, type_key) + cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats] + category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n" + + prompt = f"""카테고리 목록: +{category_context} + +사용자 입력: "{user_text}" + +위 카테고리 목록을 참고하여 JSON으로 응답하세요.""" + + raw = await ollama_client.generate_text(prompt, system=ANALYZE_SYSTEM_PROMPT) + + try: + start = raw.find("{") + end = raw.rfind("}") + 1 + if start >= 0 and end > start: + result = json.loads(raw[start:end]) + # Validate required fields + if "organized_description" not in result: + result["organized_description"] = user_text + if "suggested_type" not in result: + result["suggested_type"] = "nonconformity" + if "confidence" not in result: + result["confidence"] = 0.5 + return result + except json.JSONDecodeError: + pass + + return { + "organized_description": user_text, + "suggested_type": "nonconformity", + "suggested_category_id": None, + "confidence": 0.3, + } + + +async def summarize_report(data: dict) -> dict: + """최종 신고 내용을 요약""" + prompt = f"""신고 정보: +- 설명: {data.get('description', '')} +- 유형: {data.get('type', '')} +- 카테고리: {data.get('category', '')} +- 항목: {data.get('item', '')} +- 위치: {data.get('location', '')} +- 프로젝트: {data.get('project', '')} + +위 정보를 보기 좋게 요약하여 JSON으로 응답하세요.""" + + raw = await ollama_client.generate_text(prompt, system=SUMMARIZE_SYSTEM_PROMPT) + + try: + start = raw.find("{") + end = raw.rfind("}") + 1 + if start >= 0 and end > start: + result = json.loads(raw[start:end]) + if "summary" in result: + return result + except json.JSONDecodeError: + pass + + # Fallback: construct summary manually + parts = [] + if data.get("type"): + parts.append(f"[{data['type']}]") + if data.get("category"): + parts.append(data["category"]) + if data.get("item"): + parts.append(f"- {data['item']}") + if data.get("location"): + parts.append(f"\n위치: {data['location']}") + if data.get("project"): + parts.append(f"\n프로젝트: {data['project']}") + if data.get("description"): + parts.append(f"\n내용: {data['description']}") + + return {"summary": " ".join(parts) if parts else "신고 내용 요약"} diff --git a/system1-factory/web/js/tbm.js b/system1-factory/web/js/tbm.js index 6951ffb..a7916d8 100644 --- a/system1-factory/web/js/tbm.js +++ b/system1-factory/web/js/tbm.js @@ -392,12 +392,16 @@ function openNewTbmModal() { sessionDateDisplay.textContent = `${year}년 ${parseInt(month)}월 ${parseInt(day)}일 (${dayName})`; } - // 입력자 자동 설정 (readonly) + // 입력자 자동 설정 if (currentUser && currentUser.user_id) { const worker = allWorkers.find(w => w.user_id === currentUser.user_id); if (worker) { document.getElementById('leaderName').textContent = worker.worker_name; document.getElementById('leaderId').value = worker.user_id; + } else { + // 어드민: 작업자 목록에 없음 → 이름 표시, leaderId 비움 + document.getElementById('leaderName').textContent = currentUser.name || '관리자'; + document.getElementById('leaderId').value = ''; } } else if (currentUser && currentUser.name) { document.getElementById('leaderName').textContent = currentUser.name; @@ -534,20 +538,15 @@ function populateLeaderSelect() { if (!leaderSelect) return; // 로그인한 사용자가 작업자와 연결되어 있는지 확인 - if (currentUser && currentUser.user_id) { + const isWorker = currentUser && currentUser.user_id && allWorkers.find(w => w.user_id === currentUser.user_id); + if (isWorker) { // 작업자와 연결된 경우: 자동으로 선택하고 비활성화 - const worker = allWorkers.find(w => w.user_id === currentUser.user_id); - if (worker) { - const jobTypeText = worker.job_type ? ` (${escapeHtml(worker.job_type)})` : ''; - leaderSelect.innerHTML = ``; - leaderSelect.disabled = true; - } else { - // 작업자를 찾을 수 없는 경우 - leaderSelect.innerHTML = ''; - leaderSelect.disabled = true; - } + const worker = isWorker; + const jobTypeText = worker.job_type ? ` (${escapeHtml(worker.job_type)})` : ''; + leaderSelect.innerHTML = ``; + leaderSelect.disabled = true; } else { - // 관리자 계정 (user_id가 없음): 드롭다운으로 선택 가능 + // 관리자 또는 작업자 목록에 없는 계정: 드롭다운으로 선택 가능 const leaders = allWorkers.filter(w => w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin' ); @@ -638,13 +637,8 @@ async function saveTbmSession() { let leaderId = parseInt(document.getElementById('leaderId').value); if (!leaderId || isNaN(leaderId)) { - if (!currentUser.user_id) { - leaderId = null; - } else { - console.error(' 입력자 설정 오류'); - showToast('입력자 정보가 올바르지 않습니다.', 'error'); - return; - } + // 어드민이거나 작업자 목록에 없는 사용자: leaderId null로 저장 가능 + leaderId = null; } const sessionData = { diff --git a/system2-report/api/Dockerfile b/system2-report/api/Dockerfile index cfe530e..7725c4a 100644 --- a/system2-report/api/Dockerfile +++ b/system2-report/api/Dockerfile @@ -1,5 +1,7 @@ FROM node:18-alpine +RUN apk add --no-cache imagemagick libheif imagemagick-heic imagemagick-jpeg + WORKDIR /usr/src/app COPY package*.json ./ diff --git a/system2-report/api/controllers/workIssueController.js b/system2-report/api/controllers/workIssueController.js index 96b822a..928a15d 100644 --- a/system2-report/api/controllers/workIssueController.js +++ b/system2-report/api/controllers/workIssueController.js @@ -199,6 +199,7 @@ exports.createReport = async (req, res) => { // 부적합 유형이면 System 3(tkqc)으로 비동기 전달 try { + console.log(`[System3 연동] report_id=${reportId}, category_type=${categoryType}`); if (catInfo && catInfo.category_type === 'nonconformity') { const fs = require('fs').promises; const path = require('path'); @@ -209,8 +210,9 @@ exports.createReport = async (req, res) => { const filePath = path.join(__dirname, '..', p); const buf = await fs.readFile(filePath); photoBase64List.push(`data:image/jpeg;base64,${buf.toString('base64')}`); + console.log(`[System3 연동] 사진 읽기 성공: ${p} (${buf.length} bytes)`); } catch (readErr) { - console.error('사진 파일 읽기 실패:', p, readErr.message); + console.error('[System3 연동] 사진 파일 읽기 실패:', p, readErr.message); } } @@ -223,27 +225,32 @@ exports.createReport = async (req, res) => { if (locationParts.workplace_name) locationInfo += ` - ${locationParts.workplace_name}`; } } catch (locErr) { - console.error('위치 정보 조회 실패:', locErr.message); + console.error('[System3 연동] 위치 정보 조회 실패:', locErr.message); } } - const originalToken = (req.headers['authorization'] || '').replace('Bearer ', ''); + console.log(`[System3 연동] sendToMProject 호출: category=${catInfo.category_name}, photos=${photoBase64List.length}장, reporter=${req.user.username}`); const result = await mProjectService.sendToMProject({ category: catInfo.category_name, description: additional_description || catInfo.category_name, reporter_name: req.user.name || req.user.username, + reporter_username: req.user.username || req.user.sub, + reporter_role: req.user.role || 'user', tk_issue_id: reportId, project_id: project_id || null, location_info: locationInfo, photos: photoBase64List, - ssoToken: originalToken }); + console.log(`[System3 연동] 결과: ${JSON.stringify(result)}`); if (result.success && result.mProjectId) { await workIssueModel.updateMProjectId(reportId, result.mProjectId); + console.log(`[System3 연동] m_project_id=${result.mProjectId} 업데이트 완료`); } + } else { + console.log(`[System3 연동] 부적합 아님, 건너뜀`); } } catch (e) { - console.error('System3 연동 실패 (신고는 정상 저장됨):', e.message); + console.error('[System3 연동 실패]', e.message, e.stack); } } catch (error) { console.error('신고 생성 에러:', error); diff --git a/system2-report/api/services/imageUploadService.js b/system2-report/api/services/imageUploadService.js index 358b94c..d351c53 100644 --- a/system2-report/api/services/imageUploadService.js +++ b/system2-report/api/services/imageUploadService.js @@ -9,6 +9,7 @@ const path = require('path'); const fs = require('fs').promises; const crypto = require('crypto'); +const { execFileSync } = require('child_process'); // sharp는 선택적으로 사용 (설치되어 있지 않으면 리사이징 없이 저장) let sharp; @@ -110,6 +111,7 @@ async function saveBase64Image(base64String, prefix = 'issue', category = 'issue const filepath = path.join(uploadDir, filename); // sharp가 설치되어 있으면 리사이징 및 최적화 + let saved = false; if (sharp) { try { await sharp(buffer) @@ -119,13 +121,27 @@ async function saveBase64Image(base64String, prefix = 'issue', category = 'issue }) .jpeg({ quality: QUALITY }) .toFile(filepath); + saved = true; } catch (sharpError) { - console.error('sharp 처리 실패, 원본 저장:', sharpError.message); - // sharp 실패 시 원본 저장 - await fs.writeFile(filepath, buffer); + console.warn('sharp 처리 실패:', sharpError.message); } - } else { - // sharp가 없으면 원본 그대로 저장 + } + + // sharp 실패 시 ImageMagick으로 변환 시도 (HEIC 등) + if (!saved) { + try { + const tmpInput = filepath + '.tmp'; + await fs.writeFile(tmpInput, buffer); + execFileSync('magick', [tmpInput, '-resize', `${MAX_SIZE.width}x${MAX_SIZE.height}>`, '-quality', String(QUALITY), filepath]); + await fs.unlink(tmpInput).catch(() => {}); + saved = true; + } catch (magickError) { + console.warn('ImageMagick 변환 실패:', magickError.message); + } + } + + // 모두 실패 시 원본 저장 + if (!saved) { await fs.writeFile(filepath, buffer); } diff --git a/system2-report/api/services/mProjectService.js b/system2-report/api/services/mProjectService.js index f38fcef..edc4b43 100644 --- a/system2-report/api/services/mProjectService.js +++ b/system2-report/api/services/mProjectService.js @@ -8,6 +8,7 @@ */ const logger = require('../utils/logger'); +const jwt = require('jsonwebtoken'); // M-Project API 설정 const M_PROJECT_CONFIG = { @@ -183,20 +184,25 @@ async function sendToMProject(issueData) { category, description, reporter_name, + reporter_username = null, + reporter_role = 'user', project_name, project_id = null, tk_issue_id, location_info = null, photos = [], - ssoToken = null, } = issueData; logger.info('M-Project 연동 시작', { tk_issue_id, category }); - // SSO 토큰이 있으면 원래 사용자로 전송, 없으면 api_service 토큰 + // 신고자 정보로 SSO JWT 토큰 직접 생성 (api_service 대신 실제 신고자로) let token; - if (ssoToken) { - token = ssoToken; + if (reporter_username && process.env.JWT_SECRET) { + token = jwt.sign( + { sub: reporter_username, name: reporter_name || reporter_username, role: reporter_role }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); } else { token = await getAuthToken(); } diff --git a/system2-report/web/css/chat-report.css b/system2-report/web/css/chat-report.css new file mode 100644 index 0000000..1c829ed --- /dev/null +++ b/system2-report/web/css/chat-report.css @@ -0,0 +1,378 @@ +/* chat-report.css — 챗봇 신고 접수 UI */ + +:root { + --chat-primary: #0ea5e9; + --chat-primary-dark: #0284c7; + --chat-primary-light: #e0f2fe; + --chat-bg: #f1f5f9; + --chat-white: #ffffff; + --chat-text: #1e293b; + --chat-text-light: #64748b; + --chat-border: #e2e8f0; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif; + background: var(--chat-bg); + height: 100vh; + height: 100dvh; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; +} + +/* ── Header ── */ +.chat-header { + background: linear-gradient(135deg, var(--chat-primary), var(--chat-primary-dark)); + color: white; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; + padding-top: calc(0.75rem + env(safe-area-inset-top, 0px)); + z-index: 10; +} +.chat-header-back { + width: 36px; height: 36px; + border-radius: 50%; + border: none; + background: rgba(255,255,255,0.2); + color: white; + font-size: 1.25rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + -webkit-tap-highlight-color: transparent; +} +.chat-header-title { + font-size: 1.0625rem; + font-weight: 700; +} +.chat-header-subtitle { + font-size: 0.6875rem; + opacity: 0.85; +} + +/* ── Chat Area ── */ +.chat-area { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + -webkit-overflow-scrolling: touch; +} +.chat-area::-webkit-scrollbar { width: 4px; } +.chat-area::-webkit-scrollbar-track { background: transparent; } +.chat-area::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; } + +/* ── Message Bubbles ── */ +.chat-msg { + display: flex; + gap: 0.5rem; + max-width: 88%; + animation: bubbleIn 0.25s ease-out; +} +.chat-msg.bot { align-self: flex-start; } +.chat-msg.user { align-self: flex-end; flex-direction: row-reverse; } + +.chat-avatar { + width: 32px; height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + flex-shrink: 0; +} +.chat-msg.bot .chat-avatar { + background: var(--chat-primary-light); + color: var(--chat-primary); +} +.chat-msg.user .chat-avatar { + background: #dbeafe; + color: #2563eb; +} + +.chat-bubble { + padding: 0.75rem 1rem; + border-radius: 1.125rem; + font-size: 0.875rem; + line-height: 1.5; + word-break: break-word; +} +.chat-msg.bot .chat-bubble { + background: var(--chat-white); + color: var(--chat-text); + border-bottom-left-radius: 0.25rem; + box-shadow: 0 1px 2px rgba(0,0,0,0.06); +} +.chat-msg.user .chat-bubble { + background: var(--chat-primary); + color: white; + border-bottom-right-radius: 0.25rem; +} + +/* ── Typing Indicator ── */ +.typing-indicator { + display: flex; + gap: 0.25rem; + padding: 0.75rem 1rem; +} +.typing-indicator span { + width: 8px; height: 8px; + border-radius: 50%; + background: #94a3b8; + animation: typingBounce 1.4s infinite ease-in-out; +} +.typing-indicator span:nth-child(1) { animation-delay: 0s; } +.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } +.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } + +/* ── Option Buttons (chip style) ── */ +.chat-options { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.25rem 0; + align-self: flex-start; + max-width: 100%; + animation: bubbleIn 0.25s ease-out; +} +.chat-option-btn { + padding: 0.5rem 1rem; + border: 1.5px solid var(--chat-border); + border-radius: 2rem; + background: var(--chat-white); + font-size: 0.8125rem; + color: var(--chat-text); + cursor: pointer; + transition: all 0.15s; + -webkit-tap-highlight-color: transparent; + white-space: nowrap; +} +.chat-option-btn:active { transform: scale(0.97); } +.chat-option-btn:hover { border-color: var(--chat-primary); color: var(--chat-primary); } +.chat-option-btn.selected { + border-color: var(--chat-primary); + background: var(--chat-primary-light); + color: var(--chat-primary-dark); + font-weight: 600; +} +.chat-option-btn.suggested { + border-color: var(--chat-primary); + background: var(--chat-primary-light); + position: relative; +} +.chat-option-btn.suggested::after { + content: 'AI 추천'; + position: absolute; + top: -8px; + right: 8px; + font-size: 0.5625rem; + background: var(--chat-primary); + color: white; + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; +} + +/* ── Photo Thumbnails in Chat ── */ +.chat-photos { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; + margin-top: 0.375rem; +} +.chat-photos img { + width: 64px; height: 64px; + object-fit: cover; + border-radius: 0.5rem; + border: 1px solid var(--chat-border); +} +.chat-photos .photo-fallback { + width: 64px; height: 64px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + background: rgba(255,255,255,0.2); + border: 1px dashed rgba(255,255,255,0.5); + font-size: 0.6875rem; + text-align: center; +} + +/* ── Summary Card ── */ +.summary-card { + background: var(--chat-white); + border: 1px solid var(--chat-border); + border-radius: 0.75rem; + padding: 1rem; + margin-top: 0.5rem; + font-size: 0.8125rem; + line-height: 1.6; + animation: bubbleIn 0.25s ease-out; + align-self: flex-start; + max-width: 88%; +} +.summary-card .summary-title { + font-weight: 700; + color: var(--chat-primary-dark); + margin-bottom: 0.5rem; + font-size: 0.875rem; +} +.summary-card .summary-row { + display: flex; + gap: 0.5rem; + padding: 0.25rem 0; + border-bottom: 1px solid #f1f5f9; +} +.summary-card .summary-row:last-child { border-bottom: none; } +.summary-card .summary-label { + color: var(--chat-text-light); + flex-shrink: 0; + min-width: 4.5rem; +} +.summary-card .summary-value { + color: var(--chat-text); + font-weight: 500; +} +.summary-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + animation: bubbleIn 0.25s ease-out; + align-self: flex-start; +} +.summary-actions button { + padding: 0.625rem 1.25rem; + border: none; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.summary-actions .btn-submit { + background: var(--chat-primary); + color: white; +} +.summary-actions .btn-submit:active { background: var(--chat-primary-dark); } +.summary-actions .btn-edit { + background: #f1f5f9; + color: var(--chat-text); +} + +/* ── Input Bar ── */ +.chat-input-bar { + background: var(--chat-white); + border-top: 1px solid var(--chat-border); + padding: 0.5rem 0.75rem; + padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px)); + display: flex; + align-items: flex-end; + gap: 0.5rem; + flex-shrink: 0; +} +.chat-input-bar.disabled { + opacity: 0.5; + pointer-events: none; +} +.chat-photo-btn { + width: 40px; height: 40px; + border-radius: 50%; + border: 1.5px solid var(--chat-border); + background: var(--chat-white); + font-size: 1.125rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + -webkit-tap-highlight-color: transparent; + position: relative; +} +.chat-photo-btn .photo-count { + position: absolute; + top: -4px; right: -4px; + background: var(--chat-primary); + color: white; + font-size: 0.625rem; + width: 16px; height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; +} +.chat-text-input { + flex: 1; + border: 1.5px solid var(--chat-border); + border-radius: 1.25rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + outline: none; + resize: none; + max-height: 100px; + line-height: 1.4; + font-family: inherit; +} +.chat-text-input:focus { border-color: var(--chat-primary); } +.chat-send-btn { + width: 40px; height: 40px; + border-radius: 50%; + border: none; + background: var(--chat-primary); + color: white; + font-size: 1.125rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; +} +.chat-send-btn:disabled { background: #cbd5e1; cursor: not-allowed; } +.chat-send-btn:not(:disabled):active { background: var(--chat-primary-dark); } + +/* ── Loading overlay ── */ +.chat-loading { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} +.chat-loading-inner { + background: white; + padding: 1.5rem 2rem; + border-radius: 1rem; + text-align: center; + font-size: 0.875rem; + color: var(--chat-text); + box-shadow: 0 4px 24px rgba(0,0,0,0.15); +} + +/* ── Animations ── */ +@keyframes bubbleIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +/* ── Responsive ── */ +@media (min-width: 480px) { + body { max-width: 480px; margin: 0 auto; box-shadow: 0 0 20px rgba(0,0,0,0.1); } +} diff --git a/system2-report/web/js/chat-report.js b/system2-report/web/js/chat-report.js new file mode 100644 index 0000000..0ecbe8b --- /dev/null +++ b/system2-report/web/js/chat-report.js @@ -0,0 +1,847 @@ +/** + * chat-report.js — 챗봇 기반 신고 접수 상태머신 + 대화 로직 + */ + +// ── 상태 정의 ── +const STATE = { + INIT: 'INIT', + PHOTO_TEXT: 'PHOTO_TEXT', + CLASSIFY_TYPE: 'CLASSIFY_TYPE', + CLASSIFY_CATEGORY: 'CLASSIFY_CATEGORY', + CLASSIFY_ITEM: 'CLASSIFY_ITEM', + LOCATION: 'LOCATION', + PROJECT: 'PROJECT', + CONFIRM: 'CONFIRM', + SUBMIT: 'SUBMIT', +}; + +let currentState = STATE.INIT; + +// ── 신고 데이터 ── +let reportData = { + photos: [], + description: '', + organized_description: '', + issue_type: null, // 'nonconformity' | 'facility' | 'safety' + issue_category_id: null, + issue_category_name: null, + issue_item_id: null, + issue_item_name: null, + custom_item_name: null, + factory_category_id: null, + factory_name: null, + workplace_id: null, + workplace_name: null, + custom_location: null, + project_id: null, + project_name: null, + tbm_session_id: null, +}; + +// ── 참조 데이터 ── +let refData = { + factories: [], + workplaces: {}, // { factory_id: [workplaces] } + projects: [], + tbmSessions: [], + categories: {}, // { nonconformity: [...], facility: [...], safety: [...] } + items: {}, // { category_id: [...] } +}; + +// ── AI 분석 결과 캐시 ── +let aiAnalysis = null; + +// ── DOM ── +const API_BASE = window.API_BASE_URL || 'http://localhost:30105/api'; +const AI_API_BASE = (() => { + const hostname = window.location.hostname; + const protocol = window.location.protocol; + if (hostname.includes('technicalkorea.net')) { + return protocol + '//' + hostname + '/ai-api'; + } + return protocol + '//' + hostname + ':30200/api/ai'; +})(); + +let chatArea, textInput, sendBtn, photoInput, photoBtn; + +// ── 초기화 ── +document.addEventListener('DOMContentLoaded', () => { + chatArea = document.getElementById('chatArea'); + textInput = document.getElementById('textInput'); + sendBtn = document.getElementById('sendBtn'); + photoInput = document.getElementById('chatPhotoInput'); + photoBtn = document.getElementById('photoBtn'); + + sendBtn.addEventListener('click', onSend); + textInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); onSend(); } + }); + textInput.addEventListener('input', () => { + textInput.style.height = 'auto'; + textInput.style.height = Math.min(textInput.scrollHeight, 100) + 'px'; + sendBtn.disabled = !textInput.value.trim() && reportData.photos.length === 0; + }); + photoBtn.addEventListener('click', () => photoInput.click()); + photoInput.addEventListener('change', onPhotoSelect); + + initChat(); +}); + +// ── initChat: 인사 + 데이터 프리패치 ── +async function initChat() { + appendBot('안녕하세요! AI 신고 도우미입니다.\n\n현장에서 발견한 문제를 **사진과 함께 설명**해주시면, 신고 접수를 도와드리겠습니다.\n\n📎 버튼으로 사진을 첨부하고, 어떤 문제인지 간단히 입력해주세요.'); + + // 데이터 프리패치 + try { + const today = new Date().toISOString().slice(0, 10); + const headers = getHeaders(); + + const [factoriesRes, projectsRes, tbmRes] = await Promise.all([ + fetch(`${API_BASE}/workplaces/categories/active/list`, { headers }), + fetch(`${API_BASE}/projects/active/list`, { headers }), + fetch(`${API_BASE}/tbm/sessions/date/${today}`, { headers }), + ]); + + if (factoriesRes.ok) { + const d = await factoriesRes.json(); + refData.factories = d.data || []; + } + if (projectsRes.ok) { + const d = await projectsRes.json(); + refData.projects = d.data || []; + } + if (tbmRes.ok) { + const d = await tbmRes.json(); + refData.tbmSessions = d.data || []; + } + + // 카테고리 3가지 유형 동시 로드 + const types = ['nonconformity', 'facility', 'safety']; + const catResults = await Promise.all( + types.map(t => fetch(`${API_BASE}/work-issues/categories/type/${t}`, { headers }).then(r => r.ok ? r.json() : { data: [] })) + ); + types.forEach((t, i) => { + refData.categories[t] = catResults[i].data || []; + }); + + } catch (err) { + console.error('데이터 프리패치 실패:', err); + } + + currentState = STATE.PHOTO_TEXT; + updateInputBar(); +} + +// ── 전송 핸들러 ── +function onSend() { + const text = textInput.value.trim(); + if (!text && reportData.photos.length === 0) return; + + if (currentState === STATE.PHOTO_TEXT) { + handlePhotoTextSubmit(text); + } + + textInput.value = ''; + textInput.style.height = 'auto'; + sendBtn.disabled = true; +} + +// ── PHOTO_TEXT: 사진+텍스트 제출 → AI 분석 ── +async function handlePhotoTextSubmit(text) { + // 사용자 메시지 표시 + if (reportData.photos.length > 0 || text) { + appendUser(text, reportData.photos); + } + reportData.description = text; + + setInputDisabled(true); + showTyping(); + + // AI 분석 호출 + try { + const categoriesForAI = {}; + for (const [type, cats] of Object.entries(refData.categories)) { + categoriesForAI[type] = cats.map(c => ({ id: c.category_id, name: c.category_name })); + } + + const res = await fetch(`${AI_API_BASE}/chatbot/analyze`, { + method: 'POST', + headers: { ...getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_text: text || '사진 참고', categories: categoriesForAI }), + }); + + if (res.ok) { + aiAnalysis = await res.json(); + } else { + aiAnalysis = { organized_description: text, suggested_type: null, confidence: 0 }; + } + } catch (err) { + console.error('AI 분석 실패:', err); + aiAnalysis = { organized_description: text, suggested_type: null, confidence: 0 }; + } + + hideTyping(); + reportData.organized_description = aiAnalysis.organized_description || text; + + // 유형 선택 단계로 + currentState = STATE.CLASSIFY_TYPE; + showTypeSelection(); + setInputDisabled(true); +} + +// ── CLASSIFY_TYPE: 유형 선택 ── +function showTypeSelection() { + const typeLabels = { + nonconformity: '부적합', + facility: '시설설비', + safety: '안전', + }; + + let msg = '내용을 분석했습니다.'; + if (aiAnalysis && aiAnalysis.organized_description) { + msg += `\n\n📝 "${escapeHtml(aiAnalysis.organized_description)}"`; + } + msg += '\n\n신고 **유형**을 선택해주세요:'; + appendBot(msg); + + const options = Object.entries(typeLabels).map(([value, label]) => ({ + value, label, + suggested: aiAnalysis && aiAnalysis.suggested_type === value, + })); + appendOptions(options, onTypeSelect); +} + +function onTypeSelect(type) { + reportData.issue_type = type; + const label = { nonconformity: '부적합', facility: '시설설비', safety: '안전' }[type]; + appendUser(label); + disableCurrentOptions(); + + currentState = STATE.CLASSIFY_CATEGORY; + showCategorySelection(); +} + +// ── CLASSIFY_CATEGORY ── +function showCategorySelection() { + const cats = refData.categories[reportData.issue_type] || []; + if (cats.length === 0) { + appendBot('해당 유형의 카테고리가 없습니다. 다음 단계로 진행합니다.'); + currentState = STATE.LOCATION; + showLocationSelection(); + return; + } + + appendBot('**카테고리**를 선택해주세요:'); + + const options = cats.map(c => ({ + value: c.category_id, + label: c.category_name, + suggested: aiAnalysis && aiAnalysis.suggested_category_id === c.category_id, + })); + appendOptions(options, onCategorySelect); +} + +async function onCategorySelect(categoryId) { + const cats = refData.categories[reportData.issue_type] || []; + const cat = cats.find(c => c.category_id == categoryId); + reportData.issue_category_id = categoryId; + reportData.issue_category_name = cat ? cat.category_name : ''; + appendUser(reportData.issue_category_name); + disableCurrentOptions(); + + // 항목 로드 + currentState = STATE.CLASSIFY_ITEM; + await showItemSelection(categoryId); +} + +// ── CLASSIFY_ITEM ── +async function showItemSelection(categoryId) { + if (!refData.items[categoryId]) { + try { + const res = await fetch(`${API_BASE}/work-issues/items/category/${categoryId}`, { headers: getHeaders() }); + if (res.ok) { + const d = await res.json(); + refData.items[categoryId] = d.data || []; + } + } catch (err) { + console.error('항목 로드 실패:', err); + } + } + + const items = refData.items[categoryId] || []; + if (items.length === 0) { + appendBot('항목이 없습니다. 다음 단계로 진행합니다.'); + currentState = STATE.LOCATION; + showLocationSelection(); + return; + } + + appendBot('**세부 항목**을 선택해주세요:'); + + const options = items.map(i => ({ value: i.item_id, label: i.item_name })); + options.push({ value: '__custom__', label: '+ 직접 입력' }); + appendOptions(options, onItemSelect); +} + +function onItemSelect(itemValue) { + if (itemValue === '__custom__') { + disableCurrentOptions(); + showCustomItemInput(); + return; + } + const items = refData.items[reportData.issue_category_id] || []; + const item = items.find(i => i.item_id == itemValue); + reportData.issue_item_id = itemValue; + reportData.issue_item_name = item ? item.item_name : ''; + reportData.custom_item_name = null; + appendUser(reportData.issue_item_name); + disableCurrentOptions(); + + currentState = STATE.LOCATION; + showLocationSelection(); +} + +function showCustomItemInput() { + appendBot('항목명을 직접 입력해주세요:'); + setInputDisabled(false); + textInput.placeholder = '항목명 입력...'; + // Temporarily override send for custom item + const origOnSend = onSend; + sendBtn.onclick = () => { + const val = textInput.value.trim(); + if (!val) return; + reportData.custom_item_name = val; + reportData.issue_item_id = null; + reportData.issue_item_name = val; + appendUser(val); + textInput.value = ''; + textInput.style.height = 'auto'; + textInput.placeholder = '메시지 입력...'; + sendBtn.onclick = onSend; + setInputDisabled(true); + currentState = STATE.LOCATION; + showLocationSelection(); + }; + textInput.onkeydown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBtn.click(); } + }; +} + +// ── LOCATION: 작업장 선택 ── +function showLocationSelection() { + appendBot('**위치(작업장)**를 선택해주세요:'); + + const options = []; + refData.factories.forEach(factory => { + // 해당 공장의 TBM 작업장만 우선 표시 + const factoryTbm = refData.tbmSessions.filter(s => { + // Check if workplace belongs to this factory + return true; // We'll show all workplaces by factory group + }); + + options.push({ + value: `factory_${factory.category_id}`, + label: `🏭 ${factory.category_name}`, + isGroup: true, + }); + }); + + // 작업장 로드 후 표시 (공장별) + loadWorkplacesAndShow(); +} + +async function loadWorkplacesAndShow() { + const allOptions = []; + + for (const factory of refData.factories) { + // 작업장 로드 (맵 리전에서) + if (!refData.workplaces[factory.category_id]) { + try { + const res = await fetch(`${API_BASE}/workplaces/categories/${factory.category_id}/map-regions`, { headers: getHeaders() }); + if (res.ok) { + const d = await res.json(); + refData.workplaces[factory.category_id] = (d.data || []).map(r => ({ + workplace_id: r.workplace_id, + workplace_name: r.workplace_name, + factory_id: factory.category_id, + factory_name: factory.category_name, + })); + } + } catch (err) { + console.error(`작업장 로드 실패 (${factory.category_name}):`, err); + } + } + + const workplaces = refData.workplaces[factory.category_id] || []; + + if (workplaces.length > 0) { + // 해당 공장에 TBM이 있는 작업장 표시 + const tbmWorkplaceIds = new Set( + refData.tbmSessions + .filter(s => workplaces.some(w => w.workplace_id === s.workplace_id)) + .map(s => s.workplace_id) + ); + + workplaces.forEach(wp => { + const hasTbm = tbmWorkplaceIds.has(wp.workplace_id); + allOptions.push({ + value: JSON.stringify({ fid: factory.category_id, fname: factory.category_name, wid: wp.workplace_id, wname: wp.workplace_name }), + label: `${factory.category_name} - ${wp.workplace_name}${hasTbm ? ' 🔨' : ''}`, + }); + }); + } + } + + allOptions.push({ value: '__unknown__', label: '📍 위치 모름 / 직접 입력' }); + + appendOptions(allOptions, onLocationSelect); +} + +function onLocationSelect(value) { + disableCurrentOptions(); + if (value === '__unknown__') { + reportData.factory_category_id = null; + reportData.workplace_id = null; + reportData.workplace_name = null; + appendUser('위치 모름'); + + // 직접 입력 + appendBot('위치를 직접 입력해주세요 (또는 "모름"이라고 입력):'); + setInputDisabled(false); + textInput.placeholder = '위치 입력...'; + sendBtn.onclick = () => { + const val = textInput.value.trim(); + if (!val) return; + reportData.custom_location = val === '모름' ? null : val; + appendUser(val); + textInput.value = ''; + textInput.style.height = 'auto'; + textInput.placeholder = '메시지 입력...'; + sendBtn.onclick = onSend; + setInputDisabled(true); + currentState = STATE.PROJECT; + showProjectSelection(); + }; + textInput.onkeydown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBtn.click(); } + }; + return; + } + + try { + const loc = JSON.parse(value); + reportData.factory_category_id = loc.fid; + reportData.factory_name = loc.fname; + reportData.workplace_id = loc.wid; + reportData.workplace_name = loc.wname; + reportData.custom_location = null; + appendUser(`${loc.fname} - ${loc.wname}`); + } catch (e) { + appendUser(value); + } + + currentState = STATE.PROJECT; + showProjectSelection(); +} + +// ── PROJECT: 프로젝트 선택 ── +function showProjectSelection() { + appendBot('**프로젝트**를 선택해주세요:'); + + const options = []; + + // TBM 세션에서 프로젝트 정보 (해당 작업장 우선) + const tbmProjectIds = new Set(); + const relevantTbm = reportData.workplace_id + ? refData.tbmSessions.filter(s => s.workplace_id === reportData.workplace_id) + : refData.tbmSessions; + + relevantTbm.forEach(s => { + if (s.project_id) { + const proj = refData.projects.find(p => p.project_id === s.project_id); + if (proj && !tbmProjectIds.has(s.project_id)) { + tbmProjectIds.add(s.project_id); + const memberCount = s.team_member_count || s.member_count || 0; + options.push({ + value: JSON.stringify({ pid: proj.project_id, pname: proj.project_name, sid: s.session_id }), + label: `🔨 ${proj.project_name} (TBM ${memberCount}명)`, + }); + } + } + }); + + // 나머지 활성 프로젝트 + refData.projects.forEach(p => { + if (!tbmProjectIds.has(p.project_id)) { + options.push({ + value: JSON.stringify({ pid: p.project_id, pname: p.project_name, sid: null }), + label: p.project_name, + }); + } + }); + + options.push({ value: '__unknown__', label: '프로젝트 모름 (건너뛰기)' }); + + appendOptions(options, onProjectSelect); +} + +function onProjectSelect(value) { + disableCurrentOptions(); + if (value === '__unknown__') { + reportData.project_id = null; + reportData.project_name = null; + reportData.tbm_session_id = null; + appendUser('프로젝트 모름'); + } else { + try { + const proj = JSON.parse(value); + reportData.project_id = proj.pid; + reportData.project_name = proj.pname; + reportData.tbm_session_id = proj.sid; + appendUser(proj.pname); + } catch (e) { + appendUser(value); + } + } + + currentState = STATE.CONFIRM; + showConfirmation(); +} + +// ── CONFIRM: 요약 확인 ── +async function showConfirmation() { + showTyping(); + + // AI 요약 호출 + let summaryText = ''; + try { + const typeLabel = { nonconformity: '부적합', facility: '시설설비', safety: '안전' }[reportData.issue_type] || ''; + const res = await fetch(`${AI_API_BASE}/chatbot/summarize`, { + method: 'POST', + headers: { ...getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + description: reportData.organized_description || reportData.description, + type: typeLabel, + category: reportData.issue_category_name || '', + item: reportData.issue_item_name || reportData.custom_item_name || '', + location: reportData.workplace_name + ? `${reportData.factory_name || ''} - ${reportData.workplace_name}` + : (reportData.custom_location || '미지정'), + project: reportData.project_name || '미지정', + }), + }); + if (res.ok) { + const d = await res.json(); + summaryText = d.summary || ''; + } + } catch (err) { + console.error('AI 요약 실패:', err); + } + + hideTyping(); + + appendBot('신고 내용을 확인해주세요:'); + + // Summary card + const typeLabel = { nonconformity: '부적합', facility: '시설설비', safety: '안전' }[reportData.issue_type] || ''; + const locationText = reportData.workplace_name + ? `${reportData.factory_name || ''} - ${reportData.workplace_name}` + : (reportData.custom_location || '미지정'); + + const card = document.createElement('div'); + card.className = 'summary-card'; + card.innerHTML = ` +