/** * 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 = `