feat: AI 서비스 및 AI 어시스턴트 전용 페이지 추가

- ai-service: Ollama 기반 AI 서비스 (분류, 시맨틱 검색, RAG Q&A, 패턴 분석)
- AI 어시스턴트 페이지: 채팅형 Q&A, 시맨틱 검색, 패턴 분석, 분류 테스트
- 권한 시스템에 ai_assistant 페이지 등록 (기본 비활성)
- 기존 페이지에 AI 기능 통합 (대시보드, 수신함, 관리함)
- docker-compose, gateway, nginx 설정 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-06 09:38:30 +09:00
parent d385ce7ac1
commit b3012b8320
44 changed files with 2914 additions and 53 deletions

View File

@@ -52,6 +52,7 @@ DEFAULT_PAGES = {
'issues_dashboard': {'title': '현황판', 'default_access': True},
'reports': {'title': '보고서', 'default_access': False},
'reports_daily': {'title': '일일보고서', 'default_access': False},
'ai_assistant': {'title': 'AI 어시스턴트', 'default_access': False},
}

View File

@@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 어시스턴트</title>
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=20260306">
<link rel="stylesheet" href="/static/css/ai-assistant.css?v=20260306">
</head>
<body>
<!-- 로딩 스크린 -->
<div id="loadingScreen" class="fixed inset-0 bg-white z-50 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600 mx-auto mb-4"></div>
<p class="text-gray-600">AI 어시스턴트를 불러오는 중...</p>
</div>
</div>
<!-- 메인 콘텐츠 -->
<div id="mainContent" class="min-h-screen">
<!-- 공통 헤더 -->
<div id="commonHeader"></div>
<main class="container mx-auto px-4 py-8 content-fade-in" style="padding-top: 72px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-robot text-purple-500 mr-3"></i>
AI 어시스턴트
</h1>
<p class="text-gray-600 mt-1">AI 기반 부적합 분석, 검색, 질의응답을 한곳에서 사용하세요</p>
</div>
</div>
</div>
<!-- 1. 상태 카드 (3열 그리드) -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-purple-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">AI 서비스</p>
<p class="text-lg font-bold mt-1" id="aiStatusText">확인 중...</p>
</div>
<div id="aiStatusIcon" class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<i class="fas fa-spinner fa-spin text-gray-400"></i>
</div>
</div>
</div>
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-indigo-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">임베딩 데이터</p>
<p class="text-lg font-bold mt-1" id="aiEmbeddingCount">-</p>
</div>
<div class="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
<i class="fas fa-database text-indigo-500"></i>
</div>
</div>
</div>
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-blue-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">AI 모델</p>
<p class="text-lg font-bold mt-1" id="aiModelName">-</p>
</div>
<div class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
<i class="fas fa-brain text-blue-500"></i>
</div>
</div>
</div>
</div>
<!-- 2. AI Q&A (메인 — 채팅형) -->
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
<i class="fas fa-comments text-purple-500 mr-2"></i>
<h2 class="text-lg font-semibold text-gray-800">AI Q&A</h2>
<span class="ml-2 text-xs bg-purple-100 text-purple-600 px-2 py-0.5 rounded-full">과거 사례 기반</span>
</div>
<button onclick="clearChat()" class="text-sm text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-trash-alt mr-1"></i>대화 초기화
</button>
</div>
<!-- 채팅 히스토리 -->
<div id="chatContainer" class="chat-container bg-gray-50 rounded-lg p-4 mb-4 min-h-[200px]">
<div class="text-center text-gray-400 text-sm py-8" id="chatPlaceholder">
<i class="fas fa-robot text-4xl mb-3 text-gray-300"></i>
<p>부적합 관련 질문을 입력하세요.</p>
<p class="text-xs mt-1">과거 사례를 분석하여 답변합니다.</p>
</div>
</div>
<!-- 빠른 질문 템플릿 -->
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="setQuickQuestion('최근 가장 많이 발생하는 부적합 유형은?')"
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
<i class="fas fa-chart-pie mr-1 text-purple-400"></i>많이 발생하는 유형
</button>
<button onclick="setQuickQuestion('용접 불량의 주요 원인과 해결방법은?')"
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
<i class="fas fa-fire mr-1 text-orange-400"></i>용접 불량 원인
</button>
<button onclick="setQuickQuestion('자재 관련 부적합을 줄이려면 어떻게 해야 하나요?')"
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
<i class="fas fa-box mr-1 text-blue-400"></i>자재 부적합 개선
</button>
<button onclick="setQuickQuestion('반복적으로 발생하는 부적합 패턴이 있나요?')"
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
<i class="fas fa-redo mr-1 text-red-400"></i>반복 패턴 분석
</button>
</div>
<!-- 입력 영역 -->
<div class="flex flex-col md:flex-row gap-2">
<div class="flex-1">
<textarea id="qaQuestion" rows="3"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none text-sm"
placeholder="질문을 입력하세요... (Ctrl+Enter로 전송)"
onkeydown="if(event.ctrlKey && event.key==='Enter') submitQuestion()"></textarea>
</div>
<div class="flex flex-col gap-2 md:w-48">
<select id="qaProjectFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500">
<option value="">전체 프로젝트</option>
</select>
<button onclick="submitQuestion()"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
<i class="fas fa-paper-plane mr-1"></i>질문하기
</button>
</div>
</div>
</div>
<!-- 3. 시맨틱 검색 -->
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center mb-4">
<i class="fas fa-search text-indigo-500 mr-2"></i>
<h2 class="text-lg font-semibold text-gray-800">시맨틱 검색</h2>
<span class="ml-2 text-xs bg-indigo-100 text-indigo-600 px-2 py-0.5 rounded-full">유사 부적합 찾기</span>
</div>
<div class="flex flex-col md:flex-row gap-2 mb-4">
<input type="text" id="searchQuery"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm"
placeholder="부적합 내용을 자연어로 검색하세요... (예: 볼트 누락, 용접 불량)"
onkeydown="if(event.key==='Enter') executeSemanticSearch()">
<select id="searchProjectFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-40">
<option value="">전체 프로젝트</option>
</select>
<select id="searchCategoryFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-40">
<option value="">전체 카테고리</option>
<option value="civil">토목</option>
<option value="architecture">건축</option>
<option value="mechanical">기계</option>
<option value="electrical">전기</option>
<option value="piping">배관</option>
<option value="instrument">계장</option>
<option value="painting">도장</option>
<option value="insulation">보온</option>
<option value="fireproof">내화</option>
<option value="other">기타</option>
</select>
<select id="searchResultCount" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-28">
<option value="5">5건</option>
<option value="10" selected>10건</option>
<option value="20">20건</option>
</select>
<button onclick="executeSemanticSearch()"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm whitespace-nowrap">
<i class="fas fa-search mr-1"></i>검색
</button>
</div>
<div id="searchLoading" class="hidden text-center py-4">
<i class="fas fa-spinner fa-spin text-indigo-500 mr-1"></i>
<span class="text-sm text-gray-500">AI 검색 중...</span>
</div>
<div id="searchResults" class="space-y-2">
<!-- 검색 결과 -->
</div>
</div>
<!-- 4. 패턴 분석 -->
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center mb-4">
<i class="fas fa-chart-bar text-green-500 mr-2"></i>
<h2 class="text-lg font-semibold text-gray-800">패턴 분석</h2>
<span class="ml-2 text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded-full">부적합 패턴 파악</span>
</div>
<div class="flex flex-col md:flex-row gap-2 mb-4">
<textarea id="patternInput" rows="2"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none text-sm"
placeholder="분석할 부적합 내용을 입력하세요... (예: 배관 용접부 결함)"></textarea>
<button onclick="executePatternAnalysis()"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm whitespace-nowrap self-end">
<i class="fas fa-chart-bar mr-1"></i>패턴 분석
</button>
</div>
<div id="patternLoading" class="hidden text-center py-4">
<i class="fas fa-spinner fa-spin text-green-500 mr-1"></i>
<span class="text-sm text-gray-500">패턴 분석 중...</span>
</div>
<div id="patternResults" class="hidden">
<!-- 패턴 분석 결과 -->
</div>
</div>
<!-- 5. AI 분류 테스트 -->
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center mb-4">
<i class="fas fa-tags text-amber-500 mr-2"></i>
<h2 class="text-lg font-semibold text-gray-800">AI 분류 테스트</h2>
<span class="ml-2 text-xs bg-amber-100 text-amber-600 px-2 py-0.5 rounded-full">기본 vs RAG 비교</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">부적합 설명</label>
<textarea id="classifyDescription" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 resize-none text-sm"
placeholder="부적합 설명을 입력하세요..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상세 내용 (선택)</label>
<textarea id="classifyDetail" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 resize-none text-sm"
placeholder="상세 내용을 입력하세요..."></textarea>
</div>
</div>
<div class="flex gap-2 mb-4">
<button onclick="executeClassification(false)"
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
<i class="fas fa-tag mr-1"></i>기본 분류
</button>
<button onclick="executeClassification(true)"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm">
<i class="fas fa-tags mr-1"></i>RAG 분류
</button>
</div>
<div id="classifyLoading" class="hidden text-center py-4">
<i class="fas fa-spinner fa-spin text-amber-500 mr-1"></i>
<span class="text-sm text-gray-500">AI 분류 중...</span>
</div>
<div id="classifyResults" class="hidden">
<!-- 분류 결과 -->
</div>
</div>
</main>
</div>
<!-- AI 이슈 상세 모달 -->
<div id="aiIssueModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
<div class="sticky top-0 bg-white border-b px-5 py-3 flex justify-between items-center rounded-t-xl">
<h3 id="aiIssueModalTitle" class="font-bold text-gray-800"></h3>
<button onclick="document.getElementById('aiIssueModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div id="aiIssueModalBody" class="p-5 text-sm text-gray-700 space-y-3"></div>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/core/permissions.js?v=20260306"></script>
<script src="/static/js/components/common-header.js?v=20260306"></script>
<script src="/static/js/core/page-manager.js?v=20260306"></script>
<script src="/static/js/core/auth-manager.js?v=20260306"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260306"></script>
<script src="/static/js/utils/toast.js?v=20260306"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260306"></script>
<script src="/static/js/api.js?v=20260306"></script>
<script src="/static/js/pages/ai-assistant.js?v=20260306"></script>
</body>
</html>

View File

@@ -115,6 +115,57 @@
</div>
</div>
<!-- AI 시맨틱 검색 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center mb-3">
<i class="fas fa-robot text-purple-500 mr-2"></i>
<h3 class="text-sm font-semibold text-gray-700">AI 유사 부적합 검색</h3>
</div>
<div class="flex space-x-2">
<input type="text" id="aiSearchQuery"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="부적합 내용을 자연어로 검색하세요... (예: 볼트 누락, 용접 불량)"
onkeydown="if(event.key==='Enter') aiSemanticSearch()">
<button onclick="aiSemanticSearch()"
class="px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors whitespace-nowrap">
<i class="fas fa-search mr-1"></i>AI 검색
</button>
</div>
<div id="aiSearchLoading" class="hidden mt-3 text-center">
<i class="fas fa-spinner fa-spin text-purple-500 mr-1"></i>
<span class="text-sm text-gray-500">AI 검색 중...</span>
</div>
<div id="aiSearchResults" class="hidden mt-3 space-y-2">
<!-- 검색 결과 -->
</div>
<!-- RAG Q&A -->
<div class="mt-4 pt-4 border-t border-gray-200">
<div class="flex items-center mb-2">
<i class="fas fa-comments text-indigo-500 mr-2"></i>
<h4 class="text-sm font-semibold text-gray-700">AI Q&A (과거 사례 기반)</h4>
</div>
<div class="flex space-x-2">
<input type="text" id="aiQaQuestion"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="질문하세요... (예: 최근 자재 누락이 왜 많아?, 용접 불량 해결방법은?)"
onkeydown="if(event.key==='Enter') aiAskQuestion()">
<button onclick="aiAskQuestion()"
class="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors whitespace-nowrap">
<i class="fas fa-paper-plane mr-1"></i>질문
</button>
</div>
<div id="aiQaLoading" class="hidden mt-3 text-center">
<i class="fas fa-spinner fa-spin text-indigo-500 mr-1"></i>
<span class="text-sm text-gray-500">과거 사례 분석 중...</span>
</div>
<div id="aiQaResult" class="hidden mt-3 bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<div id="aiQaAnswer" class="text-sm text-gray-700 whitespace-pre-line"></div>
<div id="aiQaSources" class="mt-2 text-xs text-indigo-500"></div>
</div>
</div>
</div>
<!-- 프로젝트 선택 및 필터 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
@@ -549,15 +600,29 @@
</div>
</div>
<!-- AI 이슈 상세 모달 -->
<div id="aiIssueModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
<div class="sticky top-0 bg-white border-b px-5 py-3 flex justify-between items-center rounded-t-xl">
<h3 id="aiIssueModalTitle" class="font-bold text-gray-800"></h3>
<button onclick="document.getElementById('aiIssueModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div id="aiIssueModalBody" class="p-5 text-sm text-gray-700 space-y-3"></div>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/core/permissions.js?v=20260213"></script>
<script src="/static/js/components/common-header.js?v=20260213"></script>
<script src="/static/js/core/page-manager.js?v=20260213"></script>
<script src="/static/js/core/auth-manager.js?v=20260213"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
<script src="/static/js/utils/toast.js?v=20260213"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
<script src="/static/js/pages/issues-dashboard.js?v=20260213"></script>
<script src="/static/js/core/permissions.js?v=20260306"></script>
<script src="/static/js/components/common-header.js?v=20260306"></script>
<script src="/static/js/core/page-manager.js?v=20260306"></script>
<script src="/static/js/core/auth-manager.js?v=20260306"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260306"></script>
<script src="/static/js/utils/photo-modal.js?v=20260306"></script>
<script src="/static/js/utils/toast.js?v=20260306"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260306"></script>
<script src="/static/js/api.js?v=20260306"></script>
<script src="/static/js/pages/issues-dashboard.js?v=20260306"></script>
</body>
</html>

View File

@@ -204,6 +204,26 @@
</div>
</div>
<!-- AI 분류 추천 -->
<div class="bg-purple-50 border border-purple-200 rounded-lg p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-purple-700">
<i class="fas fa-robot mr-1"></i>AI 분류 추천
</span>
<button id="aiClassifyBtn" onclick="aiClassifyCurrentIssue()"
class="px-3 py-1 bg-purple-500 text-white text-xs rounded-lg hover:bg-purple-600 transition-colors">
<i class="fas fa-magic mr-1"></i>AI 분석
</button>
</div>
<div id="aiClassifyLoading" class="hidden mt-2 text-center">
<i class="fas fa-spinner fa-spin text-purple-500 mr-1"></i>
<span class="text-xs text-purple-600">AI 분석 중...</span>
</div>
<div id="aiClassifyResult" class="hidden mt-2 text-sm text-purple-800 space-y-1">
<!-- AI 결과가 여기에 표시됩니다 -->
</div>
</div>
<!-- 수정 폼 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -350,13 +370,14 @@
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=20260213"></script>
<script src="/static/js/core/permissions.js?v=20260213"></script>
<script src="/static/js/components/common-header.js?v=20260213"></script>
<script src="/static/js/core/page-manager.js?v=20260213"></script>
<script src="/static/js/components/mobile-calendar.js?v=20260213"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
<script src="/static/js/utils/toast.js?v=20260213"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
<script src="/static/js/pages/issues-inbox.js?v=20260213"></script>
<script src="/static/js/components/common-header.js?v=20260306"></script>
<script src="/static/js/core/page-manager.js?v=20260306"></script>
<script src="/static/js/components/mobile-calendar.js?v=20260306"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260306"></script>
<script src="/static/js/utils/photo-modal.js?v=20260306"></script>
<script src="/static/js/utils/toast.js?v=20260306"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260306"></script>
<script src="/static/js/api.js?v=20260306"></script>
<script src="/static/js/pages/issues-inbox.js?v=20260306"></script>
</body>
</html>

View File

@@ -161,6 +161,44 @@
<!-- 동적으로 생성될 내용 -->
</div>
<!-- AI 유사 부적합 패널 -->
<div id="aiSimilarPanel" class="mt-6 border-t pt-6 hidden">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700">
<i class="fas fa-robot text-purple-500 mr-2"></i>AI 유사 부적합
</h3>
<button id="aiSimilarRefresh" onclick="loadSimilarIssues()" class="text-xs text-purple-500 hover:text-purple-700">
<i class="fas fa-sync-alt mr-1"></i>검색
</button>
</div>
<div id="aiSimilarLoading" class="hidden text-center py-4">
<i class="fas fa-spinner fa-spin text-purple-500 mr-2"></i>
<span class="text-sm text-gray-500">유사 이슈 검색 중...</span>
</div>
<div id="aiSimilarResults" class="space-y-2">
<!-- 유사 이슈 목록 -->
</div>
<div id="aiSimilarEmpty" class="hidden text-center py-3">
<p class="text-sm text-gray-400">유사한 부적합이 없습니다</p>
</div>
<!-- RAG 해결방안 제안 -->
<div class="mt-4 pt-3 border-t border-purple-100">
<button id="aiSuggestSolutionBtn" onclick="aiSuggestSolution()"
class="w-full px-3 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white text-sm rounded-lg hover:from-purple-600 hover:to-indigo-600 transition-all">
<i class="fas fa-lightbulb mr-2"></i>AI 해결방안 제안 (과거 사례 기반)
</button>
<div id="aiSuggestLoading" class="hidden mt-2 text-center py-3">
<i class="fas fa-spinner fa-spin text-purple-500 mr-1"></i>
<span class="text-xs text-gray-500">과거 사례 분석 중...</span>
</div>
<div id="aiSuggestResult" class="hidden mt-2 bg-indigo-50 border border-indigo-200 rounded-lg p-3">
<div id="aiSuggestContent" class="text-sm text-gray-700 whitespace-pre-line"></div>
<div id="aiSuggestSources" class="mt-2 text-xs text-indigo-500"></div>
</div>
</div>
</div>
<!-- 모달 푸터 -->
<div class="flex justify-end space-x-3 mt-6 pt-6 border-t">
<button onclick="closeIssueDetailModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
@@ -299,13 +337,14 @@
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=20260213"></script>
<script src="/static/js/core/permissions.js?v=20260213"></script>
<script src="/static/js/components/common-header.js?v=20260213"></script>
<script src="/static/js/core/page-manager.js?v=20260213"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
<script src="/static/js/utils/toast.js?v=20260213"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
<script src="/static/js/pages/issues-management.js?v=20260213"></script>
<script src="/static/js/core/permissions.js?v=20260306"></script>
<script src="/static/js/components/common-header.js?v=20260306"></script>
<script src="/static/js/core/page-manager.js?v=20260306"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260306"></script>
<script src="/static/js/utils/photo-modal.js?v=20260306"></script>
<script src="/static/js/utils/toast.js?v=20260306"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260306"></script>
<script src="/static/js/api.js?v=20260306"></script>
<script src="/static/js/pages/issues-management.js?v=20260306"></script>
</body>
</html>

View File

@@ -48,6 +48,18 @@ server {
proxy_buffering off;
}
# AI API 프록시
location /ai-api/ {
proxy_pass http://ai-service:8000/api/ai/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# 모바일 전용 페이지
location /m/ {
alias /usr/share/nginx/html/m/;

View File

@@ -0,0 +1,162 @@
/* ai-assistant.css — AI 어시스턴트 페이지 전용 스타일 */
/* 페이드인 애니메이션 */
.fade-in { opacity: 0; animation: fadeIn 0.5s ease-in forwards; }
@keyframes fadeIn { to { opacity: 1; } }
.header-fade-in { opacity: 0; animation: headerFadeIn 0.6s ease-out forwards; }
@keyframes headerFadeIn { to { opacity: 1; transform: translateY(0); } from { transform: translateY(-10px); } }
.content-fade-in { opacity: 0; animation: contentFadeIn 0.7s ease-out 0.2s forwards; }
@keyframes contentFadeIn { to { opacity: 1; transform: translateY(0); } from { transform: translateY(20px); } }
/* 채팅 컨테이너 */
.chat-container {
max-height: 500px;
overflow-y: auto;
scroll-behavior: smooth;
}
.chat-container::-webkit-scrollbar {
width: 6px;
}
.chat-container::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.chat-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.chat-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 채팅 말풍선 */
.chat-bubble {
max-width: 85%;
animation: bubbleIn 0.3s ease-out;
}
@keyframes bubbleIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-bubble-user {
background: #7c3aed;
color: white;
border-radius: 18px 18px 4px 18px;
padding: 10px 16px;
margin-left: auto;
}
.chat-bubble-ai {
background: #f1f5f9;
color: #1e293b;
border-radius: 18px 18px 18px 4px;
padding: 10px 16px;
}
.chat-bubble-ai .source-link {
color: #7c3aed;
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
}
.chat-bubble-ai .source-link:hover {
color: #6d28d9;
text-decoration-style: solid;
}
/* 로딩 도트 애니메이션 */
.typing-indicator {
display: flex;
gap: 4px;
padding: 12px 16px;
}
.typing-dot {
width: 8px;
height: 8px;
background: #94a3b8;
border-radius: 50%;
animation: typingBounce 1.4s infinite ease-in-out;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* 빠른 질문 버튼 */
.quick-question-btn {
transition: all 0.2s ease;
border: 1px solid #e2e8f0;
}
.quick-question-btn:hover {
border-color: #7c3aed;
background: #faf5ff;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.12);
}
/* 상태 카드 */
.status-card {
transition: all 0.2s ease;
}
.status-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 섹션 카드 */
.section-card {
transition: all 0.2s ease;
}
/* 결과 아이템 */
.result-item {
transition: all 0.15s ease;
cursor: pointer;
}
.result-item:hover {
background: #f8fafc;
border-color: #7c3aed;
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.chat-container {
max-height: 400px;
}
.chat-bubble {
max-width: 92%;
}
button, a, [onclick], select {
min-height: 44px;
min-width: 44px;
}
body {
padding-bottom: calc(64px + env(safe-area-inset-bottom)) !important;
}
}

View File

@@ -308,6 +308,161 @@ function checkPageAccess(pageName) {
return user;
}
// AI API
const AiAPI = {
getSimilarIssues: async (issueId, limit = 5) => {
try {
const res = await fetch(`/ai-api/similar/${issueId}?n_results=${limit}`, {
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
});
if (!res.ok) return { available: false, results: [] };
return await res.json();
} catch (e) {
console.warn('AI 유사 검색 실패:', e);
return { available: false, results: [] };
}
},
searchSimilar: async (query, limit = 5, filters = {}) => {
try {
const res = await fetch('/ai-api/similar/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TokenManager.getToken()}`
},
body: JSON.stringify({ query, n_results: limit, ...filters })
});
if (!res.ok) return { available: false, results: [] };
return await res.json();
} catch (e) {
console.warn('AI 검색 실패:', e);
return { available: false, results: [] };
}
},
classifyIssue: async (description, detailNotes = '') => {
try {
const res = await fetch('/ai-api/classify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TokenManager.getToken()}`
},
body: JSON.stringify({ description, detail_notes: detailNotes })
});
if (!res.ok) return { available: false };
return await res.json();
} catch (e) {
console.warn('AI 분류 실패:', e);
return { available: false };
}
},
generateDailyReport: async (date, projectId) => {
try {
const res = await fetch('/ai-api/report/daily', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TokenManager.getToken()}`
},
body: JSON.stringify({ date, project_id: projectId })
});
if (!res.ok) return { available: false };
return await res.json();
} catch (e) {
console.warn('AI 보고서 생성 실패:', e);
return { available: false };
}
},
syncEmbeddings: async () => {
try {
const res = await fetch('/ai-api/embeddings/sync', {
method: 'POST',
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
});
if (!res.ok) return { status: 'error' };
return await res.json();
} catch (e) {
return { status: 'error' };
}
},
checkHealth: async () => {
try {
const res = await fetch('/ai-api/health');
return await res.json();
} catch (e) {
return { status: 'disconnected' };
}
},
// RAG: 해결방안 제안
suggestSolution: async (issueId) => {
try {
const res = await fetch(`/ai-api/rag/suggest-solution/${issueId}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
});
if (!res.ok) return { available: false };
return await res.json();
} catch (e) {
console.warn('AI 해결방안 제안 실패:', e);
return { available: false };
}
},
// RAG: 자연어 질의
askQuestion: async (question, projectId = null) => {
try {
const res = await fetch('/ai-api/rag/ask', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TokenManager.getToken()}`
},
body: JSON.stringify({ question, project_id: projectId })
});
if (!res.ok) return { available: false };
return await res.json();
} catch (e) {
console.warn('AI 질의 실패:', e);
return { available: false };
}
},
// RAG: 패턴 분석
analyzePattern: async (description) => {
try {
const res = await fetch('/ai-api/rag/pattern', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TokenManager.getToken()}`
},
body: JSON.stringify({ description })
});
if (!res.ok) return { available: false };
return await res.json();
} catch (e) {
console.warn('AI 패턴 분석 실패:', e);
return { available: false };
}
},
// RAG: 강화 분류 (과거 사례 참고)
classifyWithRAG: async (description, detailNotes = '') => {
try {
const res = await fetch('/ai-api/rag/classify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TokenManager.getToken()}`
},
body: JSON.stringify({ description, detail_notes: detailNotes })
});
if (!res.ok) return { available: false };
return await res.json();
} catch (e) {
console.warn('AI RAG 분류 실패:', e);
return { available: false };
}
}
};
// 프로젝트 API
const ProjectsAPI = {
getAll: (activeOnly = false) => {

View File

@@ -86,6 +86,15 @@ class CommonHeader {
}
]
},
{
id: 'ai_assistant',
title: 'AI 어시스턴트',
icon: 'fas fa-robot',
url: '/ai-assistant.html',
pageName: 'ai_assistant',
color: 'text-purple-600',
bgColor: 'text-purple-600 hover:bg-purple-50'
},
];
}

View File

@@ -20,7 +20,8 @@ class PagePermissionManager {
'issues_inbox': { title: '수신함', defaultAccess: true },
'issues_management': { title: '관리함', defaultAccess: false },
'issues_archive': { title: '폐기함', defaultAccess: false },
'reports': { title: '보고서', defaultAccess: false }
'reports': { title: '보고서', defaultAccess: false },
'ai_assistant': { title: 'AI 어시스턴트', defaultAccess: false }
};
}

View File

@@ -0,0 +1,584 @@
/**
* ai-assistant.js — AI 어시스턴트 페이지 스크립트
*/
let currentUser = null;
let projects = [];
let chatHistory = [];
// 애니메이션 함수들
function animateHeaderAppearance() {
const header = document.getElementById('commonHeader');
if (header) {
header.classList.add('header-fade-in');
}
}
// 페이지 초기화
async function initializeAiAssistant() {
try {
currentUser = await window.authManager.checkAuth();
if (!currentUser) {
document.getElementById('loadingScreen').style.display = 'none';
window.location.href = '/';
return;
}
window.pagePermissionManager.setUser(currentUser);
await window.pagePermissionManager.loadPagePermissions();
if (!window.pagePermissionManager.canAccessPage('ai_assistant')) {
alert('AI 어시스턴트 접근 권한이 없습니다.');
window.location.href = '/';
return;
}
if (window.commonHeader) {
await window.commonHeader.init(currentUser, 'ai_assistant');
setTimeout(() => animateHeaderAppearance(), 100);
}
await loadProjects();
checkAiHealth();
document.getElementById('loadingScreen').style.display = 'none';
} catch (error) {
console.error('AI 어시스턴트 초기화 실패:', error);
alert('페이지를 불러오는데 실패했습니다.');
document.getElementById('loadingScreen').style.display = 'none';
}
}
// AuthManager 대기 후 초기화
document.addEventListener('DOMContentLoaded', () => {
const checkAuthManager = () => {
if (window.authManager) {
initializeAiAssistant();
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
// 프로젝트 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
projects = await response.json();
updateProjectFilters();
}
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
}
function updateProjectFilters() {
const selects = ['qaProjectFilter', 'searchProjectFilter'];
selects.forEach(id => {
const select = document.getElementById(id);
if (!select) return;
// 기존 옵션 유지 (첫 번째 "전체 프로젝트")
while (select.options.length > 1) select.remove(1);
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.project_name;
select.appendChild(opt);
});
});
}
// ─── AI 상태 체크 ───────────────────────────────────────
async function checkAiHealth() {
try {
const health = await AiAPI.checkHealth();
const statusText = document.getElementById('aiStatusText');
const statusIcon = document.getElementById('aiStatusIcon');
const embeddingCount = document.getElementById('aiEmbeddingCount');
const modelName = document.getElementById('aiModelName');
if (health.status === 'healthy' || health.status === 'ok') {
statusText.textContent = '연결됨';
statusText.classList.add('text-green-600');
statusIcon.innerHTML = '<i class="fas fa-check-circle text-green-500 text-xl"></i>';
statusIcon.className = 'w-10 h-10 rounded-full bg-green-50 flex items-center justify-center';
} else {
statusText.textContent = '연결 안됨';
statusText.classList.add('text-red-500');
statusIcon.innerHTML = '<i class="fas fa-times-circle text-red-500 text-xl"></i>';
statusIcon.className = 'w-10 h-10 rounded-full bg-red-50 flex items-center justify-center';
}
const embCount = health.embedding_count
?? health.total_embeddings
?? health.embeddings?.total_documents;
if (embCount !== undefined) {
embeddingCount.textContent = embCount.toLocaleString() + '건';
}
const model = health.model
|| health.llm_model
|| (health.ollama?.models?.[0]);
if (model) {
modelName.textContent = model;
}
} catch (error) {
console.error('AI 상태 체크 실패:', error);
document.getElementById('aiStatusText').textContent = '오류';
}
}
// ─── Q&A 채팅 ───────────────────────────────────────────
function setQuickQuestion(text) {
document.getElementById('qaQuestion').value = text;
document.getElementById('qaQuestion').focus();
}
async function submitQuestion() {
const input = document.getElementById('qaQuestion');
const question = input.value.trim();
if (!question) return;
const projectId = document.getElementById('qaProjectFilter').value || null;
// 플레이스홀더 제거
const placeholder = document.getElementById('chatPlaceholder');
if (placeholder) placeholder.remove();
// 사용자 메시지 추가
appendChatMessage('user', question);
input.value = '';
chatHistory.push({ role: 'user', content: question });
// 로딩 표시
appendChatLoading();
try {
const result = await AiAPI.askQuestion(question, projectId);
removeChatLoading();
if (result.available === false) {
appendChatMessage('ai', 'AI 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.');
return;
}
const answer = result.answer || result.response || '답변을 생성할 수 없습니다.';
const sources = result.sources || result.related_issues || [];
appendChatMessage('ai', answer, sources);
chatHistory.push({ role: 'ai', content: answer });
} catch (error) {
removeChatLoading();
appendChatMessage('ai', '오류가 발생했습니다: ' + error.message);
}
}
function appendChatMessage(role, content, sources) {
const container = document.getElementById('chatContainer');
const wrapper = document.createElement('div');
wrapper.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'} mb-3`;
const bubble = document.createElement('div');
bubble.className = `chat-bubble ${role === 'user' ? 'chat-bubble-user' : 'chat-bubble-ai'}`;
// 내용 렌더링
const contentDiv = document.createElement('div');
contentDiv.className = 'text-sm whitespace-pre-line';
contentDiv.textContent = content;
bubble.appendChild(contentDiv);
// AI 답변 참고 사례
if (role === 'ai' && sources && sources.length > 0) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'mt-2 pt-2 border-t border-gray-200';
const sourcesTitle = document.createElement('p');
sourcesTitle.className = 'text-xs text-gray-500 mb-1';
sourcesTitle.textContent = '참고 사례:';
sourcesDiv.appendChild(sourcesTitle);
sources.forEach(source => {
const issueId = source.issue_id || source.id;
const desc = source.description || source.title || `이슈 #${issueId}`;
const similarity = source.similarity ? ` (${(source.similarity * 100).toFixed(0)}%)` : '';
const link = document.createElement('span');
link.className = 'source-link text-xs block';
link.textContent = `#${issueId} ${desc}${similarity}`;
link.onclick = () => showAiIssueDetail(issueId);
sourcesDiv.appendChild(link);
});
bubble.appendChild(sourcesDiv);
}
wrapper.appendChild(bubble);
container.appendChild(wrapper);
container.scrollTop = container.scrollHeight;
}
function appendChatLoading() {
const container = document.getElementById('chatContainer');
const wrapper = document.createElement('div');
wrapper.className = 'flex justify-start mb-3';
wrapper.id = 'chatLoadingBubble';
wrapper.innerHTML = `
<div class="chat-bubble chat-bubble-ai">
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
`;
container.appendChild(wrapper);
container.scrollTop = container.scrollHeight;
}
function removeChatLoading() {
const el = document.getElementById('chatLoadingBubble');
if (el) el.remove();
}
function clearChat() {
const container = document.getElementById('chatContainer');
container.innerHTML = `
<div class="text-center text-gray-400 text-sm py-8" id="chatPlaceholder">
<i class="fas fa-robot text-4xl mb-3 text-gray-300"></i>
<p>부적합 관련 질문을 입력하세요.</p>
<p class="text-xs mt-1">과거 사례를 분석하여 답변합니다.</p>
</div>
`;
chatHistory = [];
}
// ─── 시맨틱 검색 ────────────────────────────────────────
async function executeSemanticSearch() {
const query = document.getElementById('searchQuery').value.trim();
if (!query) return;
const projectId = document.getElementById('searchProjectFilter').value || undefined;
const category = document.getElementById('searchCategoryFilter').value || undefined;
const limit = parseInt(document.getElementById('searchResultCount').value);
const loading = document.getElementById('searchLoading');
const resultsDiv = document.getElementById('searchResults');
loading.classList.remove('hidden');
resultsDiv.innerHTML = '';
try {
const filters = {};
if (projectId) filters.project_id = parseInt(projectId);
if (category) filters.category = category;
const result = await AiAPI.searchSimilar(query, limit, filters);
loading.classList.add('hidden');
if (!result.results || result.results.length === 0) {
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">검색 결과가 없습니다.</p>';
return;
}
result.results.forEach((item, idx) => {
const issueId = item.issue_id || item.id;
const desc = item.description || '';
const similarity = item.similarity ? (item.similarity * 100).toFixed(1) : '-';
const category = item.category || '';
const status = item.status || item.review_status || '';
const card = document.createElement('div');
card.className = 'result-item border border-gray-200 rounded-lg p-3 flex items-start gap-3';
card.onclick = () => showAiIssueDetail(issueId);
card.innerHTML = `
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center text-xs font-bold text-indigo-600">
${idx + 1}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-mono text-gray-400">#${issueId}</span>
${category ? `<span class="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">${category}</span>` : ''}
${status ? `<span class="text-xs bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">${status}</span>` : ''}
</div>
<p class="text-sm text-gray-700 truncate">${desc}</p>
</div>
<div class="flex-shrink-0 text-right">
<span class="text-sm font-bold text-indigo-600">${similarity}%</span>
<p class="text-xs text-gray-400">유사도</p>
</div>
`;
resultsDiv.appendChild(card);
});
} catch (error) {
loading.classList.add('hidden');
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">검색 중 오류가 발생했습니다.</p>`;
}
}
// ─── 패턴 분석 ──────────────────────────────────────────
async function executePatternAnalysis() {
const input = document.getElementById('patternInput').value.trim();
if (!input) return;
const loading = document.getElementById('patternLoading');
const resultsDiv = document.getElementById('patternResults');
loading.classList.remove('hidden');
resultsDiv.classList.add('hidden');
try {
const result = await AiAPI.analyzePattern(input);
loading.classList.add('hidden');
resultsDiv.classList.remove('hidden');
if (result.available === false) {
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">AI 서비스에 연결할 수 없습니다.</p>';
return;
}
let html = '';
// 분석 결과
const analysis = result.analysis || result.pattern || result.answer || '';
if (analysis) {
html += `
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-3">
<h4 class="text-sm font-semibold text-green-800 mb-2">
<i class="fas fa-chart-bar mr-1"></i>분석 결과
</h4>
<div class="text-sm text-gray-700 whitespace-pre-line">${analysis}</div>
</div>
`;
}
// 관련 이슈
const relatedIssues = result.related_issues || result.sources || [];
if (relatedIssues.length > 0) {
html += `
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-link mr-1"></i>관련 이슈 (${relatedIssues.length}건)
</h4>
<div class="space-y-1">
${relatedIssues.map(issue => {
const id = issue.issue_id || issue.id;
const desc = issue.description || '';
return `<div class="result-item text-sm p-2 rounded border border-gray-100" onclick="showAiIssueDetail(${id})">
<span class="font-mono text-gray-400 text-xs">#${id}</span>
<span class="text-gray-700">${desc}</span>
</div>`;
}).join('')}
</div>
</div>
`;
}
resultsDiv.innerHTML = html || '<p class="text-sm text-gray-500 text-center py-4">분석 결과가 없습니다.</p>';
} catch (error) {
loading.classList.add('hidden');
resultsDiv.classList.remove('hidden');
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">분석 중 오류가 발생했습니다.</p>`;
}
}
// ─── AI 분류 ────────────────────────────────────────────
async function executeClassification(useRAG) {
const description = document.getElementById('classifyDescription').value.trim();
if (!description) {
alert('부적합 설명을 입력해주세요.');
return;
}
const detailNotes = document.getElementById('classifyDetail').value.trim();
const loading = document.getElementById('classifyLoading');
const resultsDiv = document.getElementById('classifyResults');
loading.classList.remove('hidden');
resultsDiv.classList.add('hidden');
try {
const result = useRAG
? await AiAPI.classifyWithRAG(description, detailNotes)
: await AiAPI.classifyIssue(description, detailNotes);
loading.classList.add('hidden');
resultsDiv.classList.remove('hidden');
if (result.available === false) {
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">AI 서비스에 연결할 수 없습니다.</p>';
return;
}
const methodLabel = useRAG ? 'RAG 분류 (과거 사례 참고)' : '기본 분류';
const methodColor = useRAG ? 'purple' : 'amber';
let html = `
<div class="bg-${methodColor}-50 border border-${methodColor}-200 rounded-lg p-4">
<h4 class="text-sm font-semibold text-${methodColor}-800 mb-3">
<i class="fas fa-${useRAG ? 'tags' : 'tag'} mr-1"></i>${methodLabel}
</h4>
<div class="grid grid-cols-2 gap-3">
`;
// 분류 결과 필드 표시
const fields = [
{ key: 'category', label: '카테고리' },
{ key: 'discipline', label: '공종' },
{ key: 'severity', label: '심각도' },
{ key: 'root_cause', label: '근본 원인' },
{ key: 'priority', label: '우선순위' },
{ key: 'suggested_action', label: '권장 조치' }
];
fields.forEach(field => {
const val = result[field.key] || result.classification?.[field.key];
if (val) {
html += `
<div>
<span class="text-xs text-gray-500">${field.label}</span>
<p class="text-sm font-medium text-gray-800">${val}</p>
</div>
`;
}
});
html += '</div>';
// 신뢰도
const confidence = result.confidence || result.classification?.confidence;
if (confidence) {
const pct = (typeof confidence === 'number' && confidence <= 1)
? (confidence * 100).toFixed(0)
: confidence;
html += `
<div class="mt-3 pt-3 border-t border-${methodColor}-200">
<span class="text-xs text-gray-500">신뢰도</span>
<div class="flex items-center gap-2 mt-1">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-${methodColor}-500 h-2 rounded-full" style="width: ${pct}%"></div>
</div>
<span class="text-sm font-bold text-${methodColor}-600">${pct}%</span>
</div>
</div>
`;
}
// RAG 참고 사례
const sources = result.sources || result.related_issues || result.similar_cases || [];
if (sources.length > 0) {
html += `
<div class="mt-3 pt-3 border-t border-${methodColor}-200">
<span class="text-xs text-gray-500">참고 사례</span>
<div class="mt-1 space-y-1">
${sources.map(s => {
const id = s.issue_id || s.id;
const desc = s.description || '';
return `<div class="result-item text-xs p-1.5 rounded border border-gray-100" onclick="showAiIssueDetail(${id})">
<span class="font-mono text-gray-400">#${id}</span> ${desc}
</div>`;
}).join('')}
</div>
</div>
`;
}
html += '</div>';
resultsDiv.innerHTML = html;
} catch (error) {
loading.classList.add('hidden');
resultsDiv.classList.remove('hidden');
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">분류 중 오류가 발생했습니다.</p>`;
}
}
// ─── 이슈 상세 모달 ─────────────────────────────────────
async function showAiIssueDetail(issueId) {
const modal = document.getElementById('aiIssueModal');
const title = document.getElementById('aiIssueModalTitle');
const body = document.getElementById('aiIssueModalBody');
title.textContent = `이슈 #${issueId}`;
body.innerHTML = '<div class="text-center py-8"><i class="fas fa-spinner fa-spin text-purple-500 text-xl"></i><p class="text-sm text-gray-500 mt-2">불러오는 중...</p></div>';
modal.classList.remove('hidden');
try {
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/issues/${issueId}`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) throw new Error('이슈를 불러올 수 없습니다.');
const issue = await response.json();
const statusMap = {
'pending': '대기',
'in_progress': '진행 중',
'completed': '완료',
'rejected': '반려'
};
body.innerHTML = `
<div class="space-y-3">
<div>
<span class="text-xs text-gray-500">프로젝트</span>
<p class="font-medium">${issue.project_name || '-'}</p>
</div>
<div>
<span class="text-xs text-gray-500">설명</span>
<p class="font-medium">${issue.description || '-'}</p>
</div>
${issue.detail_notes ? `<div>
<span class="text-xs text-gray-500">상세 내용</span>
<p class="whitespace-pre-line">${issue.detail_notes}</p>
</div>` : ''}
<div class="grid grid-cols-2 gap-3">
<div>
<span class="text-xs text-gray-500">카테고리</span>
<p>${issue.category || '-'}</p>
</div>
<div>
<span class="text-xs text-gray-500">상태</span>
<p>${statusMap[issue.review_status] || issue.review_status || '-'}</p>
</div>
<div>
<span class="text-xs text-gray-500">위치</span>
<p>${issue.location || '-'}</p>
</div>
<div>
<span class="text-xs text-gray-500">담당자</span>
<p>${issue.assigned_to_name || issue.assignee_name || '-'}</p>
</div>
</div>
${issue.resolution ? `<div>
<span class="text-xs text-gray-500">해결 방안</span>
<p class="whitespace-pre-line">${issue.resolution}</p>
</div>` : ''}
<div>
<span class="text-xs text-gray-500">등록일</span>
<p>${issue.created_at ? new Date(issue.created_at).toLocaleDateString('ko-KR') : '-'}</p>
</div>
</div>
`;
} catch (error) {
body.innerHTML = `<p class="text-sm text-red-500 text-center py-4">이슈를 불러오는데 실패했습니다.</p>`;
}
}
// 엔트리 포인트
function initializeAiAssistantApp() {
console.log('AI 어시스턴트 스크립트 로드 완료');
}
initializeAiAssistantApp();

View File

@@ -1786,8 +1786,130 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// API 스크립트 동적 로드
const script = document.createElement('script');
script.src = '/static/js/api.js?v=20260213';
script.onload = initializeDashboardApp;
document.body.appendChild(script);
// AI 시맨틱 검색
async function aiSemanticSearch() {
const query = document.getElementById('aiSearchQuery')?.value?.trim();
if (!query || typeof AiAPI === 'undefined') return;
const loading = document.getElementById('aiSearchLoading');
const results = document.getElementById('aiSearchResults');
if (loading) loading.classList.remove('hidden');
if (results) { results.classList.add('hidden'); results.innerHTML = ''; }
const data = await AiAPI.searchSimilar(query, 8);
if (loading) loading.classList.add('hidden');
if (!data.available || !data.results || data.results.length === 0) {
results.innerHTML = '<p class="text-sm text-gray-400 text-center py-2">검색 결과가 없습니다</p>';
results.classList.remove('hidden');
return;
}
results.innerHTML = data.results.map(r => {
const meta = r.metadata || {};
const similarity = Math.round((r.similarity || 0) * 100);
const issueId = meta.issue_id || r.id.replace('issue_', '');
const doc = (r.document || '').substring(0, 100);
const cat = meta.category || '';
const status = meta.review_status || '';
return `
<div class="flex items-start space-x-3 bg-gray-50 rounded-lg p-3 hover:bg-purple-50 transition-colors cursor-pointer"
onclick="showAiIssueModal(${issueId})"
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center">
<span class="text-xs font-bold text-purple-700">${similarity}%</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="text-sm font-medium text-gray-800">No.${issueId}</span>
${cat ? `<span class="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700">${cat}</span>` : ''}
${status ? `<span class="text-xs text-gray-400">${status}</span>` : ''}
</div>
<p class="text-xs text-gray-500 truncate">${doc}</p>
</div>
</div>
`;
}).join('');
results.classList.remove('hidden');
}
// RAG Q&A
async function aiAskQuestion() {
const question = document.getElementById('aiQaQuestion')?.value?.trim();
if (!question || typeof AiAPI === 'undefined') return;
const loading = document.getElementById('aiQaLoading');
const result = document.getElementById('aiQaResult');
const answer = document.getElementById('aiQaAnswer');
const sources = document.getElementById('aiQaSources');
if (loading) loading.classList.remove('hidden');
if (result) result.classList.add('hidden');
const projectId = document.getElementById('projectFilter')?.value || null;
const data = await AiAPI.askQuestion(question, projectId ? parseInt(projectId) : null);
if (loading) loading.classList.add('hidden');
if (!data.available) {
if (answer) answer.textContent = 'AI 서비스를 사용할 수 없습니다';
if (result) result.classList.remove('hidden');
return;
}
if (answer) answer.textContent = data.answer || '';
if (sources && data.sources) {
const refs = data.sources.slice(0, 5).map(s =>
`No.${s.id}(${s.similarity}%)`
).join(', ');
sources.textContent = refs ? `참고: ${refs}` : '';
}
if (result) result.classList.remove('hidden');
}
// AI 이슈 상세 모달
async function showAiIssueModal(issueId) {
const modal = document.getElementById('aiIssueModal');
const title = document.getElementById('aiIssueModalTitle');
const body = document.getElementById('aiIssueModalBody');
if (!modal || !body) return;
title.textContent = `부적합 No.${issueId}`;
body.innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin text-purple-500"></i> 로딩 중...</div>';
modal.classList.remove('hidden');
try {
const token = typeof TokenManager !== 'undefined' ? TokenManager.getToken() : null;
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch(`/api/issues/${issueId}`, { headers });
if (!res.ok) throw new Error('fetch failed');
const issue = await res.json();
const categoryText = typeof getCategoryText === 'function' ? getCategoryText(issue.category || issue.final_category) : (issue.category || issue.final_category || '-');
const statusText = typeof getStatusText === 'function' ? getStatusText(issue.review_status) : (issue.review_status || '-');
const deptText = typeof getDepartmentText === 'function' ? getDepartmentText(issue.responsible_department) : (issue.responsible_department || '-');
body.innerHTML = `
<div class="flex flex-wrap gap-2 mb-3">
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-700">${categoryText}</span>
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">${statusText}</span>
<span class="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">${deptText}</span>
${issue.report_date ? `<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-600">${issue.report_date}</span>` : ''}
</div>
${issue.description ? `<div><strong class="text-gray-600">설명:</strong><p class="mt-1 whitespace-pre-line">${issue.description}</p></div>` : ''}
${issue.detail_notes ? `<div><strong class="text-gray-600">상세:</strong><p class="mt-1 whitespace-pre-line">${issue.detail_notes}</p></div>` : ''}
${issue.final_description ? `<div><strong class="text-gray-600">최종 판정:</strong><p class="mt-1 whitespace-pre-line">${issue.final_description}</p></div>` : ''}
${issue.solution ? `<div><strong class="text-gray-600">해결방안:</strong><p class="mt-1 whitespace-pre-line">${issue.solution}</p></div>` : ''}
${issue.cause_detail ? `<div><strong class="text-gray-600">원인:</strong><p class="mt-1 whitespace-pre-line">${issue.cause_detail}</p></div>` : ''}
${issue.management_comment ? `<div><strong class="text-gray-600">관리 의견:</strong><p class="mt-1 whitespace-pre-line">${issue.management_comment}</p></div>` : ''}
<div class="pt-3 border-t text-right">
<a href="/issues-management.html#issue-${issueId}" class="text-xs text-purple-500 hover:underline">관리함에서 보기 →</a>
</div>
`;
} catch (e) {
body.innerHTML = `<p class="text-red-500">이슈를 불러올 수 없습니다</p>
<a href="/issues-management.html#issue-${issueId}" class="text-xs text-purple-500 hover:underline">관리함에서 보기 →</a>`;
}
}
// 초기화
initializeDashboardApp();

View File

@@ -879,14 +879,81 @@ function showError(message) {
alert(message);
}
// API 스크립트 동적 로딩
const script = document.createElement('script');
script.src = '/static/js/api.js?v=20260213';
script.onload = function() {
console.log('API 스크립트 로드 완료 (issues-inbox.html)');
initializeInbox();
};
script.onerror = function() {
console.error('API 스크립트 로드 실패');
};
document.head.appendChild(script);
// AI 분류 추천
async function aiClassifyCurrentIssue() {
if (!currentIssueId || typeof AiAPI === 'undefined') return;
const issue = issues.find(i => i.id === currentIssueId);
if (!issue) return;
const btn = document.getElementById('aiClassifyBtn');
const loading = document.getElementById('aiClassifyLoading');
const result = document.getElementById('aiClassifyResult');
if (btn) btn.disabled = true;
if (loading) loading.classList.remove('hidden');
if (result) result.classList.add('hidden');
// RAG 강화 분류 사용 (과거 사례 참고)
const classifyFn = AiAPI.classifyWithRAG || AiAPI.classifyIssue;
const data = await classifyFn(
issue.description || issue.final_description || '',
issue.detail_notes || ''
);
if (loading) loading.classList.add('hidden');
if (btn) btn.disabled = false;
if (!data.available) {
if (result) {
result.innerHTML = '<p class="text-xs text-red-500">AI 서비스를 사용할 수 없습니다</p>';
result.classList.remove('hidden');
}
return;
}
const categoryMap = {
'material_missing': '자재 누락',
'design_error': '설계 오류',
'incoming_defect': '반입 불량',
'inspection_miss': '검사 누락',
};
const deptMap = {
'production': '생산',
'quality': '품질',
'purchasing': '구매',
'design': '설계',
'sales': '영업',
};
const cat = data.category || '';
const dept = data.responsible_department || '';
const severity = data.severity || '';
const summary = data.summary || '';
const confidence = data.category_confidence ? Math.round(data.category_confidence * 100) : '';
result.innerHTML = `
<div class="space-y-1">
<p><strong>분류:</strong> ${categoryMap[cat] || cat} ${confidence ? `(${confidence}%)` : ''}</p>
<p><strong>부서:</strong> ${deptMap[dept] || dept}</p>
<p><strong>심각도:</strong> ${severity}</p>
${summary ? `<p><strong>요약:</strong> ${summary}</p>` : ''}
<button onclick="applyAiClassification('${cat}')"
class="mt-2 px-3 py-1 bg-purple-600 text-white text-xs rounded hover:bg-purple-700">
<i class="fas fa-check mr-1"></i>적용
</button>
</div>
`;
result.classList.remove('hidden');
}
function applyAiClassification(category) {
const reviewCategory = document.getElementById('reviewCategory');
if (reviewCategory && category) {
reviewCategory.value = category;
}
if (window.showToast) {
window.showToast('AI 추천이 적용되었습니다', 'success');
}
}
// 초기화 (api.js는 HTML에서 로드됨)
initializeInbox();

View File

@@ -930,13 +930,100 @@ async function openIssueDetailModal(issueId) {
// 모달 표시
document.getElementById('issueDetailModal').classList.remove('hidden');
// AI 유사 부적합 자동 로드
const aiPanel = document.getElementById('aiSimilarPanel');
if (aiPanel) {
aiPanel.classList.remove('hidden');
loadSimilarIssues();
}
}
function closeIssueDetailModal() {
document.getElementById('issueDetailModal').classList.add('hidden');
const aiPanel = document.getElementById('aiSimilarPanel');
if (aiPanel) aiPanel.classList.add('hidden');
// RAG 결과 초기화
const suggestResult = document.getElementById('aiSuggestResult');
if (suggestResult) suggestResult.classList.add('hidden');
currentModalIssueId = null;
}
// RAG: AI 해결방안 제안
async function aiSuggestSolution() {
if (!currentModalIssueId || typeof AiAPI === 'undefined') return;
const btn = document.getElementById('aiSuggestSolutionBtn');
const loading = document.getElementById('aiSuggestLoading');
const result = document.getElementById('aiSuggestResult');
const content = document.getElementById('aiSuggestContent');
const sources = document.getElementById('aiSuggestSources');
if (btn) btn.disabled = true;
if (loading) loading.classList.remove('hidden');
if (result) result.classList.add('hidden');
const data = await AiAPI.suggestSolution(currentModalIssueId);
if (loading) loading.classList.add('hidden');
if (btn) btn.disabled = false;
if (!data.available) {
if (content) content.textContent = 'AI 서비스를 사용할 수 없습니다';
if (result) result.classList.remove('hidden');
return;
}
if (content) content.textContent = data.suggestion || '';
if (sources && data.referenced_issues) {
const refs = data.referenced_issues
.filter(r => r.has_solution)
.map(r => `No.${r.id}(${r.similarity}%)`)
.join(', ');
sources.textContent = refs ? `참고 사례: ${refs}` : '';
}
if (result) result.classList.remove('hidden');
}
// AI 유사 부적합 검색
async function loadSimilarIssues() {
if (!currentModalIssueId || typeof AiAPI === 'undefined') return;
const loading = document.getElementById('aiSimilarLoading');
const results = document.getElementById('aiSimilarResults');
const empty = document.getElementById('aiSimilarEmpty');
if (loading) loading.classList.remove('hidden');
if (results) results.innerHTML = '';
if (empty) empty.classList.add('hidden');
const data = await AiAPI.getSimilarIssues(currentModalIssueId, 5);
if (loading) loading.classList.add('hidden');
if (!data.available || !data.results || data.results.length === 0) {
if (empty) empty.classList.remove('hidden');
return;
}
results.innerHTML = data.results.map(r => {
const meta = r.metadata || {};
const similarity = Math.round((r.similarity || 0) * 100);
const issueId = meta.issue_id || r.id.replace('issue_', '');
const doc = (r.document || '').substring(0, 80);
const cat = meta.category || '';
return `
<div class="bg-purple-50 border border-purple-100 rounded-lg p-3 cursor-pointer hover:bg-purple-100 transition-colors"
onclick="openIssueDetailModal(${issueId})"
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-purple-700">No.${issueId}</span>
<span class="text-xs px-2 py-0.5 rounded-full ${similarity >= 70 ? 'bg-purple-200 text-purple-800' : 'bg-gray-200 text-gray-600'}">
${similarity}% 유사
</span>
</div>
<p class="text-xs text-gray-600 line-clamp-2">${doc}...</p>
${cat ? `<span class="text-xs text-purple-500 mt-1 inline-block">${cat}</span>` : ''}
</div>
`;
}).join('');
}
function createModalContent(issue, project) {
return `
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -1186,17 +1273,8 @@ function getPriorityBadge(priority) {
return `<span class="badge ${p.class}">${p.text}</span>`;
}
// API 스크립트 동적 로딩
const script = document.createElement('script');
script.src = '/static/js/api.js?v=20260213';
script.onload = function() {
console.log('✅ API 스크립트 로드 완료 (issues-management.js)');
initializeManagement();
};
script.onerror = function() {
console.error('❌ API 스크립트 로드 실패');
};
document.head.appendChild(script);
// 초기화 (api.js는 HTML에서 로드됨)
initializeManagement();
// 추가 정보 모달 관련 함수들
let selectedIssueId = null;