feat: RAG 임베딩 자동 동기화 + AI 서비스 개선
- 부적합 라이프사이클 전 과정에서 Qdrant 임베딩 자동 동기화 - 관리함 5개 저장 함수 + 수신함 상태 변경 시 fire-and-forget sync - 30분 주기 전체 재동기화 안전망 (FastAPI lifespan 백그라운드 태스크) - build_document_text에 카테고리(final_category/category) 포함 - RAG 질의에 DB 통계 집계 지원 (카테고리별/부서별 건수) - Qdrant client.search → query_points API 마이그레이션 - AI 어시스턴트 페이지 권한 추가 (tkuser) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from qdrant_client import QdrantClient
|
from qdrant_client import QdrantClient
|
||||||
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
|
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class VectorStore:
|
class VectorStore:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -51,17 +54,19 @@ class VectorStore:
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
query_filter = self._build_filter(where) if where else None
|
query_filter = self._build_filter(where) if where else None
|
||||||
try:
|
try:
|
||||||
results = self.client.search(
|
response = self.client.query_points(
|
||||||
collection_name=self.collection,
|
collection_name=self.collection,
|
||||||
query_vector=embedding,
|
query=embedding,
|
||||||
limit=n_results,
|
limit=n_results,
|
||||||
query_filter=query_filter,
|
query_filter=query_filter,
|
||||||
|
with_payload=True,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(f"Qdrant search failed: {e}", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for hit in results:
|
for hit in response.points:
|
||||||
payload = hit.payload or {}
|
payload = hit.payload or {}
|
||||||
item = {
|
item = {
|
||||||
"id": payload.get("original_id", str(hit.id)),
|
"id": payload.get("original_id", str(hit.id)),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
@@ -11,6 +13,8 @@ from db.metadata_store import metadata_store
|
|||||||
from services.ollama_client import ollama_client
|
from services.ollama_client import ollama_client
|
||||||
from middlewares.auth import verify_token
|
from middlewares.auth import verify_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PUBLIC_PATHS = {"/", "/api/ai/health", "/api/ai/models"}
|
PUBLIC_PATHS = {"/", "/api/ai/health", "/api/ai/models"}
|
||||||
|
|
||||||
|
|
||||||
@@ -25,11 +29,29 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
vector_store.initialize()
|
vector_store.initialize()
|
||||||
metadata_store.initialize()
|
metadata_store.initialize()
|
||||||
|
sync_task = asyncio.create_task(_periodic_sync())
|
||||||
yield
|
yield
|
||||||
|
sync_task.cancel()
|
||||||
await ollama_client.close()
|
await ollama_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
[질문]
|
[질문]
|
||||||
{question}
|
{question}
|
||||||
|
|
||||||
[관련 부적합 데이터]
|
{stats_summary}
|
||||||
|
|
||||||
|
[관련 부적합 사례 (유사도 검색 결과)]
|
||||||
{retrieved_cases}
|
{retrieved_cases}
|
||||||
|
|
||||||
답변 규칙:
|
답변 규칙:
|
||||||
- 핵심을 먼저 말하고 근거 사례를 인용하세요
|
- 통계 요약이 있으면 통계 데이터를 우선 참고하고, 없으면 관련 사례만 참고하세요
|
||||||
|
- 핵심을 먼저 말하고 근거 데이터를 인용하세요
|
||||||
- 500자 이내로 간결하게 답변하세요
|
- 500자 이내로 간결하게 답변하세요
|
||||||
- 마크다운 사용: **굵게**, 번호 목록, 소제목(###) 활용
|
- 마크다운 사용: **굵게**, 번호 목록, 소제목(###) 활용
|
||||||
- 데이터에 없는 내용은 추측하지 마세요
|
- 데이터에 없는 내용은 추측하지 마세요
|
||||||
@@ -82,6 +82,38 @@ def get_daily_qc_stats(date_str: str) -> dict:
|
|||||||
return dict(row._mapping) if row else {}
|
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]:
|
def get_issues_for_date(date_str: str) -> list[dict]:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
result = conn.execute(
|
result = conn.execute(
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from services.ollama_client import ollama_client
|
from services.ollama_client import ollama_client
|
||||||
from db.vector_store import vector_store
|
from db.vector_store import vector_store
|
||||||
from db.metadata_store import metadata_store
|
from db.metadata_store import metadata_store
|
||||||
from services.db_client import get_all_issues, get_issue_by_id, get_issues_since
|
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:
|
def build_document_text(issue: dict) -> str:
|
||||||
parts = []
|
parts = []
|
||||||
|
cat = issue.get("final_category") or issue.get("category")
|
||||||
|
if cat:
|
||||||
|
parts.append(f"분류: {cat}")
|
||||||
if issue.get("description"):
|
if issue.get("description"):
|
||||||
parts.append(issue["description"])
|
parts.append(issue["description"])
|
||||||
if issue.get("final_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:
|
async def sync_single_issue(issue_id: int) -> dict:
|
||||||
|
logger.info(f"Sync single issue: {issue_id}")
|
||||||
issue = get_issue_by_id(issue_id)
|
issue = get_issue_by_id(issue_id)
|
||||||
if not issue:
|
if not issue:
|
||||||
return {"status": "not_found"}
|
return {"status": "not_found"}
|
||||||
|
|||||||
@@ -1,8 +1,54 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from services.ollama_client import ollama_client
|
from services.ollama_client import ollama_client
|
||||||
from services.embedding_service import search_similar_by_text, build_document_text
|
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
|
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:
|
def _format_retrieved_issues(results: list[dict]) -> str:
|
||||||
if not results:
|
if not results:
|
||||||
@@ -81,11 +127,16 @@ async def rag_ask(question: str, project_id: int = None) -> dict:
|
|||||||
results = await search_similar_by_text(
|
results = await search_similar_by_text(
|
||||||
question, n_results=7, filters=None
|
question, n_results=7, filters=None
|
||||||
)
|
)
|
||||||
|
logger.info(f"RAG ask: question='{question[:50]}', results={len(results)}")
|
||||||
context = _format_retrieved_issues(results)
|
context = _format_retrieved_issues(results)
|
||||||
|
|
||||||
|
# 통계성 질문일 때만 DB 집계 포함 (토큰 절약)
|
||||||
|
stats = _build_stats_summary() if _needs_stats(question) else ""
|
||||||
|
|
||||||
template = load_prompt("prompts/rag_qa.txt")
|
template = load_prompt("prompts/rag_qa.txt")
|
||||||
prompt = template.format(
|
prompt = template.format(
|
||||||
question=question,
|
question=question,
|
||||||
|
stats_summary=stats,
|
||||||
retrieved_cases=context,
|
retrieved_cases=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -349,6 +349,20 @@ const AiAPI = {
|
|||||||
return { available: false };
|
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 () => {
|
syncEmbeddings: async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/ai-api/embeddings/sync', {
|
const res = await fetch('/ai-api/embeddings/sync', {
|
||||||
|
|||||||
@@ -827,6 +827,7 @@ async function confirmStatus() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(currentIssueId);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
alert(`상태가 성공적으로 변경되었습니다.\n${result.destination}으로 이동됩니다.`);
|
alert(`상태가 성공적으로 변경되었습니다.\n${result.destination}으로 이동됩니다.`);
|
||||||
closeStatusModal();
|
closeStatusModal();
|
||||||
|
|||||||
@@ -895,6 +895,7 @@ async function saveIssueChanges(issueId) {
|
|||||||
console.log('Response status:', response.status);
|
console.log('Response status:', response.status);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId);
|
||||||
alert('변경사항이 저장되었습니다.');
|
alert('변경사항이 저장되었습니다.');
|
||||||
await loadIssues(); // 목록 새로고침
|
await loadIssues(); // 목록 새로고침
|
||||||
} else {
|
} else {
|
||||||
@@ -1507,6 +1508,7 @@ async function saveDetailEdit(issueId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId);
|
||||||
// 성공 시 이슈 데이터 업데이트
|
// 성공 시 이슈 데이터 업데이트
|
||||||
issue.final_description = combinedDescription;
|
issue.final_description = combinedDescription;
|
||||||
|
|
||||||
@@ -1873,6 +1875,7 @@ async function saveIssueFromModal(issueId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId);
|
||||||
// 저장 성공 후 데이터 새로고침하고 모달은 유지
|
// 저장 성공 후 데이터 새로고침하고 모달은 유지
|
||||||
await initializeManagement(); // 페이지 새로고침
|
await initializeManagement(); // 페이지 새로고침
|
||||||
|
|
||||||
@@ -2197,6 +2200,7 @@ async function saveAndCompleteIssue(issueId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (saveResponse.ok) {
|
if (saveResponse.ok) {
|
||||||
|
if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId);
|
||||||
alert('부적합이 수정되고 최종 완료 처리되었습니다.');
|
alert('부적합이 수정되고 최종 완료 처리되었습니다.');
|
||||||
closeIssueEditModal();
|
closeIssueEditModal();
|
||||||
initializeManagement(); // 페이지 새로고침
|
initializeManagement(); // 페이지 새로고침
|
||||||
@@ -2226,6 +2230,7 @@ async function finalConfirmCompletion(issueId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
if (typeof AiAPI !== 'undefined') AiAPI.syncSingleIssue(issueId);
|
||||||
alert('부적합이 최종 완료 처리되었습니다.');
|
alert('부적합이 최종 완료 처리되었습니다.');
|
||||||
closeIssueEditModal();
|
closeIssueEditModal();
|
||||||
initializeManagement(); // 페이지 새로고침
|
initializeManagement(); // 페이지 새로고침
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ const DEFAULT_PAGES = {
|
|||||||
'reports': { title: '보고서', system: 'system3', group: '보고서', default_access: false },
|
'reports': { title: '보고서', system: 'system3', group: '보고서', default_access: false },
|
||||||
'reports_daily': { 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_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 }
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const SYSTEM3_PAGES = {
|
|||||||
{ key: 'reports_daily', title: '일일보고서', icon: 'fa-file-excel', def: false },
|
{ key: 'reports_daily', title: '일일보고서', icon: 'fa-file-excel', def: false },
|
||||||
{ key: 'reports_weekly', title: '주간보고서', icon: 'fa-calendar-week', def: false },
|
{ key: 'reports_weekly', title: '주간보고서', icon: 'fa-calendar-week', def: false },
|
||||||
{ key: 'reports_monthly', title: '월간보고서', icon: 'fa-calendar-alt', def: false },
|
{ key: 'reports_monthly', title: '월간보고서', icon: 'fa-calendar-alt', def: false },
|
||||||
|
],
|
||||||
|
'AI': [
|
||||||
|
{ key: 'ai_assistant', title: 'AI 어시스턴트', icon: 'fa-robot', def: false },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user