diff --git a/ai-service/db/vector_store.py b/ai-service/db/vector_store.py index ad8e4f6..701c344 100644 --- a/ai-service/db/vector_store.py +++ b/ai-service/db/vector_store.py @@ -1,8 +1,11 @@ +import logging import uuid from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue from config import settings +logger = logging.getLogger(__name__) + class VectorStore: def __init__(self): @@ -51,17 +54,19 @@ class VectorStore: ) -> list[dict]: query_filter = self._build_filter(where) if where else None try: - results = self.client.search( + response = self.client.query_points( collection_name=self.collection, - query_vector=embedding, + query=embedding, limit=n_results, query_filter=query_filter, + with_payload=True, ) - except Exception: + except Exception as e: + logger.error(f"Qdrant search failed: {e}", exc_info=True) return [] items = [] - for hit in results: + for hit in response.points: payload = hit.payload or {} item = { "id": payload.get("original_id", str(hit.id)), diff --git a/ai-service/main.py b/ai-service/main.py index 9a1091d..1539b1b 100644 --- a/ai-service/main.py +++ b/ai-service/main.py @@ -1,3 +1,5 @@ +import asyncio +import logging import os from contextlib import asynccontextmanager from fastapi import FastAPI, Request @@ -11,6 +13,8 @@ from db.metadata_store import metadata_store from services.ollama_client import ollama_client from middlewares.auth import verify_token +logger = logging.getLogger(__name__) + PUBLIC_PATHS = {"/", "/api/ai/health", "/api/ai/models"} @@ -25,11 +29,29 @@ class AuthMiddleware(BaseHTTPMiddleware): return await call_next(request) +async def _periodic_sync(): + """30분마다 전체 이슈 재동기화 (안전망)""" + await asyncio.sleep(60) # 시작 후 1분 대기 (초기화 완료 보장) + while True: + try: + from services.embedding_service import sync_all_issues + result = await sync_all_issues() + logger.info(f"Periodic sync completed: {result}") + except asyncio.CancelledError: + logger.info("Periodic sync task cancelled") + return + except Exception as e: + logger.warning(f"Periodic sync failed: {e}") + await asyncio.sleep(1800) # 30분 + + @asynccontextmanager async def lifespan(app: FastAPI): vector_store.initialize() metadata_store.initialize() + sync_task = asyncio.create_task(_periodic_sync()) yield + sync_task.cancel() await ollama_client.close() diff --git a/ai-service/prompts/rag_qa.txt b/ai-service/prompts/rag_qa.txt index 7c3fa74..24d4c24 100644 --- a/ai-service/prompts/rag_qa.txt +++ b/ai-service/prompts/rag_qa.txt @@ -3,11 +3,14 @@ [질문] {question} -[관련 부적합 데이터] +{stats_summary} + +[관련 부적합 사례 (유사도 검색 결과)] {retrieved_cases} 답변 규칙: -- 핵심을 먼저 말하고 근거 사례를 인용하세요 +- 통계 요약이 있으면 통계 데이터를 우선 참고하고, 없으면 관련 사례만 참고하세요 +- 핵심을 먼저 말하고 근거 데이터를 인용하세요 - 500자 이내로 간결하게 답변하세요 - 마크다운 사용: **굵게**, 번호 목록, 소제목(###) 활용 - 데이터에 없는 내용은 추측하지 마세요 \ No newline at end of file diff --git a/ai-service/services/db_client.py b/ai-service/services/db_client.py index 495fa59..a5ad766 100644 --- a/ai-service/services/db_client.py +++ b/ai-service/services/db_client.py @@ -82,6 +82,38 @@ def get_daily_qc_stats(date_str: str) -> dict: return dict(row._mapping) if row else {} +def get_category_stats() -> list[dict]: + """카테고리별 부적합 건수 집계""" + with engine.connect() as conn: + result = conn.execute( + text( + "SELECT COALESCE(final_category, category) AS category, " + "COUNT(*) AS count " + "FROM qc_issues " + "GROUP BY COALESCE(final_category, category) " + "ORDER BY count DESC" + ) + ) + return [dict(row._mapping) for row in result] + + +def get_department_stats() -> list[dict]: + """부서별 부적합 건수 집계""" + with engine.connect() as conn: + result = conn.execute( + text( + "SELECT responsible_department AS department, " + "COUNT(*) AS count " + "FROM qc_issues " + "WHERE responsible_department IS NOT NULL " + "AND responsible_department != '' " + "GROUP BY responsible_department " + "ORDER BY count DESC" + ) + ) + return [dict(row._mapping) for row in result] + + def get_issues_for_date(date_str: str) -> list[dict]: with engine.connect() as conn: result = conn.execute( diff --git a/ai-service/services/embedding_service.py b/ai-service/services/embedding_service.py index c05fe51..8e17eb2 100644 --- a/ai-service/services/embedding_service.py +++ b/ai-service/services/embedding_service.py @@ -1,11 +1,18 @@ +import logging + from services.ollama_client import ollama_client from db.vector_store import vector_store from db.metadata_store import metadata_store from services.db_client import get_all_issues, get_issue_by_id, get_issues_since +logger = logging.getLogger(__name__) + def build_document_text(issue: dict) -> str: parts = [] + cat = issue.get("final_category") or issue.get("category") + if cat: + parts.append(f"분류: {cat}") if issue.get("description"): parts.append(issue["description"]) if issue.get("final_description"): @@ -84,6 +91,7 @@ async def sync_all_issues() -> dict: async def sync_single_issue(issue_id: int) -> dict: + logger.info(f"Sync single issue: {issue_id}") issue = get_issue_by_id(issue_id) if not issue: return {"status": "not_found"} diff --git a/ai-service/services/rag_service.py b/ai-service/services/rag_service.py index 56883b2..ff86730 100644 --- a/ai-service/services/rag_service.py +++ b/ai-service/services/rag_service.py @@ -1,8 +1,54 @@ +import logging +import time + from services.ollama_client import ollama_client from services.embedding_service import search_similar_by_text, build_document_text -from services.db_client import get_issue_by_id +from services.db_client import get_issue_by_id, get_category_stats, get_department_stats from services.utils import load_prompt +logger = logging.getLogger(__name__) + +_stats_cache = {"data": "", "expires": 0} +STATS_CACHE_TTL = 300 # 5분 + +STATS_KEYWORDS = {"많이", "빈도", "추이", "비율", "통계", "몇 건", "자주", "빈번", "유형별", "부서별"} + + +def _needs_stats(question: str) -> bool: + """키워드 매칭으로 통계성 질문인지 판별""" + return any(kw in question for kw in STATS_KEYWORDS) + + +def _build_stats_summary() -> str: + """DB 집계 통계 요약 (5분 TTL 캐싱, 실패 시 빈 문자열)""" + now = time.time() + if _stats_cache["data"] and now < _stats_cache["expires"]: + return _stats_cache["data"] + try: + lines = ["[전체 통계 요약]"] + cats = get_category_stats() + if cats: + total = sum(c["count"] for c in cats) + lines.append(f"총 부적합 건수: {total}건") + lines.append("카테고리별:") + for c in cats[:10]: + pct = round(c["count"] / total * 100, 1) + lines.append(f" - {c['category']}: {c['count']}건 ({pct}%)") + depts = get_department_stats() + if depts: + lines.append("부서별:") + for d in depts[:10]: + lines.append(f" - {d['department']}: {d['count']}건") + if len(lines) <= 1: + return "" # 데이터 없으면 빈 문자열 + result = "\n".join(lines) + _stats_cache["data"] = result + _stats_cache["expires"] = now + STATS_CACHE_TTL + return result + except Exception as e: + logger.warning(f"Stats summary failed: {e}") + return "" + def _format_retrieved_issues(results: list[dict]) -> str: if not results: @@ -81,11 +127,16 @@ async def rag_ask(question: str, project_id: int = None) -> dict: results = await search_similar_by_text( question, n_results=7, filters=None ) + logger.info(f"RAG ask: question='{question[:50]}', results={len(results)}") context = _format_retrieved_issues(results) + # 통계성 질문일 때만 DB 집계 포함 (토큰 절약) + stats = _build_stats_summary() if _needs_stats(question) else "" + template = load_prompt("prompts/rag_qa.txt") prompt = template.format( question=question, + stats_summary=stats, retrieved_cases=context, ) diff --git a/system3-nonconformance/web/static/js/api.js b/system3-nonconformance/web/static/js/api.js index ec2708b..2f6659b 100644 --- a/system3-nonconformance/web/static/js/api.js +++ b/system3-nonconformance/web/static/js/api.js @@ -349,6 +349,20 @@ const AiAPI = { return { available: false }; } }, + syncSingleIssue: async (issueId) => { + try { + await fetch('/ai-api/embeddings/sync-single', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TokenManager.getToken()}` + }, + body: JSON.stringify({ issue_id: issueId }) + }); + } catch (e) { + console.warn('AI 임베딩 동기화 실패 (무시):', e.message); + } + }, syncEmbeddings: async () => { try { const res = await fetch('/ai-api/embeddings/sync', { diff --git a/system3-nonconformance/web/static/js/pages/issues-inbox.js b/system3-nonconformance/web/static/js/pages/issues-inbox.js index 44a3cf1..fe5caf5 100644 --- a/system3-nonconformance/web/static/js/pages/issues-inbox.js +++ b/system3-nonconformance/web/static/js/pages/issues-inbox.js @@ -827,6 +827,7 @@ async function confirmStatus() { }); if (response.ok) { + if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(currentIssueId); const result = await response.json(); alert(`상태가 성공적으로 변경되었습니다.\n${result.destination}으로 이동됩니다.`); closeStatusModal(); diff --git a/system3-nonconformance/web/static/js/pages/issues-management.js b/system3-nonconformance/web/static/js/pages/issues-management.js index 5866488..09f1289 100644 --- a/system3-nonconformance/web/static/js/pages/issues-management.js +++ b/system3-nonconformance/web/static/js/pages/issues-management.js @@ -895,6 +895,7 @@ async function saveIssueChanges(issueId) { console.log('Response status:', response.status); if (response.ok) { + if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId); alert('변경사항이 저장되었습니다.'); await loadIssues(); // 목록 새로고침 } else { @@ -1507,6 +1508,7 @@ async function saveDetailEdit(issueId) { }); if (response.ok) { + if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId); // 성공 시 이슈 데이터 업데이트 issue.final_description = combinedDescription; @@ -1873,6 +1875,7 @@ async function saveIssueFromModal(issueId) { }); if (response.ok) { + if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId); // 저장 성공 후 데이터 새로고침하고 모달은 유지 await initializeManagement(); // 페이지 새로고침 @@ -2197,6 +2200,7 @@ async function saveAndCompleteIssue(issueId) { }); if (saveResponse.ok) { + if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId); alert('부적합이 수정되고 최종 완료 처리되었습니다.'); closeIssueEditModal(); initializeManagement(); // 페이지 새로고침 @@ -2226,6 +2230,7 @@ async function finalConfirmCompletion(issueId) { }); if (response.ok) { + if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId); alert('부적합이 최종 완료 처리되었습니다.'); closeIssueEditModal(); initializeManagement(); // 페이지 새로고침 diff --git a/user-management/api/models/permissionModel.js b/user-management/api/models/permissionModel.js index 03ba106..299780c 100644 --- a/user-management/api/models/permissionModel.js +++ b/user-management/api/models/permissionModel.js @@ -50,7 +50,9 @@ const DEFAULT_PAGES = { 'reports': { title: '보고서', system: 'system3', group: '보고서', default_access: false }, 'reports_daily': { title: '일일보고서', system: 'system3', group: '보고서', default_access: false }, 'reports_weekly': { title: '주간보고서', system: 'system3', group: '보고서', default_access: false }, - 'reports_monthly': { title: '월간보고서', system: 'system3', group: '보고서', default_access: false } + 'reports_monthly': { title: '월간보고서', system: 'system3', group: '보고서', default_access: false }, + // AI + 'ai_assistant': { title: 'AI 어시스턴트', system: 'system3', group: 'AI', default_access: false } }; /** diff --git a/user-management/web/static/js/tkuser-users.js b/user-management/web/static/js/tkuser-users.js index 47e7a93..e13811c 100644 --- a/user-management/web/static/js/tkuser-users.js +++ b/user-management/web/static/js/tkuser-users.js @@ -53,6 +53,9 @@ const SYSTEM3_PAGES = { { key: 'reports_daily', title: '일일보고서', icon: 'fa-file-excel', def: false }, { key: 'reports_weekly', title: '주간보고서', icon: 'fa-calendar-week', def: false }, { key: 'reports_monthly', title: '월간보고서', icon: 'fa-calendar-alt', def: false }, + ], + 'AI': [ + { key: 'ai_assistant', title: 'AI 어시스턴트', icon: 'fa-robot', def: false }, ] };