Files
tk-factory-services/system3-nonconformance/web/static/js/pages/ai-assistant.js
Hyungi Ahn b3012b8320 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>
2026-03-06 09:38:30 +09:00

585 lines
23 KiB
JavaScript

/**
* 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();