This commit is contained in:
dsyoon
2025-12-27 14:06:26 +09:00
parent 23f5388c56
commit 46460b77f8
33 changed files with 4600 additions and 1 deletions

View File

@@ -0,0 +1,192 @@
"""backend.engines.dev_chatbot
개발챗봇 엔진 모듈.
구성
1. TOOL_ID / TOOL_INFO 엔진 메타 데이터 (레지스트리에 자동 수집)
2. prepare_context() Question ↔ VectorStore 유사도 검색 후 컨텍스트 생성
3. 벡터/파일 유틸 index_pdf, delete_pdf, get_vector_store
4. FastAPI router PDF 업로드·조회·삭제 등 API
다른 엔진을 만들 때 이 파일을 템플릿으로 삼으면 일관된 구조를 유지할 수 있다.
"""
# General Chatbot engine configuration
TOOL_ID = "dev_chatbot"
TOOL_INFO = {
"name": "개발챗봇",
"description": "일반적인 챗봇입니다.",
"system_prompt": (
"당신은 다목적 AI 어시스턴트입니다. 사용자의 질문에 대해 PDF 문서 분석 및 실시간 웹 검색을 통해 가장 신뢰할 수 있는 정보를 찾아 제공해야 합니다. "
"1) 질문과 관련된 내부·외부 PDF 자료가 있으면 우선적으로 내용을 요약·분석하여 근거와 함께 답변하세요. "
"2) 추론·계산·코드 예시·표·수식이 도움이 되면 적극 활용하세요. "
"3) 답변의 신뢰도를 높이기 위해 항상 출처(페이지 번호, 링크 등)를 명시하고, 확인되지 않은 정보는 가정임을 분명히 밝히세요. "
"4) 모호한 요청이나 추가 정보가 필요한 경우에는 명확한 follow-up 질문을 통해 요구 사항을 파악한 뒤 답변하세요. "
"5) 사실과 다른 정보를 임의로 생성하지 마세요. 필요한 정보가 없을 경우 솔직히 설명하고 가능한 대안을 제시합니다."
"6) 한국어로 대답해주세요."
)
}
from typing import Tuple, List
import os
from langchain.schema import Document
# get_vector_store 함수는 app.py 에 정의되어 주입받는다.
import urllib.parse
from fastapi import APIRouter, UploadFile, File
from fastapi.responses import StreamingResponse
from datetime import datetime
# -------------------------------------------------
# 벡터스토어 및 PDF 색인/삭제 기능
# -------------------------------------------------
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))
UPLOAD_DIR = os.path.join(ROOT_DIR, "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
# 벡터스토어 기능 비활성화(GPT 전용 모드)
def get_vector_store():
return None
# PDF 처리 기능 비활성화
try:
from langchain.text_splitter import RecursiveCharacterTextSplitter
except ModuleNotFoundError:
from langchain_text_splitters import RecursiveCharacterTextSplitter
try:
import pdfplumber # type: ignore
except ImportError:
pdfplumber = None
def delete_pdf(filename: str):
file_path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(file_path):
os.remove(file_path)
vectordb = get_vector_store()
try:
vectordb.delete(where={"source": filename, "tool": TOOL_ID})
except Exception:
pass
# -------------------------------------------------
# FastAPI Router (PDF 업로드/조회/삭제)
# -------------------------------------------------
router = APIRouter()
@router.post("/upload_pdf")
async def upload_pdf(files: List[UploadFile] = File(...)):
saved_files = []
for up in files:
if not up.filename.lower().endswith(".pdf"):
continue
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S%f')}_{up.filename}"
file_path = os.path.join(UPLOAD_DIR, filename)
with open(file_path, "wb") as f:
f.write(await up.read())
try:
if pdfplumber is None:
# pdfplumber 미설치 시 인덱싱 건너뜀
return
filename = os.path.basename(file_path)
docs = []
saved_files.append(filename)
except Exception as e:
return {"status": "error", "message": str(e)}
return {"status": "success", "files": saved_files}
@router.get("/files")
async def list_files():
pdfs = [f for f in os.listdir(UPLOAD_DIR) if f.lower().endswith('.pdf')]
return {"files": pdfs}
@router.get("/file_content")
async def get_file_content(filename: str):
file_path = os.path.join(UPLOAD_DIR, filename)
if not os.path.isfile(file_path):
return {"status": "error", "message": "파일을 찾을 수 없습니다."}
try:
# Assuming PDFPlumberLoader is available or needs to be imported
# from langchain.document_loaders import PDFPlumberLoader
# loader = PDFPlumberLoader(file_path)
# docs = loader.load()
# text = "\n\n".join(d.page_content for d in docs)
# return {"status": "success", "content": text}
# Placeholder for actual PDF content extraction if PDFPlumberLoader is not available
return {"status": "success", "content": "PDF content extraction is not implemented in this engine."}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.get("/pdf")
async def serve_pdf(filename: str):
# 1차: uploads 디렉터리
file_path = os.path.join(UPLOAD_DIR, filename)
# 2차: scripts/gxp 원본 디렉터리 (GxP 문서용)
if not os.path.isfile(file_path):
alt_dir = os.path.join(ROOT_DIR, "scripts", "gxp")
file_path = os.path.join(alt_dir, filename)
if not os.path.isfile(file_path):
return {"status":"error","message":"파일을 찾을 수 없습니다."}
file_stream = open(file_path, "rb")
safe_name = urllib.parse.quote(filename)
headers = {"Content-Disposition": f'inline; filename="{safe_name}"'}
return StreamingResponse(file_stream, media_type="application/pdf", headers=headers)
@router.delete("/file")
async def remove_file(filename: str):
try:
delete_pdf(filename)
return {"status": "success"}
except Exception as e:
return {"status": "error", "message": str(e)}
def prepare_context(message: str, knowledge_mode: str, vectordb) -> Tuple[str, List[Document]]:
"""사용자 메시지에 대한 벡터 검색 컨텍스트와 문서 리스트를 반환합니다.
vectordb: langchain_chroma.Chroma 인스턴스 (지연 로드)
"""
context_text = ""
retrieved_docs: List[Document] = []
if not vectordb or vectordb._collection.count() == 0:
return context_text, retrieved_docs
# dev_chatbot 문서만 대상으로 검색
docs_with_scores = vectordb.similarity_search_with_score(
message,
k=4,
filter={"tool": TOOL_ID}
)
score_threshold = 0.9
filtered = [d for d, s in docs_with_scores if s is not None and s < score_threshold]
retrieved_docs = filtered if filtered else [d for d, _ in docs_with_scores[:2]]
if not retrieved_docs:
return context_text, []
# 페이지별 그룹화 후 상위 2페이지만 사용
page_groups = {}
for d in retrieved_docs:
src = d.metadata.get("source", "")
pg = d.metadata.get("page_number", "")
key = (src, pg)
page_groups.setdefault(key, []).append(d.page_content)
selected_pages = list(page_groups.keys())[:2]
snippets = []
for (src, pg) in selected_pages:
texts = page_groups[(src, pg)]
joined = "\n".join(texts)
snippet_txt = joined[:1500]
header = f"[출처:{src} p{pg}]"
snippets.append(header + "\n" + snippet_txt)
context_text = "\n\n[관련 문서 발췌]\n" + "\n---\n".join(snippets)
# retrieved_docs 축소
retrieved_docs = [d for d in retrieved_docs if (d.metadata.get("source",""), d.metadata.get("page_number","")) in selected_pages]
return context_text, retrieved_docs