Compare commits
4 Commits
59cbcebb94
...
9647ae0d56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9647ae0d56 | ||
|
|
e6cc466a0e | ||
|
|
617c51ca53 | ||
|
|
e9d73ee30e |
@@ -2,8 +2,8 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
OLLAMA_BASE_URL: str = "http://100.111.160.84:11434"
|
||||
OLLAMA_TEXT_MODEL: str = "qwen3:8b"
|
||||
OLLAMA_BASE_URL: str = "https://gpu.hyungi.net"
|
||||
OLLAMA_TEXT_MODEL: str = "qwen3.5:9b-q8_0"
|
||||
OLLAMA_EMBED_MODEL: str = "bge-m3"
|
||||
OLLAMA_TIMEOUT: int = 120
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
당신은 공장 품질관리(QC) 데이터 분석가입니다. 아래 질문에 대해 과거 부적합 데이터를 기반으로 답변하세요.
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 과거 부적합 데이터를 기반으로 질문에 답변하세요.
|
||||
|
||||
[질문]
|
||||
{question}
|
||||
@@ -6,9 +6,8 @@
|
||||
[관련 부적합 데이터]
|
||||
{retrieved_cases}
|
||||
|
||||
위 데이터를 근거로 질문에 답변하세요.
|
||||
- 제공된 데이터를 적극적으로 활용하여 답변하세요
|
||||
- 관련 사례를 구체적으로 인용하며 분석하세요
|
||||
- 패턴이나 공통점이 있다면 정리하세요
|
||||
- 숫자나 통계가 있다면 포함하세요
|
||||
- 간결하되 유용한 답변을 하세요
|
||||
답변 규칙:
|
||||
- 핵심을 먼저 말하고 근거 사례를 인용하세요
|
||||
- 500자 이내로 간결하게 답변하세요
|
||||
- 마크다운 사용: **굵게**, 번호 목록, 소제목(###) 활용
|
||||
- 데이터에 없는 내용은 추측하지 마세요
|
||||
@@ -7,12 +7,24 @@ router = APIRouter(tags=["health"])
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
ollama_status = await ollama_client.check_health()
|
||||
backends = await ollama_client.check_health()
|
||||
stats = vector_store.stats()
|
||||
|
||||
# 메인 텍스트 모델명 결정 (Ollama 메인, MLX fallback)
|
||||
model_name = None
|
||||
ollama_models = backends.get("ollama", {}).get("models", [])
|
||||
if ollama_models:
|
||||
model_name = ollama_models[0]
|
||||
if not model_name and backends.get("mlx", {}).get("status") == "connected":
|
||||
model_name = backends["mlx"].get("model")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "tk-ai-service",
|
||||
"ollama": ollama_status,
|
||||
"embeddings": vector_store.stats(),
|
||||
"model": model_name,
|
||||
"ollama": backends.get("ollama", {}),
|
||||
"mlx": backends.get("mlx", {}),
|
||||
"embeddings": stats,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,21 @@ class OllamaClient:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
client = await self._get_client()
|
||||
# 조립컴 Ollama 메인, MLX fallback
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": settings.OLLAMA_TEXT_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {"temperature": 0.3, "num_predict": 2048},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["message"]["content"]
|
||||
except Exception:
|
||||
response = await client.post(
|
||||
f"{settings.MLX_BASE_URL}/chat/completions",
|
||||
json={
|
||||
@@ -55,31 +69,20 @@ class OllamaClient:
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["choices"][0]["message"]["content"]
|
||||
except Exception:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": settings.OLLAMA_TEXT_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.3, "num_predict": 2048},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["message"]["content"]
|
||||
|
||||
async def check_health(self) -> dict:
|
||||
result = {}
|
||||
short_timeout = httpx.Timeout(5.0, connect=3.0)
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
async with httpx.AsyncClient(timeout=short_timeout) as c:
|
||||
response = await c.get(f"{self.base_url}/api/tags")
|
||||
models = response.json().get("models", [])
|
||||
result["ollama"] = {"status": "connected", "models": [m["name"] for m in models]}
|
||||
except Exception:
|
||||
result["ollama"] = {"status": "disconnected"}
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"{settings.MLX_BASE_URL}/health")
|
||||
async with httpx.AsyncClient(timeout=short_timeout) as c:
|
||||
response = await c.get(f"{settings.MLX_BASE_URL}/health")
|
||||
result["mlx"] = {"status": "connected", "model": settings.MLX_TEXT_MODEL}
|
||||
except Exception:
|
||||
result["mlx"] = {"status": "disconnected"}
|
||||
|
||||
@@ -79,7 +79,7 @@ async def rag_ask(question: str, project_id: int = None) -> dict:
|
||||
"""부적합 데이터를 기반으로 자연어 질문에 답변"""
|
||||
# 프로젝트 필터 없이 전체 데이터에서 검색 (과거 미지정 데이터 포함)
|
||||
results = await search_similar_by_text(
|
||||
question, n_results=15, filters=None
|
||||
question, n_results=7, filters=None
|
||||
)
|
||||
context = _format_retrieved_issues(results)
|
||||
|
||||
|
||||
@@ -298,8 +298,8 @@ services:
|
||||
ports:
|
||||
- "30400:8000"
|
||||
environment:
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://100.111.160.84:11434}
|
||||
- OLLAMA_TEXT_MODEL=${OLLAMA_TEXT_MODEL:-qwen3:8b}
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-https://gpu.hyungi.net}
|
||||
- OLLAMA_TEXT_MODEL=${OLLAMA_TEXT_MODEL:-qwen3.5:9b-q8_0}
|
||||
- OLLAMA_EMBED_MODEL=${OLLAMA_EMBED_MODEL:-bge-m3}
|
||||
- OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-120}
|
||||
- MLX_BASE_URL=${MLX_BASE_URL:-https://llm.hyungi.net}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/css/ai-assistant.css?v=20260307">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 로딩 스크린 -->
|
||||
@@ -279,6 +280,6 @@
|
||||
<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>
|
||||
<script src="/static/js/pages/ai-assistant.js?v=20260307"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -115,57 +115,6 @@
|
||||
</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">
|
||||
@@ -600,19 +549,6 @@
|
||||
</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=20260306"></script>
|
||||
<script src="/static/js/components/common-header.js?v=20260306"></script>
|
||||
|
||||
@@ -67,6 +67,20 @@
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
/* AI 답변 마크다운 스타일 */
|
||||
.chat-bubble-ai .prose { color: #1e293b; }
|
||||
.chat-bubble-ai .prose h1,
|
||||
.chat-bubble-ai .prose h2,
|
||||
.chat-bubble-ai .prose h3 { font-size: 0.95em; font-weight: 700; margin: 0.8em 0 0.3em; color: #334155; }
|
||||
.chat-bubble-ai .prose p { margin: 0.4em 0; }
|
||||
.chat-bubble-ai .prose ul,
|
||||
.chat-bubble-ai .prose ol { margin: 0.3em 0; padding-left: 1.4em; }
|
||||
.chat-bubble-ai .prose li { margin: 0.15em 0; }
|
||||
.chat-bubble-ai .prose strong { color: #7c3aed; }
|
||||
.chat-bubble-ai .prose code { background: #e2e8f0; padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||
.chat-bubble-ai .prose blockquote { border-left: 3px solid #7c3aed; padding-left: 0.8em; margin: 0.5em 0; color: #64748b; }
|
||||
.chat-bubble-ai .prose hr { border-color: #e2e8f0; margin: 0.6em 0; }
|
||||
|
||||
.chat-bubble-ai .source-link {
|
||||
color: #7c3aed;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -127,7 +127,8 @@ async function checkAiHealth() {
|
||||
|
||||
const model = health.model
|
||||
|| health.llm_model
|
||||
|| (health.ollama?.models?.[0]);
|
||||
|| health.ollama?.ollama?.models?.[0]
|
||||
|| health.ollama?.models?.[0];
|
||||
if (model) {
|
||||
modelName.textContent = model;
|
||||
}
|
||||
@@ -193,8 +194,13 @@ function appendChatMessage(role, content, sources) {
|
||||
|
||||
// 내용 렌더링
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'text-sm whitespace-pre-line';
|
||||
contentDiv.textContent = content;
|
||||
if (role === 'ai' && typeof marked !== 'undefined') {
|
||||
contentDiv.className = 'text-sm prose prose-sm max-w-none';
|
||||
contentDiv.innerHTML = marked.parse(content);
|
||||
} else {
|
||||
contentDiv.className = 'text-sm whitespace-pre-line';
|
||||
contentDiv.textContent = content;
|
||||
}
|
||||
bubble.appendChild(contentDiv);
|
||||
|
||||
// AI 답변 참고 사례
|
||||
|
||||
@@ -1786,130 +1786,5 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -456,6 +456,18 @@ function createInProgressRow(issue, project) {
|
||||
<i class="fas fa-lightbulb text-yellow-500 mr-1"></i>해결방안 (확정)
|
||||
</label>
|
||||
<textarea id="management_comment_${issue.id}" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none ${isPendingCompletion ? 'bg-gray-100 cursor-not-allowed' : ''}" placeholder="확정된 해결 방안을 입력하세요..." ${isPendingCompletion ? 'readonly' : ''}>${cleanManagementComment(issue.management_comment)}</textarea>
|
||||
${!isPendingCompletion ? `
|
||||
<button onclick="aiSuggestSolutionInline(${issue.id})" class="mt-2 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="aiSuggestResult_${issue.id}" class="hidden mt-2 p-3 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<p id="aiSuggestContent_${issue.id}" class="text-sm text-gray-800 whitespace-pre-wrap"></p>
|
||||
<p id="aiSuggestSources_${issue.id}" class="text-xs text-purple-600 mt-2"></p>
|
||||
<button onclick="applyAiSuggestion(${issue.id})" class="mt-2 text-xs px-2 py-1 bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors">
|
||||
<i class="fas fa-paste mr-1"></i>해결방안에 적용
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -984,6 +996,48 @@ async function aiSuggestSolution() {
|
||||
if (result) result.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// RAG: AI 해결방안 제안 (인라인 카드용)
|
||||
async function aiSuggestSolutionInline(issueId) {
|
||||
if (typeof AiAPI === 'undefined') return;
|
||||
const btn = event.target.closest('button');
|
||||
const result = document.getElementById(`aiSuggestResult_${issueId}`);
|
||||
const content = document.getElementById(`aiSuggestContent_${issueId}`);
|
||||
const sources = document.getElementById(`aiSuggestSources_${issueId}`);
|
||||
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>AI 분석 중...'; }
|
||||
if (result) result.classList.add('hidden');
|
||||
|
||||
const data = await AiAPI.suggestSolution(issueId);
|
||||
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-lightbulb mr-2"></i>AI 해결방안 제안 (과거 사례 기반)'; }
|
||||
|
||||
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 제안 → 해결방안 textarea에 적용
|
||||
function applyAiSuggestion(issueId) {
|
||||
const content = document.getElementById(`aiSuggestContent_${issueId}`);
|
||||
const textarea = document.getElementById(`management_comment_${issueId}`);
|
||||
if (content && textarea) {
|
||||
textarea.value = content.textContent;
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// AI 유사 부적합 검색
|
||||
async function loadSimilarIssues() {
|
||||
if (!currentModalIssueId || typeof AiAPI === 'undefined') return;
|
||||
|
||||
Reference in New Issue
Block a user