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:
Hyungi Ahn
2026-03-12 13:05:32 +09:00
parent 65db787f92
commit 5b1b89254c
11 changed files with 154 additions and 8 deletions

View File

@@ -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)),

View File

@@ -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()

View File

@@ -3,11 +3,14 @@
[질문]
{question}
[관련 부적합 데이터]
{stats_summary}
[관련 부적합 사례 (유사도 검색 결과)]
{retrieved_cases}
답변 규칙:
- 핵심을 먼저 말하고 근거 사례를 인용하세요
- 통계 요약이 있으면 통계 데이터를 우선 참고하고, 없으면 관련 사례만 참고하세요
- 핵심을 먼저 말하고 근거 데이터를 인용하세요
- 500자 이내로 간결하게 답변하세요
- 마크다운 사용: **굵게**, 번호 목록, 소제목(###) 활용
- 데이터에 없는 내용은 추측하지 마세요

View File

@@ -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(

View File

@@ -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"}

View File

@@ -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,
)