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

4
.gitignore vendored
View File

@@ -1,4 +1,8 @@
# ---> Python
chroma_db/*
logs/*
uploads/*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.9" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ncuetalk_backend.iml" filepath="$PROJECT_DIR$/.idea/ncuetalk_backend.iml" />
</modules>
</component>
</project>

8
.idea/ncuetalk_backend.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.9" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/table_schema.sql" dialect="GenericSQL" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

505
README.md
View File

@@ -1,2 +1,505 @@
# ncuetalk_backend
# 엔큐톡 (ncuetalk) - 다목적 AI 채팅 플랫폼
엔큐톡은 다양한 AI 도구들을 통합한 웹 기반 채팅 플랫폼입니다. ChatGPT, 문서 분석, 번역, 연구 질의응답 등 여러 AI 서비스를 하나의 통합 인터페이스에서 제공합니다.
## 📋 목차
- [주요 기능](#주요-기능)
- [프로젝트 구조](#프로젝트-구조)
- [설치 및 실행](#설치-및-실행)
- [도구별 상세 가이드](#도구별-상세-가이드)
- [API 문서](#api-문서)
- [개발 가이드](#개발-가이드)
## 🚀 주요 기능
### 🔧 통합 AI 도구 플랫폼
- **6개의 전문 AI 도구** 통합 제공
- **통일된 채팅 인터페이스**로 일관된 사용자 경험
- **세션 관리** 및 **대화 기록** 저장
- **파일 업로드 및 처리** 기능
### 🎯 지원 도구 목록
| 도구 | 기능 | 특징 |
|------|------|--------------------------|
| **ChatGPT** | OpenAI 모델 대화 | GPT-5 등 다중 모델 지원 |
| **GxP 챗봇** | GxP 문서 질의응답 | 벡터 DB 기반 문서 검색 |
| **개발챗봇** | PDF 문서 분석 | PDF 업로드, 벡터 검색, 지식베이스 모드 |
| **문서번역** | 한영 번역 서비스 | Word 문서 + 실시간 텍스트 번역 |
| **연구QA** | 연구 질의응답 | 외부 연구 플랫폼 연동 |
| **Text2SQL** | LIMS 데이터 조회 | 자연어를 SQL로 변환 |
## 📁 프로젝트 구조
```
ncuetalk/
├── README.md # 프로젝트 문서
├── requirements.txt # Python 의존성
├── index.html # 메인 웹 페이지
├── main.js # 프론트엔드 로직
├── style.css # 스타일시트
├──
├── backend/ # 백엔드 서버
│ ├── app.py # FastAPI 메인 앱
│ └── engines/ # AI 도구 엔진들
│ ├── __init__.py # 엔진 레지스트리
│ ├── chatgpt_tool/ # ChatGPT 도구
│ ├── chatbot_gxp/ # GxP 챗봇
│ ├── dev_chatbot/ # 개발챗봇
│ ├── doc_translation/ # 문서번역
│ ├── research_qa/ # 연구QA
│ └── lims_text2sql/ # Text2SQL
├── frontend/ # 프론트엔드 리소스
│ ├── dev_chatbot/ # 개발챗봇 UI 컴포넌트
│ ├── lims_text2sql/ # Text2SQL UI
│ └── research_qa/ # 연구QA UI
├── uploads/ # 업로드된 파일들
├── chroma_db/ # 벡터 데이터베이스
├── logs/ # 시스템 로그
└── scripts/ # 유틸리티 스크립트
```
## 🛠 설치 및 실행
### 전제 조건
- Python 3.8+
- Node.js (선택사항, 프론트엔드 개발 시)
- OpenAI API 키
### 1. 환경 설정
```bash
# 저장소 클론
git clone <repository-url>
cd ncuetalk
# 가상환경 생성 및 활성화
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 의존성 설치
pip install -r requirements.txt
```
### 2. 환경 변수 설정
프로젝트 루트에 `.env` 파일 생성:
```env
# OpenAI API 설정
OPENAI_API_KEY=your_openai_api_key_here
# Ollama 모델 설정 (자체모델 사용 시)
OLLAMA_MODEL=gpt-oss:latest
LLM_PROVIDER=ollama
# 기타 설정
UPLOAD_MAX_SIZE=50MB
```
### 3. 서버 실행
```bash
# 백엔드 서버 시작
cd backend
python -m uvicorn app:app --host 0.0.0.0 --port 8010 --reload
# 프론트엔드 서버 시작 (별도 터미널)
cd ..
python -m http.server 3000
```
### 4. 웹 브라우저에서 접속
- 프론트엔드: http://localhost:3000
- 백엔드 API: http://localhost:8010
- API 문서: http://localhost:8010/docs
## 🎯 도구별 상세 가이드
### 1. ChatGPT 💬
**기능**: OpenAI의 ChatGPT 모델과 직접 대화
**지원 모델**:
- `auto`: 자동 선택 (기본값)
- `gpt-5`: GPT-5 모델
**사용법**:
1. 도구 목록에서 "ChatGPT" 선택
2. 모델 선택 드롭다운에서 원하는 모델 선택
3. 채팅창에 질문 입력 후 엔터
### 2. GxP 챗봇 📋
**기능**: GxP(Good Practice) 문서에 대한 전문적인 질의응답
**특징**:
- Adobe PDF Services API 기반 고품질 텍스트 추출
- ChromaDB 벡터 데이터베이스를 활용한 의미 검색
- AI Agent 기반 도구 선택 자동화
**사용법**:
1. "GxP 챗봇" 선택
2. GxP 관련 질문 입력 (예: "mapping test가 무엇인지?")
3. 벡터 검색을 통해 관련 문서에서 답변 생성
### 3. 개발챗봇 🛠
**기능**: PDF 문서 업로드 및 분석을 통한 질의응답
**주요 기능**:
- PDF 파일 업로드 및 벡터화
- 문서 기반 질의응답
- 출처 페이지 번호 제공
- 지식베이스 전용 모드 지원
**사용법**:
1. "개발챗봇" 선택
2. 좌측 파일 리스트에서 "+" 버튼으로 PDF 업로드
3. 모델 선택: "자체모델" (Ollama 기반)
4. 지식모드 선택: "지식베이스" (업로드된 문서만 참조)
5. 업로드한 문서에 대해 질문
**파일 관리**:
- 지원 형식: PDF 파일만
- 최대 파일 크기: 환경 설정에 따름
- 파일 삭제: 각 파일 옆 "✕" 버튼
### 4. 문서번역 🌐
**기능**: 한국어를 영어로 번역 (Word 문서 + 실시간 텍스트)
**지원 기능**:
- **Word 문서 번역**: .doc/.docx 파일 업로드 후 일괄 번역
- **실시간 텍스트 번역**: 채팅창에 입력한 텍스트 즉시 번역
- **이중언어 문서 생성**: 원본과 번역문이 함께 포함된 결과 파일
**모델 선택**:
- **자체모델**: Ollama 기반 gpt-oss:latest (빠른 처리)
- **외부모델**: OpenAI GPT-5 (고품질 번역)
**사용법**:
**📄 Word 문서 번역**:
1. "문서번역" 선택
2. 좌측 파일 리스트에서 "+" 버튼
3. .doc 또는 .docx 파일 선택
4. 자동으로 번역 처리 시작
5. 완료 후 "[원본다운]", "[결과다운]" 버튼으로 파일 다운로드
**💬 실시간 텍스트 번역**:
1. "문서번역" 선택
2. 모델 선택 (자체모델/외부모델)
3. 채팅창에 번역할 한국어 텍스트 입력
4. 즉시 영어 번역 결과 확인
**예시**:
```
입력: "안녕하세요. 오늘 날씨가 좋네요."
출력: "Hello. The weather is nice today."
```
### 5. 연구QA 📚
**기능**: 연구 관련 질의응답 (외부 플랫폼 연동)
**특징**:
- 외부 연구 플랫폼과 iframe 연동
- 연구 방법론, 데이터 분석, 논문 작성 지원
**사용법**:
1. "연구QA 챗봇" 선택
2. 자동으로 외부 연구 플랫폼 로드
3. 연동된 플랫폼에서 직접 질의응답
### 6. Text2SQL (LIMS) 🗃
**기능**: 자연어를 SQL 쿼리로 변환하여 LIMS 데이터 조회
**특징**:
- 자연어 질의를 SQL로 자동 변환
- LIMS 데이터베이스 전용 최적화
**사용법**:
1. "Text2SQL (LIMS)" 선택
2. 자동으로 외부 LIMS 플랫폼 로드
3. 자연어로 데이터 조회 요청
## 📚 API 문서
### 🔗 공통 엔드포인트
#### GET `/`
- **설명**: 서버 상태 확인
- **응답**: `{"message": "엔큐톡 AI 채팅 서버가 실행 중입니다."}`
#### GET `/tools`
- **설명**: 사용 가능한 도구 목록 조회
- **응답**:
```json
[
{
"id": "chatgpt",
"name": "ChatGPT",
"description": "OpenAI ChatGPT 모델과 대화할 수 있는 도구입니다."
}
]
```
#### POST `/chat`
- **설명**: 통합 채팅 엔드포인트 (모든 도구 지원)
- **Content-Type**: `multipart/form-data`
- **Parameters**:
- `message` (string, required): 사용자 메시지
- `tool_id` (string, required): 도구 ID
- `session_id` (string, optional): 세션 ID
- `model` (string, optional): 모델명 (기본값: "exone3.5")
- `knowledge_mode` (string, optional): 지식 모드 ("hybrid", "kb_only")
- `image` (file[], optional): 이미지 파일들
- **응답**:
```json
{
"response": "AI 응답 텍스트",
"status": "success",
"session_id": "생성된_세션_ID",
"tool_name": "도구명"
}
```
### 🛠 개발챗봇 API
**Base Path**: `/`
#### POST `/upload_pdf`
- **설명**: PDF 파일 업로드 및 벡터화
- **Content-Type**: `multipart/form-data`
- **Parameters**:
- `files` (file[], required): PDF 파일들
- **응답**:
```json
{
"status": "success",
"files": ["파일명1.pdf", "파일명2.pdf"]
}
```
#### GET `/files`
- **설명**: 업로드된 PDF 파일 목록 조회
- **응답**:
```json
{
"files": ["파일명1.pdf", "파일명2.pdf"]
}
```
#### GET `/file_content`
- **설명**: PDF 파일 내용 조회
- **Parameters**:
- `filename` (string, required): 파일명
- **응답**: PDF 내용을 JSON 형태로 반환
#### DELETE `/delete_pdf/{filename}`
- **설명**: PDF 파일 삭제
- **Parameters**:
- `filename` (string, required): 삭제할 파일명
#### GET `/pdf`
- **설명**: PDF 파일 뷰어 제공
- **Parameters**:
- `filename` (string, required): 파일명
### 🌐 문서번역 API
**Base Path**: `/doc_translation`
#### POST `/upload_doc`
- **설명**: Word 문서 업로드 및 번역
- **Content-Type**: `multipart/form-data`
- **Parameters**:
- `files` (file[], required): Word 파일들 (.doc, .docx)
- **응답**:
```json
{
"status": "success",
"files": [
{
"original_filename": "원본파일명.docx",
"result_filename": "결과파일명.docx",
"status": "success"
}
]
}
```
#### GET `/files`
- **설명**: 번역 파일 목록 조회
- **응답**:
```json
[
{
"filename": "원본파일명.docx",
"result_filename": "결과파일명.docx",
"has_result": true
}
]
```
#### GET `/download/{filename}`
- **설명**: 파일 다운로드
- **Parameters**:
- `filename` (string, required): 다운로드할 파일명
#### DELETE `/delete/{original_filename}`
- **설명**: 번역 파일 삭제 (원본+결과 모두)
- **Parameters**:
- `original_filename` (string, required): 원본 파일명
### 📋 GxP 챗봇 API
**Base Path**: `/gxp`
#### POST `/chat`
- **설명**: GxP 챗봇 대화
- **Parameters**:
- `query` (string, required): 질의 내용
- `session_id` (string, required): 세션 ID
#### POST `/ai-agent-chat`
- **설명**: AI Agent 기반 GxP 챗봇 대화
- **Parameters**:
- `query` (string, required): 질의 내용
- `session_id` (string, required): 세션 ID
#### GET `/active-sessions`
- **설명**: 활성 세션 목록 조회
#### GET `/serve-gxp-pdf/{filename}`
- **설명**: GxP PDF 파일 제공
#### GET `/search-vector-db`
- **설명**: 벡터 DB 검색
- **Parameters**:
- `query` (string, required): 검색 쿼리
- `plant` (string, optional): 공장명 필터
- `filename` (string, optional): 파일명 필터
- `collection_name` (string, optional): 컬렉션명 필터
#### GET `/collections`
- **설명**: ChromaDB 컬렉션 목록 조회
#### GET `/collections/{collection_name}`
- **설명**: 특정 컬렉션 정보 조회
### 💬 ChatGPT API
**Base Path**: `/chatgpt`
#### POST `/chat`
- **설명**: ChatGPT 전용 대화 엔드포인트
- **Parameters**:
- `message` (string, required): 메시지
- `model` (string, optional): 모델명 ("auto", "gpt-5", etc.)
- `session_id` (string, optional): 세션 ID
## 🔧 개발 가이드
### 새로운 도구 추가하기
1. **엔진 디렉토리 생성**:
```bash
mkdir backend/engines/new_tool
```
2. **`__init__.py` 작성**:
```python
TOOL_ID = "new_tool"
TOOL_INFO = {
"name": "새 도구",
"description": "새 도구 설명",
"system_prompt": "시스템 프롬프트"
}
# 필요한 함수들 구현
def prepare_context(question: str, **kwargs) -> str:
# 컨텍스트 준비 로직
return "준비된 컨텍스트"
# FastAPI router (선택사항)
from fastapi import APIRouter
router = APIRouter()
@router.get("/new-endpoint")
async def new_endpoint():
return {"message": "새 엔드포인트"}
```
3. **백엔드 등록**:
```python
# backend/app.py에 추가
from engines.new_tool import router as new_tool_router
app.include_router(new_tool_router, prefix="/new_tool")
```
4. **프론트엔드 통합**:
```javascript
// main.js에 도구별 로직 추가
} else if (toolId === 'new_tool') {
// 새 도구 전용 UI 로직
}
```
### 환경 변수 설정
`.env` 파일에서 다음 변수들을 설정할 수 있습니다:
```env
# 필수 설정
OPENAI_API_KEY=your_api_key
# 선택적 설정
OLLAMA_MODEL=gpt-oss:latest
LLM_PROVIDER=ollama
UPLOAD_MAX_SIZE=50MB
VECTOR_DB_PATH=./chroma_db
LOG_LEVEL=INFO
```
### 로깅
시스템 로그는 `logs/chat.log`에 저장됩니다:
- 채팅 요청/응답 로그
- 오류 로그
- 파일 업로드/삭제 로그
### 데이터베이스
- **벡터 DB**: ChromaDB (`chroma_db/` 디렉토리)
- **파일 저장**: `uploads/` 디렉토리
- **세션 데이터**: 메모리 저장 (서버 재시작 시 초기화)
## 🤝 기여하기
1. Fork 저장소
2. Feature 브랜치 생성 (`git checkout -b feature/AmazingFeature`)
3. 변경사항 커밋 (`git commit -m 'Add some AmazingFeature'`)
4. 브랜치에 Push (`git push origin feature/AmazingFeature`)
5. Pull Request 생성
## 📄 라이선스
이 프로젝트는 MIT 라이선스 하에 배포됩니다.
## 📞 지원
문제가 발생하거나 질문이 있으시면:
- GitHub Issues 생성
- 개발팀 연락
---
**엔큐톡**과 함께 더 스마트한 AI 경험을 만들어보세요! 🚀

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@

852
backend/app.py Normal file
View File

@@ -0,0 +1,852 @@
"""backend.app
FastAPI 엔트리포인트.
• 공통 미들웨어/CORS 및 로거 설정
• 엔진 레지스트리(engines.__init__)에서 TOOLS 를 가져와 `/tools` API 에 노출
• 개발챗봇(dev_chatbot) Router 포함 PDF 업로드·벡터검색 등 전용 API 분리
• `/chat` 하나의 엔드포인트로 모든 도구 요청을 처리하며,
- 세션 관리(tool_sessions)
- 이미지 OCR
- knowledge_mode(kb_only/hybrid) 분기
- 각 엔진별 prepare_context 로 컨텍스트 생성
• 실시간 로그(chat.log) 저장
코드를 길게 설명하기보다, 각 섹션(Import / 설정 / 유틸 / Router 등록 / 엔드포인트) 위에
블록 주석을 배치해 가독성을 높였다.
"""
from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn
import os
from datetime import datetime
from typing import List, Dict, Optional
from PIL import Image
import json
import logging
import openai
from dotenv import load_dotenv
# --- LangChain 1.1+ 호환성 패치 (레거시 경로 alias) ---
import sys, importlib, types
try:
import langchain # 확인용
mappings = {
'langchain.docstore.document': 'langchain_community.docstore',
'langchain.text_splitter': 'langchain_text_splitters',
'langchain.callbacks': 'langchain_community.callbacks',
'langchain.callbacks.streaming_stdout': 'langchain_community.callbacks.streaming_stdout',
'langchain.prompts': 'langchain_core.prompts',
'langchain.output_parsers.openai_tools': 'langchain_community.output_parsers.openai_tools',
'langchain.tools': 'langchain_community.tools',
'langchain.tools.render': 'langchain_community.tools.render',
'langchain_ollama': None,
}
for old, new in mappings.items():
if old not in sys.modules:
if new:
try:
mod = importlib.import_module(new)
sys.modules[old] = mod
except Exception:
pass
else:
import types as _t
dummy = _t.ModuleType('langchain_ollama')
class _Dummy:
def __init__(self,*a,**kw):
raise ImportError('langchain_ollama removed')
dummy.OllamaLLM = _Dummy
dummy.OllamaEmbeddings = _Dummy
sys.modules['langchain_ollama']=dummy
# schema.Document alias
if 'langchain.schema' not in sys.modules:
try:
from langchain_core.documents import Document as _Doc
schema_mod = types.ModuleType('langchain.schema')
schema_mod.Document = _Doc
sys.modules['langchain.schema'] = schema_mod
except Exception:
pass
except Exception:
pass
import re
# PDF 관련 로더 미사용
from fastapi.responses import FileResponse
from fastapi.responses import StreamingResponse
import urllib.parse
import psycopg2
from psycopg2.extras import RealDictCursor
import requests
from bs4 import BeautifulSoup
# GxP 챗봇 제거로 관련 컨트롤러 import 삭제
from engines.chatgpt_tool.controller.ChatGPTController import router as chatgpt_router
# .env 파일 로드 (프로젝트 루트에서)
load_dotenv()
# 환경 변수에서 API Key 가져오기
OPEN_API_KEY = os.getenv("OPENAI_API_KEY", "")
if not OPEN_API_KEY:
raise RuntimeError("OPENAI_API_KEY 환경 변수가 설정되어 있지 않습니다. .env 파일을 확인하세요.")
openai_client = openai.OpenAI(api_key=OPEN_API_KEY)
app = FastAPI(title="엔큐톡 AI 채팅 서버", version="1.0.0")
# CORS 설정 (credentials 포함 시 * 를 사용할 수 없음)
DEV_FRONT_URLS = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://ncue.net:5173",
os.getenv("FRONTEND_ORIGIN", ""),
]
app.add_middleware(
CORSMiddleware,
allow_origins=[o for o in DEV_FRONT_URLS if o],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(chatgpt_router)
# 파일 경로 설정
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 프로젝트 루트 디렉터리 (backend 의 상위)
ROOT_DIR = os.path.dirname(BASE_DIR)
# uploads, chroma_db 를 프로젝트 루트로 이동했으므로 해당 경로를 사용
UPLOAD_DIR = os.path.join(ROOT_DIR, "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
# 벡터스토어 디렉터리 (chroma_db)
VECTOR_DIR = os.path.join(ROOT_DIR, "chroma_db")
os.makedirs(VECTOR_DIR, exist_ok=True)
# 도구별 세션 저장소
tool_sessions: Dict[str, List[Dict]] = {}
# circled number map 1-20
CIRCLED = {1:'',2:'',3:'',4:'',5:'',6:'',7:'',8:'',9:'',10:'',11:'',12:'',13:'',14:'',15:'',16:'',17:'',18:'',19:'',20:''}
# 답변이 "정보 없음" 류의 부정적 응답인지 판별하는 간단한 휴리스틱
def is_negative_answer(text: str) -> bool:
"""사용자 질문에 대한 답변이 관련 정보를 찾지 못했음을 나타내는지 확인"""
if not text:
return True
lowers = text.lower()
negative_keywords = [
"없습니다", "없어요", "없다", "찾을 수 없", "파악할 수 없", "모르", "not found",
"정보가 없습니다", "관련 정보가 없습니다", "않습니다", "죄송", "포함되어 있지", "구체적인 설명", "구체적 설명", "구체적으로 언급"
]
return any(kw.lower() in lowers for kw in negative_keywords)
# 도구 정의
from engines import TOOLS
# dev_chatbot 전용 기능 가져오기
from engines.dev_chatbot import (
router as gc_router,
get_vector_store as gc_get_vector_store,
prepare_context as gc_prepare_context,
)
# doc_translation 전용 기능 가져오기
from engines.doc_translation import (
router as doc_translation_router,
prepare_context as doc_translation_prepare_context,
)
# (옵션) GxP 챗봇 벡터 DB 서비스 현재 모듈이 제거되었을 수 있으므로 더미 처리
try:
from engines.chatbot_gxp.service.GxPVectorDBService import GxPVectorDBService
except ModuleNotFoundError:
class GxPVectorDBService: # type: ignore
def __init__(self, *a, **kw):
pass
def similarity_search(self, *a, **kw):
return []
# FastAPI 라우터 등록
app.include_router(gc_router)
app.include_router(doc_translation_router, prefix="/doc_translation")
class ToolInfo(BaseModel):
id: str
name: str
description: str
@app.get("/")
async def root():
return {"message": "엔큐톡 AI 채팅 서버가 실행 중입니다."}
@app.get("/tools", response_model=List[ToolInfo])
async def get_tools():
# 프론트엔드 카드 표시 순서를 사용자 요구에 맞춰 개발챗봇 → GxP 챗봇 순으로 고정하고,
# 나머지 도구들은 기존 등록 순서를 그대로 유지합니다.
preferred_order = ["chatgpt", "chatbot_gxp"] # dev_chatbot 은 맨 뒤로 이동
# preferred_order 에 명시된 도구를 먼저, 이후 나머지 도구를 추가
ordered_ids = [tid for tid in preferred_order if tid in TOOLS]
# dev_chatbot 을 제외한 나머지 도구들 추가
ordered_ids += [tid for tid in TOOLS.keys() if tid not in ordered_ids and tid != "dev_chatbot"]
# 최종적으로 dev_chatbot 을 맨 뒤에 추가
if "dev_chatbot" in TOOLS:
ordered_ids.append("dev_chatbot")
return [
ToolInfo(id=tid, name=TOOLS[tid]["name"], description=TOOLS[tid]["description"])
for tid in ordered_ids
]
class ChatRequest(BaseModel):
message: str
tool_id: str
session_id: Optional[str] = None
class ChatResponse(BaseModel):
response: str
status: str = "success"
session_id: str
tool_name: str
def is_meaningful_text(text):
import re
# 한글 자모(ㄱㄴㅏ 등) 단독 포함 시 = 오타/무의미로 간주
if re.search(r'[\u3130-\u318F]', text):
return False
# 영어·한글·숫자만 추출 후 길이 판단
cleaned = re.sub(r'[^가-힣a-zA-Z0-9]', '', text)
# 반복문자(같은 글자 4회 이상 반복) 무의미
if re.search(r'(.)\1{3,}', cleaned):
return False
# 안내·설명 패턴 필터
if re.search(r'(이미지에서 추출|안내|설명|반환|추출하지 못했습니다)', text):
return False
# 너무 짧으면 의미 없음 (완성형 기준 8자 이상)
return len(cleaned) >= 8
def extract_ocr_text(image_context):
# 실제 OCR 결과만 추출
if image_context.startswith("\n이미지에서 추출한 텍스트:\n"):
extracted = image_context.split("\n이미지에서 추출한 텍스트:\n", 1)[-1].strip()
else:
extracted = image_context.strip()
# 안내문, 설명문, 특수문자 등 의미 없는 값 필터링
if not is_meaningful_text(extracted):
return "[이미지에서 텍스트를 추출하지 못했습니다.]"
return extracted
OCR_COMMANDS = ["텍스트 추출", "텍스트 추출해줘", "텍스트 추출해 주세요", "텍스트 추출해주세요"]
# 로그 디렉터리 및 로거 설정
LOG_DIR = os.path.join(ROOT_DIR, "logs")
os.makedirs(LOG_DIR, exist_ok=True)
LOGGER = logging.getLogger("chat_logger")
if not LOGGER.handlers:
LOGGER.setLevel(logging.INFO)
_fh = logging.FileHandler(os.path.join(LOG_DIR, "chat.log"), encoding="utf-8")
_fh.setFormatter(logging.Formatter('[%(asctime)s] %(message)s'))
LOGGER.addHandler(_fh)
def _safe_chat_log(payload: dict) -> None:
"""logs/chat.log 에 JSON 한 줄로 안전하게 기록한다.
phase(요청/응답), status, reason 등의 필드를 받아도 되고, 없어도 된다.
"""
try:
LOGGER.info(json.dumps(payload, ensure_ascii=False))
except Exception:
# 로깅 중 예외는 서비스 흐름에 영향 주지 않음
pass
# ------------------------
# Auth APIs (login/logout)
# ------------------------
class LoginDTO(BaseModel):
email: str
password: str
def _get_db_conn():
# .env 의 DATABASE_URL(예: postgresql://user:pw@host:port/dbname) 사용
dsn = os.getenv("DATABASE_URL") or os.getenv("POSTGRES_DSN")
if not dsn:
raise RuntimeError("DATABASE_URL 환경변수가 필요합니다")
return psycopg2.connect(dsn)
@app.post("/auth/login")
def auth_login(dto: LoginDTO):
try:
with _get_db_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
# id 컬럼 → user_id 로 변경 반영
cur.execute(
"SELECT user_id, email FROM users WHERE email=%s AND password = crypt(%s, password)",
(dto.email, dto.password),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=401, detail="invalid credentials")
return {"user_id": row["user_id"], "email": row["email"]}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/auth/logout")
def auth_logout():
return {"ok": True}
# ------------------------
# Community: QnA Board APIs
# ------------------------
class QnaCreateDTO(BaseModel):
title: str
content: str
author_id: Optional[str] = None
author_email: Optional[str] = None
@app.get("/community/qna")
def list_qna():
with _get_db_conn() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT id, title, content, created_at, updated_at, views, author_id FROM qna_board ORDER BY id DESC")
rows = cur.fetchall() or []
return rows
@app.post("/community/qna")
def create_qna(dto: QnaCreateDTO):
with _get_db_conn() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
# resolve author_no from id/no/email
author_no = None
if dto.author_id is not None:
try:
num = int(dto.author_id)
cur.execute("SELECT no FROM users WHERE no=%s", (num,))
r = cur.fetchone()
if r:
author_no = r["no"]
except Exception:
cur.execute("SELECT no FROM users WHERE user_id=%s", (dto.author_id,))
r = cur.fetchone()
if r:
author_no = r["no"]
if author_no is None and dto.author_email:
cur.execute("SELECT no FROM users WHERE email=%s", (dto.author_email,))
r = cur.fetchone()
if r:
author_no = r["no"]
if author_no is None:
# allow anonymous with default author from env
default_id = os.getenv("DEFAULT_QNA_AUTHOR_ID", "admin")
default_email = os.getenv("DEFAULT_QNA_AUTHOR_EMAIL")
if default_email:
cur.execute("SELECT no FROM users WHERE email=%s", (default_email,))
r = cur.fetchone()
if r:
author_no = r["no"]
if author_no is None and default_id:
cur.execute("SELECT no FROM users WHERE user_id=%s", (default_id,))
r = cur.fetchone()
if r:
author_no = r["no"]
if author_no is None:
raise HTTPException(status_code=400, detail="author not found; set DEFAULT_QNA_AUTHOR_ID or pass author_email/id")
cur.execute(
"INSERT INTO qna_board (title, content, author_id) VALUES (%s, %s, %s) RETURNING id",
(dto.title, dto.content, author_no),
)
new_id = cur.fetchone()["id"]
conn.commit()
return {"id": new_id}
@app.get("/community/qna/{qid}")
def get_qna(qid: int, increase: int = 1):
"""단건 조회. increase=1 일 때 조회수 +1 처리 후 반환."""
with _get_db_conn() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
if increase:
cur.execute("UPDATE qna_board SET views=views+1, updated_at=NOW() WHERE id=%s", (qid,))
conn.commit()
cur.execute("SELECT id, title, content, created_at, updated_at, views, author_id FROM qna_board WHERE id=%s", (qid,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="not found")
return row
# ------------------------
# Community: AI News Board APIs
# ------------------------
class AiNewsCreateDTO(BaseModel):
url: str
author_id: Optional[str] = None
author_email: Optional[str] = None
def _extract_og(url: str) -> dict:
meta = {"title": "", "description": "", "image": "", "url": url}
try:
resp = requests.get(url, timeout=5, headers={"User-Agent": "Mozilla/5.0"})
if resp.ok:
soup = BeautifulSoup(resp.text, 'html.parser')
og_title = soup.find('meta', property='og:title')
og_desc = soup.find('meta', property='og:description')
og_img = soup.find('meta', property='og:image')
title_tag = soup.find('title')
meta["title"] = (og_title["content"].strip() if og_title and og_title.get("content") else (title_tag.text.strip() if title_tag else ""))
meta["description"] = (og_desc["content"].strip() if og_desc and og_desc.get("content") else "")
meta["image"] = (og_img["content"].strip() if og_img and og_img.get("content") else "")
except Exception:
pass
return meta
@app.get("/community/ai_news")
def list_ai_news(offset: int = 0, limit: int = 10):
with _get_db_conn() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"SELECT id, url, created_at, updated_at, views, author_id FROM ai_news_board ORDER BY id DESC LIMIT %s OFFSET %s",
(limit, offset),
)
rows = cur.fetchall() or []
enriched = []
for r in rows:
og = _extract_og(r["url"]) if r.get("url") else {"title":"","description":"","image":"","url":r.get("url")}
r.update({"meta": og})
enriched.append(r)
return {"items": enriched, "nextOffset": offset + len(enriched)}
@app.post("/community/ai_news")
def create_ai_news(dto: AiNewsCreateDTO):
with _get_db_conn() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
# resolve author
author_no = None
if dto.author_id is not None:
try:
num = int(dto.author_id)
cur.execute("SELECT no FROM users WHERE no=%s", (num,))
r = cur.fetchone()
if r:
author_no = r["no"]
except Exception:
cur.execute("SELECT no FROM users WHERE user_id=%s", (dto.author_id,))
r = cur.fetchone()
if r:
author_no = r["no"]
if author_no is None and dto.author_email:
cur.execute("SELECT no FROM users WHERE email=%s", (dto.author_email,))
r = cur.fetchone()
if r:
author_no = r["no"]
if author_no is None:
default_id = os.getenv("DEFAULT_QNA_AUTHOR_ID", "admin")
cur.execute("SELECT no FROM users WHERE user_id=%s", (default_id,))
r = cur.fetchone()
if r:
author_no = r["no"]
if author_no is None:
raise HTTPException(status_code=400, detail="author not found")
cur.execute("INSERT INTO ai_news_board (url, author_id) VALUES (%s, %s) RETURNING id", (dto.url, author_no))
new_id = cur.fetchone()["id"]
conn.commit()
return {"id": new_id}
@app.get("/community/ai_news/{nid}")
def get_ai_news(nid: int, increase: int = 1):
with _get_db_conn() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
if increase:
cur.execute("UPDATE ai_news_board SET views=views+1, updated_at=NOW() WHERE id=%s", (nid,))
conn.commit()
cur.execute("SELECT id, url, created_at, updated_at, views, author_id FROM ai_news_board WHERE id=%s", (nid,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="not found")
row.update({"meta": _extract_og(row["url"])})
return row
@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(
message: str = Form(...),
tool_id: str = Form(...),
session_id: Optional[str] = Form(None),
image: List[UploadFile] = File(None),
model: str = Form("gpt-5"),
ocr_model: str = Form("none"),
knowledge_mode: str = Form("hybrid"),
):
try:
# 세션 ID를 가장 먼저 확보하여, 모든 분기에서 동일 ID로 로깅되도록 함
if not session_id:
# tool_id 가 잘못되었더라도 임시 세션 ID를 생성하여 추적 가능하게 처리
safe_tool = tool_id if tool_id else "unknown"
session_id = f"{safe_tool}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
# 요청 수신 로그 (항상 기록)
_safe_chat_log({
"timestamp": datetime.now().isoformat(),
"phase": "request",
"tool_id": tool_id,
"session_id": session_id,
"model": model,
"ocr_model": ocr_model,
"knowledge_mode": knowledge_mode,
"user_message": message,
})
# ---- 1) 입력 검증: 무의미한 메시지 필터링 ----
# ChatGPT, 문서번역 도구는 간단 인사말이나 모든 텍스트를 처리해야 하므로 필터를 우회
if tool_id not in ["chatgpt", "doc_translation"] and not is_meaningful_text(message):
_safe_chat_log({
"timestamp": datetime.now().isoformat(),
"phase": "response",
"status": "error",
"reason": "meaningless_text",
"tool_id": tool_id,
"session_id": session_id,
"user_message": message,
})
return ChatResponse(
response="질문이 명확하지 않습니다. 좀 더 구체적인 내용을 입력해 주세요.",
status="error",
session_id=session_id or "",
tool_name=""
)
# 도구 ID 검증
if tool_id not in TOOLS:
_safe_chat_log({
"timestamp": datetime.now().isoformat(),
"phase": "response",
"status": "error",
"reason": "invalid_tool_id",
"tool_id": tool_id,
"session_id": session_id,
"user_message": message,
})
return ChatResponse(
response="유효하지 않은 도구입니다.",
status="error",
session_id="",
tool_name=""
)
# ------------------------------------------------------------------
# chatbot_gxp 은 GxPChatController.chat 로 위임처리 (중복 로직 제거)
# ------------------------------------------------------------------
if tool_id == "chatbot_gxp":
# GxPChatController.chat 은 query, session_id 를 매개변수로 받음
gxp_resp = await GxPChatController.chat(query=message, session_id=session_id)
# 오류 코드 처리
if gxp_resp.status_code != 200:
try:
err_detail = json.loads(gxp_resp.body.decode())
err_msg = err_detail.get("error", "GxP 챗봇 처리 중 오류가 발생했습니다.")
except Exception:
err_msg = "GxP 챗봇 처리 중 오류가 발생했습니다."
_safe_chat_log({
"timestamp": datetime.now().isoformat(),
"phase": "response",
"status": "error",
"tool_id": tool_id,
"session_id": session_id,
"user_message": message,
"response": err_msg,
})
return ChatResponse(
response=err_msg,
status="error",
session_id=session_id,
tool_name=TOOLS[tool_id]["name"],
)
# 정상 응답 처리
data = json.loads(gxp_resp.body.decode()) if isinstance(gxp_resp.body, (bytes, bytearray)) else gxp_resp.body
answer_text = data.get("answer", "") if isinstance(data, dict) else str(data)
_safe_chat_log({
"timestamp": datetime.now().isoformat(),
"phase": "response",
"status": "success",
"tool_id": tool_id,
"session_id": session_id,
"user_message": message,
"response": answer_text[:1000],
})
return ChatResponse(
response=answer_text,
session_id=session_id,
tool_name=TOOLS[tool_id]["name"],
)
# 세션 초기화
if session_id not in tool_sessions:
tool_sessions[session_id] = []
# 이미지 처리 (생략, 기존 코드 유지)
image_context = ""
if image:
ocr_texts = []
for img in image:
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S%f')}_{img.filename}"
image_path = os.path.join(UPLOAD_DIR, filename)
with open(image_path, "wb") as f:
f.write(await img.read())
try:
ocr_result = "[OCR 비활성화]"
if ocr_result:
ocr_texts.append(ocr_result)
except Exception as ocr_err:
ocr_texts.append(f"[OCR 실패: {ocr_err}]")
if ocr_texts:
image_context = f"\n이미지에서 추출한 텍스트:\n{chr(10).join(ocr_texts)}\n"
else:
image_context = "\n[이미지에서 텍스트를 추출하지 못했습니다.]\n"
# 대화 기록 구성
conversation_history = ""
if tool_sessions[session_id]:
history = tool_sessions[session_id][-5:]
conversation_history = "\n".join([
f"{'사용자' if msg['role'] == 'user' else 'AI'}: {msg['content']}"
for msg in history
])
# 프롬프트 구성
tool_info = TOOLS[tool_id]
system_prompt = tool_info["system_prompt"]
# 벡터 검색 (dev_chatbot 전용)
context_text = ""
retrieved_docs = []
if tool_id == "dev_chatbot":
context_text, retrieved_docs = gc_prepare_context(message, knowledge_mode, gc_get_vector_store())
if knowledge_mode == "kb_only" and not context_text:
return ChatResponse(response="지식베이스에 관련 정보가 없습니다.", session_id=session_id, tool_name=tool_info["name"])
# ---- GxP 챗봇 전용 벡터 검색 ----
if tool_id == "chatbot_gxp":
gxp_vec_service = GxPVectorDBService()
retrieved_docs = gxp_vec_service.similarity_search(query=message)
if retrieved_docs:
# 페이지별 최대 2개, 길이 1200자 제한 snippet 생성
page_groups = {}
for d in retrieved_docs:
meta = d.get("metadata", {}) if isinstance(d, dict) else d.metadata
pg = meta.get("page") or meta.get("page_number", "")
filename = meta.get("filename") or meta.get("source", "")
key = (filename, pg)
page_groups.setdefault(key, []).append(d.get("content") if isinstance(d, dict) else d.page_content)
selected = list(page_groups.keys())[:2]
snippets = []
for (fname, pg) in selected:
joined = "\n".join(page_groups[(fname, pg)])[:1200]
snippets.append(f"[출처:{fname} p{pg}]\n{joined}")
context_text = "\n\n[관련 문서 발췌]\n" + "\n---\n".join(snippets)
# ---- 문서번역 전용 처리 ----
if tool_id == "doc_translation":
# 실시간 채팅 번역 처리 (모델 선택 지원)
translated_response = doc_translation_prepare_context(message, model=model)
_safe_chat_log({
"timestamp": datetime.now().isoformat(),
"phase": "response",
"status": "success",
"tool_id": tool_id,
"session_id": session_id,
"user_message": message,
"response": translated_response[:1000],
})
return ChatResponse(response=translated_response, session_id=session_id, tool_name=tool_info["name"])
# kb_only 모드용 추가 지침
if knowledge_mode == "kb_only":
system_prompt += "\n\n주의: 반드시 위의 [관련 문서 발췌] 내용에 근거해서만 답변하고, 모르면 모른다고 답해라."
full_prompt = f"""{system_prompt}
{context_text}
{image_context}
{conversation_history}
사용자: {message}
AI:"""
response = ""
completion = openai_client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
*( [{"role": "user" if msg['role']=="user" else "assistant", "content": msg['content']} for msg in tool_sessions[session_id][-5:]] if tool_sessions[session_id] else [] ),
{"role": "user", "content": (image_context + message) if image_context else message}
]
)
response = completion.choices[0].message.content.strip()
# ----------------------------------------------------------
# 공통: GxP 챗봇 근거 문서명/페이지 삽입
# ----------------------------------------------------------
if tool_id == "chatbot_gxp" and retrieved_docs:
cites = []
seen = set()
for d in retrieved_docs:
meta = d.get("metadata", {}) if isinstance(d, dict) else d.metadata
fname = meta.get("filename") or meta.get("source") or meta.get("path")
pg = meta.get("page") or meta.get("page_number")
if fname and pg and (fname, pg) not in seen:
cites.append(f"**(문서명: {fname}, 페이지: {pg})**")
seen.add((fname, pg))
if len(cites) >= 3:
break
if cites:
response = response.rstrip() + "\n\n" + "\n".join(cites)
# 응답 앞부분의 불필요한 사과 문구 제거 (의미 있는 답변인데 "죄송합니다" 로 시작하는 경우)
if re.match(r"^죄송합니다[.,]?\s*", response, flags=re.I) and not is_negative_answer(response):
response = re.sub(r"^죄송합니다[.,]?\s*", "", response, flags=re.I)
# 세션에 대화 기록 저장
tool_sessions[session_id].append({
"role": "user",
"content": message,
"timestamp": datetime.now().isoformat()
})
tool_sessions[session_id].append({
"role": "assistant",
"content": response,
"timestamp": datetime.now().isoformat()
})
# 세션 크기 제한 (최대 20개 메시지)
if len(tool_sessions[session_id]) > 20:
tool_sessions[session_id] = tool_sessions[session_id][-20:]
# dev_chatbot: 인라인 "출처:" 문구 제거 후 [출처] 블록 추가
if tool_id == "dev_chatbot" and retrieved_docs and not is_negative_answer(response):
# 1) 기존 응답에서 "출처:"가 포함된 구문/괄호 제거
response = re.sub(r"[\(\[]?출처[:][^\]\)\n]*[\)\]]?", "", response)
# '**출처**:' 형태의 블록 제거
response = re.sub(r"\*\*출처\*\*[:]?\s*(?:\n|\r|.)*?(?=\n{2,}|$)", "", response, flags=re.I)
# '**참고 문헌**' 블록 제거
response = re.sub(r"\*\*참고[^\n]*\n(?:- .*\n?)*", "", response, flags=re.I)
# 2) 파일별 페이지 모음 생성
file_pages = {}
for d in retrieved_docs:
src = d.metadata.get("source", "")
pg_raw = d.metadata.get("page_number", d.metadata.get("page", ""))
if src and str(pg_raw).isdigit():
file_pages.setdefault(src, set()).add(int(pg_raw))
if file_pages:
ref_lines = []
for src, pages in file_pages.items():
sorted_pages = sorted(pages)
# timestamp prefix 제거
display_src = src
ts_part = src.split("_",1)[0]
if len(src.split("_",1))>1 and ts_part.isdigit():
display_src = src.split("_",1)[1]
page_links = []
for p in sorted_pages:
page_num = p
fname_enc = urllib.parse.quote(src)
page_links.append(f"[p{page_num}](/pdf?filename={fname_enc}#page={page_num})")
ref_lines.append(f"{display_src}: " + ", ".join(page_links))
# 출처 블록 삽입 (중복 방지)
response = response.rstrip()
response += "\n\n[출처]\n" + "\n".join(ref_lines)
# 로깅
try:
LOGGER.info(json.dumps({
"timestamp": datetime.now().isoformat(),
"tool_id": tool_id,
"tool_name": tool_info["name"],
"session_id": session_id,
"model": model,
"ocr_model": ocr_model,
"knowledge_mode": knowledge_mode,
"user_message": message,
"response": response[:1000] # 응답 길이 제한
}, ensure_ascii=False))
except Exception:
pass
# 이미지에서 텍스트 추출만 요청한 경우(프롬프트 없이 OCR 결과만 반환)
if image and message.strip() in OCR_COMMANDS:
ocr_only = extract_ocr_text(image_context)
_safe_chat_log({
"timestamp": datetime.now().isoformat(),
"phase": "response",
"status": "success",
"tool_id": tool_id,
"session_id": session_id,
"user_message": message,
"response": ocr_only[:1000],
})
return ChatResponse(
response=ocr_only,
session_id=session_id,
tool_name=tool_info["name"]
)
return ChatResponse(
response=response,
session_id=session_id,
tool_name=tool_info["name"]
)
except Exception as e:
return ChatResponse(
response=f"오류가 발생했습니다: {str(e)}",
status="error",
session_id=session_id if 'session_id' in locals() else "",
tool_name=TOOLS.get(tool_id, {}).get("name", "") if 'tool_id' in locals() else ""
)
@app.get("/sessions/{tool_id}")
async def get_sessions(tool_id: str):
"""특정 도구의 세션 목록을 반환합니다."""
if tool_id not in TOOLS:
return {"error": "유효하지 않은 도구입니다."}
sessions = [
{
"session_id": session_id,
"message_count": len(messages),
"last_updated": messages[-1]["timestamp"] if messages else None
}
for session_id, messages in tool_sessions.items()
if session_id.startswith(f"{tool_id}_")
]
return {"sessions": sessions}
@app.delete("/sessions/{session_id}")
async def delete_session(session_id: str):
"""특정 세션을 삭제합니다."""
if session_id in tool_sessions:
del tool_sessions[session_id]
return {"message": "세션이 삭제되었습니다."}
return {"error": "세션을 찾을 수 없습니다."}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8010)

View File

@@ -0,0 +1,28 @@
"""backend.engines package
엔진 레지스트리.
서브패키지를 순회해 각 엔진 모듈이 노출한
`TOOL_ID` 와 `TOOL_INFO` 를 자동 수집하여 `TOOLS` dict 로 제공한다.
외부(backend.app 등)에서는 from engines import TOOLS 로 공통 접근.
"""
from importlib import import_module
import pkgutil
# Dictionary mapping tool_id to its info
TOOLS = {}
# Discover and import all subpackages to collect TOOL_INFO
package_name = __name__
for _, module_name, is_pkg in pkgutil.iter_modules(__path__):
if not is_pkg:
# Skip if it's not a package (we expect dirs)
continue
try:
module = import_module(f"{package_name}.{module_name}")
# Each engine package must expose TOOL_ID and TOOL_INFO
if hasattr(module, "TOOL_ID") and hasattr(module, "TOOL_INFO"):
TOOLS[module.TOOL_ID] = module.TOOL_INFO
except ModuleNotFoundError:
# If submodule import fails, skip silently
continue

View File

@@ -0,0 +1,11 @@
TOOL_ID = "chatgpt"
TOOL_INFO = {
"name": "ChatGPT",
"description": "OpenAI ChatGPT 모델과 대화할 수 있는 도구입니다.",
"system_prompt": (
"You are ChatGPT, a large language model trained by OpenAI. "
"Provide thorough, well-structured answers in Korean using 전문지식과 신뢰할 수 있는 공개 자료를 바탕으로 설명하세요. 필요 시 표/리스트/소제목을 활용해 가독성을 높이십시오. "
"만약 기업의 위치나 연락처 등 요청이 오면 본사·연구소·공장 등 주요 거점을 빠짐없이 요약해 주세요."
)
}

View File

@@ -0,0 +1,53 @@
from fastapi import APIRouter, Form
from typing import Optional
import os
import openai
from dotenv import load_dotenv
load_dotenv()
OPEN_API_KEY = os.getenv("OPENAI_API_KEY", "")
openai_client = openai.OpenAI(api_key=OPEN_API_KEY)
router = APIRouter(prefix="/chatgpt", tags=["ChatGPT"])
# 모델 매핑 테이블 (프론트 선택값 ➜ OpenAI 모델명)
MODEL_MAP = {
"auto": "gpt-5",
"gpt-5": "gpt-5",
"gpt-5-mini": "gpt-5-mini",
"gpt-5-nano": "gpt-5-nano",
}
SYSTEM_PROMPT = (
"You are ChatGPT, a large language model trained by OpenAI. "
"Provide thorough, well-structured answers in Korean with rich formatting. "
"Always include 적절한 소제목과 불릿, 줄바꿈을 활용하고, 각 소제목 앞에 관련 이모지(예: 🏢 본사, 🧪 연구소, 🏭 공장 등)를 붙여 가독성을 높이십시오. "
"필요하면 표 또는 번호 리스트를 사용하세요. "
"회사의 위치·연락처·교통편을 묻는 질문에는 반드시 본사, 연구소, 공장 등 주요 거점 정보를 빠짐없이 상세히 제공합니다. "
"답변 길이 제한 없이 충분히 상세히 작성하세요."
)
@router.post("/chat")
async def chat_gpt_endpoint(
message: str = Form(...),
model: str = Form("auto"),
session_id: Optional[str] = Form(None)
):
# 모델 매핑
model_name = MODEL_MAP.get(model, "gpt-5")
# 직접 OpenAI ChatCompletion 호출
completion = openai_client.chat.completions.create(
model=model_name,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message},
]
)
return {
"status": "success",
"response": completion.choices[0].message.content.strip(),
"session_id": session_id or "",
"tool_name": "ChatGPT",
}

View File

@@ -0,0 +1 @@

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

View File

@@ -0,0 +1,346 @@
"""backend.engines.doc_translation
문서번역 엔진 모듈.
MS Word 문서를 업로드하여 한글을 영어로 번역하는 기능을 제공합니다.
"""
import os
import shutil
import re
from datetime import datetime
from typing import List
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
from fastapi.responses import FileResponse
from docx import Document as DocxDocument
import openai
from dotenv import load_dotenv
# .env 파일 로드
load_dotenv()
TOOL_ID = "doc_translation"
TOOL_INFO = {
"name": "문서번역",
"description": "MS Word 문서를 업로드하면 한글을 영어로 번역합니다.",
"system_prompt": (
"당신은 전문 번역가입니다. 한국어 문서를 영어로 정확하고 자연스럽게 번역해주세요. "
"번역할 때는 원문의 의미와 뉘앙스를 최대한 보존하면서도 영어로 자연스럽게 표현해주세요. "
"대상 문장 다음 줄에 번역 문장을 제공해주세요."
)
}
# 업로드 디렉토리 설정 - 프로젝트 루트/uploads (개발챗봇과 동일)
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)
# OpenAI API 키 설정
openai.api_key = os.getenv("OPENAI_API_KEY")
def translate_text_with_gpt(text: str) -> str:
"""ChatGPT를 사용하여 텍스트를 한글에서 영어로 번역 (채팅용)"""
try:
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
response = client.chat.completions.create(
model="gpt-5",
messages=[
{
"role": "system",
"content": TOOL_INFO["system_prompt"]
},
{
"role": "user",
"content": f"다음 한국어 텍스트를 영어로 번역해주세요:\n\n{text}"
}
]
)
return response.choices[0].message.content
except Exception as e:
print(f"번역 오류: {e}")
return f"번역 오류가 발생했습니다: {str(e)}"
# (Ollama 내부 모델 번역 기능 제거)
def translate_paragraph(paragraph: str) -> str:
"""단일 단락을 번역"""
try:
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
response = client.chat.completions.create(
model="gpt-5",
messages=[
{
"role": "system",
"content": "당신은 전문 번역가입니다. 한국어 문단을 영어로 정확하고 자연스럽게 번역해주세요. 번역문만 제공하세요."
},
{
"role": "user",
"content": f"다음 한국어 문단을 영어로 번역해주세요:\n\n{paragraph}"
}
]
)
return response.choices[0].message.content.strip()
except Exception as e:
print(f"단락 번역 오류: {e}")
return f"[번역 오류: {str(e)}]"
def extract_paragraphs_from_docx(file_path: str) -> List[str]:
"""Word 문서에서 단락별 텍스트 추출"""
try:
doc = DocxDocument(file_path)
paragraphs = []
for paragraph in doc.paragraphs:
if paragraph.text.strip():
paragraphs.append(paragraph.text.strip())
return paragraphs
except Exception as e:
raise HTTPException(status_code=400, detail=f"Word 파일 읽기 오류: {str(e)}")
def create_bilingual_docx(original_paragraphs: List[str], translated_paragraphs: List[str], output_path: str):
"""원본과 번역이 번갈아 나타나는 이중언어 Word 문서 생성"""
try:
doc = DocxDocument()
# 각 단락별로 원본과 번역을 번갈아 추가
for i, (original, translated) in enumerate(zip(original_paragraphs, translated_paragraphs)):
# 원본 단락 추가 (굵은 글씨로)
original_para = doc.add_paragraph()
original_run = original_para.add_run(original)
original_run.bold = True
# 번역 단락 추가 (일반 글씨로)
translated_para = doc.add_paragraph(translated)
# 단락 사이에 여백 추가 (마지막 단락이 아닌 경우)
if i < len(original_paragraphs) - 1:
doc.add_paragraph("") # 빈 줄 추가
doc.save(output_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"이중언어 파일 생성 오류: {str(e)}")
def prepare_context(question: str, vector_store=None, model: str = "external") -> str:
"""채팅용 컨텍스트 준비 (실시간 번역)"""
if not question.strip():
return ""
# 모델 선택에 따른 번역 처리
translated = translate_text_with_gpt(question)
return translated
# FastAPI Router
router = APIRouter()
@router.post("/upload_doc")
async def upload_document(files: List[UploadFile] = File(...)):
"""MS Word 문서 업로드 및 번역 처리"""
if not files:
raise HTTPException(status_code=400, detail="파일이 업로드되지 않았습니다.")
processed_files = []
for file in files:
# MS Word 파일 확인
if not (file.filename.lower().endswith('.doc') or file.filename.lower().endswith('.docx')):
raise HTTPException(status_code=400, detail="MS Word 파일(.doc, .docx)만 업로드 가능합니다.")
# 파일명 생성: [doc_translation]_admin_YYYYMMDDHHMMSSMMM_원본파일명
timestamp = datetime.now().strftime('%Y%m%d%H%M%S%f')[:-3] # 밀리초 3자리까지
original_name = os.path.splitext(file.filename)[0]
extension = os.path.splitext(file.filename)[1]
uploaded_filename = f"[{TOOL_ID}]_admin_{timestamp}_{original_name}{extension}"
uploaded_path = os.path.join(UPLOAD_DIR, uploaded_filename)
# 파일 저장
try:
with open(uploaded_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# 단락별 텍스트 추출
paragraphs = extract_paragraphs_from_docx(uploaded_path)
if not paragraphs:
raise HTTPException(status_code=400, detail="문서에 번역할 텍스트가 없습니다.")
# 각 단락을 개별적으로 번역
translated_paragraphs = []
for paragraph in paragraphs:
translated = translate_paragraph(paragraph)
translated_paragraphs.append(translated)
# 이중언어 문서 저장 (원본 + 번역)
result_filename = f"[{TOOL_ID}]_admin_{timestamp}_{original_name}_결과{extension}"
result_path = os.path.join(UPLOAD_DIR, result_filename)
create_bilingual_docx(paragraphs, translated_paragraphs, result_path)
processed_files.append({
"original_filename": uploaded_filename,
"result_filename": result_filename,
"status": "success"
})
except Exception as e:
# 업로드된 파일이 있다면 삭제
if os.path.exists(uploaded_path):
os.remove(uploaded_path)
raise HTTPException(status_code=500, detail=f"파일 처리 오류: {str(e)}")
return {"status": "success", "files": processed_files}
@router.get("/files")
async def list_files():
"""업로드된 파일 목록 조회"""
try:
files = []
for filename in os.listdir(UPLOAD_DIR):
if filename.startswith(f"[{TOOL_ID}]") and not filename.endswith("_결과.doc") and not filename.endswith("_결과.docx"):
file_path = os.path.join(UPLOAD_DIR, filename)
stat = os.stat(file_path)
# 결과 파일 존재 여부 확인
# 파일명에서 확장자 분리
base_name, ext = os.path.splitext(filename)
result_filename = f"{base_name}_결과{ext}"
result_exists = os.path.exists(os.path.join(UPLOAD_DIR, result_filename))
files.append({
"filename": filename,
"upload_time": datetime.fromtimestamp(stat.st_ctime).isoformat(),
"size": stat.st_size,
"has_result": result_exists,
"result_filename": result_filename if result_exists else None
})
# 업로드 시간 순으로 정렬 (최신순)
files.sort(key=lambda x: x["upload_time"], reverse=True)
return files
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 목록 조회 오류: {str(e)}")
@router.get("/download/{filename}")
async def download_file(filename: str):
"""번역된 파일 다운로드"""
file_path = os.path.join(UPLOAD_DIR, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
return FileResponse(
path=file_path,
filename=filename,
media_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
)
@router.post("/translate_chat")
async def translate_chat(message: str = Form(...)):
"""실시간 채팅 번역"""
if not message.strip():
raise HTTPException(status_code=400, detail="번역할 메시지가 없습니다.")
try:
translated = translate_text_with_gpt(message)
return {"translated_text": translated}
except Exception as e:
raise HTTPException(status_code=500, detail=f"번역 오류: {str(e)}")
@router.delete("/files/{filename}")
async def delete_file(filename: str):
"""파일 삭제 (서버 저장 파일명으로)"""
file_path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(file_path):
os.remove(file_path)
# 해당하는 결과 파일도 삭제
if not filename.endswith("_결과.doc") and not filename.endswith("_결과.docx"):
base_name, ext = os.path.splitext(filename)
result_filename = f"{base_name}_결과{ext}"
result_path = os.path.join(UPLOAD_DIR, result_filename)
if os.path.exists(result_path):
os.remove(result_path)
return {"status": "success", "message": "파일이 삭제되었습니다."}
@router.delete("/delete_by_original_name/{original_filename}")
async def delete_file_by_original_name(original_filename: str):
"""파일 삭제 (원본 파일명으로) - 외부 접근용"""
try:
# 업로드된 파일 중에서 원본 파일명과 일치하는 파일 찾기
deleted_files = []
for filename in os.listdir(UPLOAD_DIR):
if filename.startswith(f"[{TOOL_ID}]"):
# 실제 파일명 추출
pattern = r'^\[doc_translation\]_admin_\d{14,17}_(.+)$'
match = re.match(pattern, filename)
if match and match.group(1) == original_filename:
# 원본 파일 삭제
file_path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(file_path):
os.remove(file_path)
deleted_files.append(filename)
# 결과 파일도 삭제
if not filename.endswith("_결과.doc") and not filename.endswith("_결과.docx"):
base_name, ext = os.path.splitext(filename)
result_filename = f"{base_name}_결과{ext}"
result_path = os.path.join(UPLOAD_DIR, result_filename)
if os.path.exists(result_path):
os.remove(result_path)
deleted_files.append(result_filename)
if deleted_files:
return {"status": "success", "message": f"파일이 삭제되었습니다.", "deleted_files": deleted_files}
else:
return {"status": "error", "message": "해당하는 파일을 찾을 수 없습니다."}
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 삭제 오류: {str(e)}")
@router.get("/list_files")
async def list_all_files():
"""모든 파일 목록 조회 (원본 파일명으로) - 외부 접근용"""
try:
files = []
for filename in os.listdir(UPLOAD_DIR):
if filename.startswith(f"[{TOOL_ID}]") and not filename.endswith("_결과.doc") and not filename.endswith("_결과.docx"):
# 실제 파일명 추출
pattern = r'^\[doc_translation\]_admin_\d{14,17}_(.+)$'
match = re.match(pattern, filename)
if match:
original_name = match.group(1)
file_path = os.path.join(UPLOAD_DIR, filename)
stat = os.stat(file_path)
# 결과 파일 존재 여부 확인
base_name, ext = os.path.splitext(filename)
result_filename = f"{base_name}_결과{ext}"
result_exists = os.path.exists(os.path.join(UPLOAD_DIR, result_filename))
files.append({
"original_filename": original_name,
"server_filename": filename,
"upload_time": datetime.fromtimestamp(stat.st_ctime).isoformat(),
"size": stat.st_size,
"has_result": result_exists,
"result_filename": result_filename if result_exists else None
})
# 업로드 시간 순으로 정렬 (최신순)
files.sort(key=lambda x: x["upload_time"], reverse=True)
return {"status": "success", "files": files}
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 목록 조회 오류: {str(e)}")

Binary file not shown.

View File

@@ -0,0 +1,57 @@
// AIService.js
// -----------------------------------------------------------------------------
// 백엔드 `/chat` 엔드포인트 호출 전용 모듈.
// - question : 사용자가 보낸 텍스트
// - toolId : 엔진 ID (ex: dev_chatbot)
// - sessionId : 세션 지속용 ID (null이면 서버가 새로 발급)
// - files : 첨부 파일 배열
// 응답 형태 { response, sessionId, toolName }
// 프론트엔드의 ChatHandler / ChatInput 등에서 재사용한다.
// -----------------------------------------------------------------------------
// 실제 운영환경에서는 REACT_APP_* 형태의 .env 값을 사용하도록 권장.
const OPENAI_API_KEY = process.env.REACT_APP_OPENAI_API_KEY;
// FastAPI 서버 주소 필요시 프록시 / env 로 분리.
const API_BASE_URL = 'http://localhost:8010';
/**
* 챗봇 API 호출 함수 (multipart/form-data)
* @param {string} question - 사용자 질문
* @param {string} toolId - 엔진 ID
* @param {string|null} sessionId- 세션 ID (옵션)
* @param {File[]} files - 첨부 파일 배열
* @returns {Promise<{response:string, sessionId:string, toolName:string}>}
*/
const AIService = async (question, toolId, sessionId = null, files = []) => {
try {
// ------------ FormData 구성 -------------
const formData = new FormData();
formData.append('message', question);
formData.append('tool_id', toolId);
if (sessionId) formData.append('session_id', sessionId);
files.forEach(file => formData.append('image', file));
// ------------- Fetch -------------------
const response = await fetch(`${API_BASE_URL}/chat`, {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return {
response : data.response?.trim() || 'AI 응답을 생성하지 못했습니다.',
sessionId: data.session_id,
toolName : data.tool_name,
};
} catch (e) {
console.error('AI 서비스 오류:', e);
return { response: 'AI 응답을 생성하지 못했습니다.', sessionId: null, toolName: '' };
}
};
export default AIService;

View File

@@ -0,0 +1,40 @@
// ChatHandler.js
// -----------------------------------------------------------------------------
// 1) 이미지가 있을 경우 OCRService 로 텍스트 추출
// 2) 최종 question(원문 + OCR 텍스트) 을 AIService 로 전달
// 3) AIService 응답을 그대로 반환하여 상위 컴포넌트(ChatInput)에서 처리
// -----------------------------------------------------------------------------
import OCRService from './OCRService';
import AIService from './AIService';
/**
* 사용자의 입력(text + image)을 받아 AIService 로 요청하는 헬퍼.
* @param {Object} params
* @param {string} params.text - 사용자가 입력한 텍스트
* @param {File[]} params.files - 첨부 파일 배열(선택)
* @param {string} params.toolId - 호출할 엔진 ID
* @param {string=} params.sessionId - 세션 ID (선택)
* @returns {Promise<{response:string, sessionId:string, toolName:string}>}
*/
const ChatHandler = async ({ text, files = [], toolId, sessionId = null }) => {
let question = text;
// 이미지 파일이 있으면 OCR 실행 후 question 에 병합
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (imageFiles.length) {
const ocrTexts = [];
for (const img of imageFiles) {
const ocrText = await OCRService(img);
if (ocrText) ocrTexts.push(ocrText);
}
if (ocrTexts.length) {
question = `${text}\n\n이미지에서 추출한 텍스트:\n${ocrTexts.join('\n')}`;
}
}
// AI 서비스 호출
return AIService(question, toolId, sessionId, files);
};
export default ChatHandler;

View File

@@ -0,0 +1,93 @@
// ChatInput.jsx (간단한 상태 기반 데모 컴포넌트)
// -----------------------------------------------------------------------------
// • inputText / inputImage 상태 관리
// • ChatHandler 호출 후 messages 배열에 push
// • very minimal UI (실제 서비스에서는 스타일·UX 개선 필요)
// -----------------------------------------------------------------------------
import React, { useState } from 'react';
import ChatHandler from './ChatHandler';
const ChatInput = () => {
// ---------------- state ----------------
const [inputText, setInputText] = useState('');
const [selectedFiles, setSelectedFiles] = useState([]); // File[]
const [messages, setMessages] = useState([]); // {text, files, sender}
const [loading, setLoading] = useState(false);
// ------------- handlers ---------------
const handleTextChange = e => setInputText(e.target.value);
const handleFilesChange = e => {
const files = Array.from(e.target.files || []);
if (files.length) {
setSelectedFiles(prev => [...prev, ...files]);
}
};
const handleFileRemove = idx => {
setSelectedFiles(prev => prev.filter((_, i) => i !== idx));
};
// 채팅 보내기
const handleSend = async () => {
if (!inputText && selectedFiles.length === 0) return;
setLoading(true);
// 1) 사용자 메시지 화면에 표시
const userMessage = { text: inputText, files: selectedFiles, sender: 'user' };
setMessages(prev => [...prev, userMessage]);
// 2) ChatHandler 로 AI 답변 요청
const { response } = await ChatHandler({
text : inputText,
files : selectedFiles,
toolId : 'dev_chatbot', // 데모용 고정
});
// 3) AI 응답 메시지 push
setMessages(prev => [...prev, userMessage, { text: response, sender: 'ai' }]);
// 4) 입력 초기화
setInputText('');
setSelectedFiles([]);
setLoading(false);
};
// ---------------- render --------------
return (
<div>
<div style={{ minHeight: 200, border: '1px solid #ccc', marginBottom: 10, padding: 10 }}>
{messages.map((msg, idx) => (
<div key={idx} style={{ marginBottom: 8 }}>
<b>{msg.sender === 'user' ? '나' : 'AI'}:</b> {msg.text}
{msg.files && msg.files.length > 0 && (
<ul style={{ marginTop: 4 }}>
{msg.files.map((file, fIdx) => (
<li key={fIdx}>{file.name}</li>
))}
</ul>
)}
</div>
))}
{loading && <div>AI가 응답을 생성하고 있습니다...</div>}
</div>
{/* 입력 영역 */}
<input type="text" value={inputText} onChange={handleTextChange} placeholder="메시지를 입력하세요" style={{ width: 300 }} />
<input type="file" multiple onChange={handleFilesChange} />
{selectedFiles.length > 0 && (
<ul style={{ marginTop: 4 }}>
{selectedFiles.map((file, idx) => (
<li key={idx}>
{file.name} <button type="button" onClick={() => handleFileRemove(idx)}>삭제</button>
</li>
))}
</ul>
)}
<button onClick={handleSend} disabled={loading}>전송</button>
</div>
);
};
export default ChatInput;

View File

@@ -0,0 +1,22 @@
// OCRService.js 브라우저에서 동작하는 간단 OCR 래퍼(Tesseract.js)
// -----------------------------------------------------------------------------
// 이미지 File 객체를 받아 한국어+영어 텍스트를 추출하여 반환한다.
// 주의: 브라우저 워커 기반이므로 큰 이미지·다중 호출 시 성능 이슈가 있을 수 있다.
// -----------------------------------------------------------------------------
import Tesseract from 'tesseract.js';
/**
* @param {File} imageFile - 이미지 파일 객체
* @returns {Promise<string>} 추출 텍스트 (trim 처리)
*/
const OCRService = async (imageFile) => {
try {
const { data: { text } } = await Tesseract.recognize(imageFile, 'kor+eng');
return text.trim();
} catch (e) {
return '';
}
};
export default OCRService;

View File

@@ -0,0 +1,5 @@
# LIMS Text2SQL 프론트엔드
이 폴더는 LIMS Text2SQL 엔진을 위한 전용 프론트엔드 자바스크립트/컴포넌트를 보관합니다.
현재 메인 UI에서는 외부 서비스(iframe)로 연결하므로 파일이 비어 있습니다.
필요 시 이곳에 컴포넌트를 추가하세요.

View File

@@ -0,0 +1,5 @@
# 연구QA 프론트엔드
이 디렉터리에는 연구QA 엔진 전용 프론트엔드 코드가 들어갑니다.
현재 `index.html` 의 iframe 접근 방식으로 동작하므로 별도 UI 컴포넌트는 필요하지 않습니다.
향후 기능 고도화 시 이곳에 React/Vue 컴포넌트를 추가하세요.

118
index.html Normal file
View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>엔큐톡 AI엔진</title>
<link rel="stylesheet" href="style.css">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="main.js" defer></script>
</head>
<body>
<div class="container">
<aside class="sidebar">
<div class="logo">엔큐톡</div>
<nav class="menu">
<ul>
<li class="menu-item active" data-page="chat">채팅</li>
<li class="menu-item" data-page="tools">도구</li>
<li class="menu-item" data-page="lecture">강의</li>
<li class="menu-item" data-page="community">커뮤니티</li>
</ul>
</nav>
</aside>
<main class="main-content">
<header class="topbar">
<span class="page-title">채팅</span>
<button class="login-btn">로그인</button>
</header>
<section class="page page-chat active">
<div class="file-sidebar" style="display:none;">
<div class="file-sidebar-header" style="display:flex; justify-content:space-between; align-items:center;">
<span>파일 리스트</span>
<button class="file-add-btn" style="font-size:18px; padding:0 8px;">+</button>
</div>
<ul class="file-list"></ul>
</div>
<div class="chat-center">
<div class="chat-logo-wrapper">
<div class="chat-logo"></div>
<div class="chat-title">엔큐톡</div>
</div>
</div>
<div class="file-slide" style="display:none;">
<button class="file-slide-close">×</button>
<iframe class="file-viewer" style="width:100%; height:100%; border:none;"></iframe>
</div>
<!-- 모델 선택 드롭다운 삭제 -->
<!-- <div class="model-select-area" style="margin: 0 0 8px 0; text-align: right;">
<label for="model-select" style="font-size: 14px; margin-right: 6px;">모델 선택:</label>
<select id="model-select" style="font-size: 14px; padding: 2px 8px;">
<option value="exone3.5">exone3.5</option>
<option value="gpt">gpt</option>
</select>
</div> -->
<div class="chat-input-wrapper">
<div class="image-preview-area" style="display:none;"></div>
<div class="chat-input-area">
<!-- 통합 모델 선택 셀렉트 박스 -->
<select class="model-select" style="margin-right:12px; height:36px; border-radius:12px; border:1px solid #ffd6c2; color:#ff6b4a; font-size:1rem;">
<option value="auto">Auto</option>
<option value="gpt-5">GPT-5</option>
</select>
<!-- 개발챗봇 전용 모델 선택 -->
<select class="gc-model-select" style="margin-right:12px; height:36px; border-radius:12px; border:1px solid #ffd6c2; color:#ff6b4a; font-size:1rem; display:none;">
<option value="woong">자체모델</option>
</select>
<select class="knowledge-mode" style="margin-right:12px; height:36px; border-radius:12px; border:1px solid #ffd6c2; color:#ff6b4a; font-size:1rem; display:none;">
<option value="kb_only">지식베이스</option>
<!--<option value="hybrid">혼합</option>-->
</select>
<!-- 문서번역 전용 모델 선택 -->
<select class="dt-model-select" style="margin-right:12px; height:36px; border-radius:12px; border:1px solid #ffd6c2; color:#ff6b4a; font-size:1rem; display:none;">
<option value="internal">자체모델</option>
<option value="external">외부모델</option>
</select>
<div contenteditable="true" class="chat-input" data-placeholder="메시지 입력"></div>
<input type="file" class="chat-file-input" accept="image/*,application/pdf" multiple style="display:none;">
<button class="chat-send" disabled></button>
</div>
</div>
<!-- footer 제거 -->
</section>
<section class="page page-tools">
<div class="tools-header">
<span class="tools-title">도구 목록</span>
<div class="tools-filters">
<button class="filter-btn active" data-filter="all">전체</button>
<button class="filter-btn" data-filter="favorite">즐겨찾기</button>
<button class="filter-btn" data-filter="오픈AI">오픈AI</button>
<button class="filter-btn" data-filter="내부AI">내부AI</button>
</div>
</div>
<div class="tools-list">
<!-- 도구 카드가 동적으로 들어감 -->
</div>
</section>
<section class="page page-lecture">
<div style="padding:40px; font-size:1.1rem;">강의 콘텐츠가 준비 중입니다.</div>
</section>
<section class="page page-community">
<div class="community-container">
<aside class="community-sidebar">
<ul>
<li class="community-menu-item active" data-content="newsletter">뉴스레터</li>
<li class="community-menu-item" data-content="qna">질의응답</li>
</ul>
</aside>
<main class="community-content">
<!-- 기본 컨텐츠: 뉴스레터 -->
<div style="padding:24px;">뉴스레터 콘텐츠가 준비 중입니다.</div>
</main>
</div>
</section>
<!-- 기타 메뉴 추가 예정 -->
</main>
</div>
</body>
</html>

1081
main.js Normal file

File diff suppressed because it is too large Load Diff

34
requirements.txt Normal file
View File

@@ -0,0 +1,34 @@
# 문서번역에 필요한 핵심 패키지들만
fastapi
uvicorn
python-multipart
python-dotenv
openai>=1.0.0
python-docx
langchain-ollama
Pillow
# paddleocr, paddlepaddle 제거
langchain
langchain-chroma
langchain-community
langchain-core
langchain-ollama
langchain-openai
langchain-text-splitters
langgraph
psycopg2-binary
defusedxml
Sphinx
sphinx-rtd-theme
sphinxcontrib-applehelp
sphinxcontrib-devhelp
sphinxcontrib-htmlhelp
sphinxcontrib-jquery
sphinxcontrib-jsmath
beautifulsoup4
sphinxcontrib-qthelp
sphinxcontrib-serializinghtml
#pip install --no-deps pdfservices-sdk
pdfservices-sdk

3
scripts/README.md Normal file
View File

@@ -0,0 +1,3 @@
# scripts
오프라인 처리를 위한 쉘 스크립트 모음

Binary file not shown.

171
scripts/gxp_bulk_ingest.py Normal file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""gxp_bulk_ingest.py
Bulk-ingest GxP PDF files into the GxP Vector DB.
Usage::
python scripts/gxp_bulk_ingest.py [--dir PATH_TO_PDFS]
If no --dir given, defaults to scripts/gxp/ .
The script will:
1. Recursively scan the directory for *.pdf files.
2. For each file, run the PDF-Plumber extraction via GxPDocumentPreprocessingService.
3. Send the extracted result to GxPVectorDBService.construct_vector_db().
This bypasses the HTTP API layer and calls the internal services directly, so it must
be run in the project root (or ensure PYTHONPATH includes project root).
"""
# 표준 라이브러리
from pathlib import Path
import argparse
import sys
import os
from typing import Set
# 외부 라이브러리 (런타임에 없으면 requirements.txt 참고)
from langchain_openai import OpenAIEmbeddings
# Ensure backend path importable
ROOT = Path(__file__).resolve().parents[1]
BACKEND_PATH = ROOT / "backend"
sys.path.append(str(BACKEND_PATH))
# 내부 서비스 모듈
from engines.chatbot_gxp.service.GxPDocumentPreprocessingService import (
GxPDocumentPreprocessingService,
)
from engines.chatbot_gxp.service.GxPVectorDBService import GxPVectorDBService
# ---------------------------------------------------------------------------
# 유틸리티
# ---------------------------------------------------------------------------
def ensure_ollama() -> None:
"""Ollama 서버가 동작 중인지 사전 확인한다.
임베딩 테스트가 실패하면 즉시 종료한다.
"""
try:
_ = OpenAIEmbeddings().embed_query("ping")
except Exception as exc: # pylint: disable=broad-except
print(
"[!] Ollama 서버에 연결할 수 없습니다. 'ollama serve'가 실행 중인지 확인하세요.\n",
f" 상세 오류: {exc}",
sep="",
)
sys.exit(1)
def ingest_pdfs(
pdf_dir: Path,
*,
skip_existing: bool = False,
reindex_existing: bool = False,
) -> None:
"""디렉터리 내 PDF를 벡터 DB 에 일괄 인덱싱한다."""
pre_service = GxPDocumentPreprocessingService()
vec_service = GxPVectorDBService()
# 기존 컬렉션 목록 캐싱
existing_collections: Set[str] = {
col["name"] for col in vec_service._list_collections() # type: ignore
}
pdf_files = list(pdf_dir.rglob("*.pdf"))
if not pdf_files:
print(f"[!] No PDF files found in {pdf_dir}")
return
stats = {"indexed": 0, "skipped": 0, "failed": 0}
for pdf_path in pdf_files:
rel_path = pdf_path.relative_to(ROOT)
print(f"[+] Processing {rel_path}")
try:
# 1단계: 전처리
doc = pre_service.pdf_plumber_edms_document_text_extraction(str(pdf_path))
# 2단계: 컬렉션 이름 계산 후 존재 여부 판단
raw_name = f"gxp_{doc.get('plant', 'default')}_{doc.get('filename', 'document')}"
collection_name = vec_service._sanitize_collection_name(raw_name) # type: ignore
if collection_name in existing_collections:
if skip_existing:
print(" ↩︎ skip (already indexed)")
stats["skipped"] += 1
continue
if reindex_existing:
print(" collection exists → 삭제 후 재인덱싱")
vec_service.delete_collection(collection_name)
existing_collections.remove(collection_name)
# 3단계: 벡터 DB 구축
ok = vec_service.construct_vector_db(doc)
if ok:
print(" ✔ indexed")
stats["indexed"] += 1
existing_collections.add(collection_name)
else:
print(" ✖ service returned False")
stats["failed"] += 1
except Exception as exc: # pylint: disable=broad-except
print(f" ✖ failed: {exc}")
stats["failed"] += 1
# 요약 통계 출력
print("\n──────── 요약 통계 ────────")
for k, v in stats.items():
print(f"{k:8}: {v}")
def main() -> None:
"""엔트리 포인트"""
parser = argparse.ArgumentParser(
description="Bulk ingest GxP PDFs into Chroma vector DB",
)
parser.add_argument(
"--dir",
type=str,
default=str(ROOT / "scripts" / "gxp"),
help="Directory containing PDF files (default: scripts/gxp)",
)
excl = parser.add_mutually_exclusive_group()
excl.add_argument(
"--skip-existing",
action="store_true",
help="Skip PDFs whose collection already exists",
)
excl.add_argument(
"--reindex",
action="store_true",
help="Delete existing collection then reindex",
)
args = parser.parse_args()
pdf_dir = Path(args.dir).expanduser().resolve()
if not pdf_dir.is_dir():
print(f"Directory not found: {pdf_dir}")
sys.exit(1)
ensure_ollama()
ingest_pdfs(
pdf_dir,
skip_existing=args.skip_existing,
reindex_existing=args.reindex,
)
if __name__ == "__main__":
main()

839
style.css Normal file
View File

@@ -0,0 +1,839 @@
/* prevent horizontal layout shift due to scrollbar */
html, body {
overflow-y: scroll;
}
body {
margin: 0;
padding: 0;
font-family: 'Noto Sans KR', sans-serif;
background: #FFF3E0;
color: #222;
}
.container {
display: flex;
height: 100vh;
}
.sidebar {
width: 80px;
background: linear-gradient(180deg, #FFE0B2 0%, #FFCC80 100%);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 20px;
box-shadow: 1px 0 8px #FFD180;
}
.logo {
font-size: 2rem;
font-weight: bold;
color: #FF9800;
margin-bottom: 40px;
letter-spacing: 2px;
}
.menu {
width: 100%;
}
.menu ul {
list-style: none;
padding: 0;
margin: 0;
}
.menu-item {
padding: 18px 0;
text-align: center;
cursor: pointer;
color: #333;
font-size: 1.1rem;
transition: background 0.2s, color 0.2s;
}
.menu-item.active, .menu-item:hover {
background: #FFF3E0;
color: #FF9800;
font-weight: bold;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: #FFF3E0;
min-width: 0;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px 0 24px;
background: transparent;
height: 60px;
}
.page-title {
font-size: 1.3rem;
font-weight: bold;
}
.login-btn {
background: none;
border: 1px solid #b2b2b2;
border-radius: 16px;
padding: 6px 18px;
color: #00b6f0;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.login-btn:hover {
background: #e6f7ff;
}
.page {
display: none;
flex: 1;
flex-direction: column;
height: 100%;
}
.page.active {
display: flex;
}
/* 채팅 화면 */
.page-chat {
justify-content: center;
align-items: center;
position: relative;
}
.chat-center {
position: absolute;
top: 35%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
max-height: calc(100vh - 200px);
padding: 20px;
width: 100%;
max-width: min(800px, calc(95vw - 80px)); /* 메인 사이드바(80px) 고려 */
}
.chat-logo-wrapper {
position: relative;
width: 100px;
height: 100px;
margin-bottom: 10px;
}
.chat-logo {
width: 100px;
height: 100px;
background: radial-gradient(circle, #FF9800 70%, #FFE0B2 100%);
border-radius: 50%;
box-shadow: 0 0 16px #FFD180;
position: relative;
z-index: 1;
}
.chat-title {
font-size: 2rem;
font-weight: 700;
color: #222;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
margin: 0;
z-index: 2;
pointer-events: none;
writing-mode: initial;
text-align: center;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.chat-subtitle {
font-size: 0.9rem;
color: #888;
text-align: center;
margin-top: 8px;
}
.chat-input-wrapper {
width: 700px;
max-width: calc(95vw - 80px); /* 메인 사이드바(80px) 고려 */
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
}
.chat-input-area {
display: flex;
align-items: center;
background: #fff;
border-radius: 24px;
box-shadow: 0 2px 8px #FFD180;
padding: 8px 16px;
width: 100%;
max-width: 100%;
}
.chat-input {
flex: 1;
border: none;
outline: none;
font-size: 1rem;
padding: 8px 10px;
background: #fff;
color: #222;
resize: none;
min-height: 32px;
max-height: 160px;
line-height: 1.5;
box-sizing: border-box;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.chat-input:empty:before {
content: attr(data-placeholder);
color: #bbb;
pointer-events: none;
}
.chat-plus, .chat-hash, .chat-send {
background: none;
border: none;
font-size: 1.2rem;
margin-left: 8px;
cursor: pointer;
color: #FF9800;
border-radius: 50%;
width: 32px;
height: 32px;
transition: background 0.2s;
}
.chat-send:disabled {
color: #FF9800;
cursor: not-allowed;
}
.footer {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 18px;
font-size: 0.95rem;
color: #b2b2b2;
}
/* 도구 화면 */
.page-tools {
padding: 24px 32px 0 32px;
flex-direction: column;
align-items: flex-start;
}
.tools-header {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 18px;
}
.tools-title {
font-size: 1.2rem;
font-weight: bold;
margin-right: 24px;
}
.tools-filters {
display: flex;
gap: 10px;
}
.filter-btn {
background: #fff;
border: 1px solid #FFE0B2;
border-radius: 16px;
padding: 6px 16px;
color: #FF9800;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.filter-btn.active, .filter-btn:hover {
background: #FFCC80;
color: #FF9800;
font-weight: bold;
}
.tools-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px;
width: 100%;
}
.tool-card {
position: relative;
background: #FFF8E1;
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px #FFE0B2;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.tool-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px #FFD180;
border-color: #FFB74D;
}
.tool-card.selected {
border-color: #ff6b4a;
background: linear-gradient(135deg, #fff6f3 0%, #fff 100%);
box-shadow: 0 4px 20px rgba(255, 107, 74, 0.2);
}
.tool-card:active {
transform: translateY(0);
box-shadow: 0 2px 8px #ffe0d3;
}
.tool-title {
font-size: 1.1rem;
font-weight: bold;
color: #222;
}
.tool-desc {
font-size: 0.98rem;
color: #666;
}
.tool-fav {
position: absolute;
top: 14px;
right: 14px;
font-size: 1.2rem;
color: #FFD6C2;
cursor: pointer;
transition: color 0.2s;
}
.tool-fav.active {
color: #FF9800;
}
@media (max-width: 900px) {
.tools-list {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.chat-input-wrapper {
width: calc(95vw - 80px); /* 메인 사이드바 고려 */
min-width: 0;
}
.chat-input-area {
width: 100%;
min-width: 0;
}
.image-preview-area {
width: 100%;
min-width: 0;
}
/* 모바일에서 파일 사이드바 너비 축소 */
.file-sidebar {
width: 200px;
}
.page-chat.has-file-sidebar .chat-center {
left: calc(50% + 100px); /* 축소된 파일 사이드바(200px)의 절반만큼 이동 */
max-width: calc(95vw - 280px); /* 메인 사이드바(80px) + 파일 사이드바(200px) 고려 */
transform: translate(-50%, -50%);
}
.page-chat.has-file-sidebar .chat-input-wrapper {
left: calc(50% + 100px); /* 축소된 파일 사이드바(200px)의 절반만큼 이동 */
max-width: calc(95vw - 280px); /* 메인 사이드바(80px) + 파일 사이드바(200px) 고려 */
width: calc(100% - 200px); /* 축소된 파일 사이드바 영역 제외 */
transform: translateX(-50%);
}
}
/* 아주 작은 화면에서 추가 여백 확보 */
@media (max-width: 600px) {
.chat-input-wrapper {
width: calc(90vw - 80px); /* 더 많은 여백 확보 */
max-width: calc(90vw - 80px);
}
/* 작은 화면에서 파일 사이드바 더 축소 */
.file-sidebar {
width: 180px;
}
.page-chat.has-file-sidebar .chat-center {
left: calc(50% + 90px); /* 더 축소된 파일 사이드바(180px)의 절반만큼 이동 */
max-width: calc(90vw - 260px); /* 메인 사이드바(80px) + 파일 사이드바(180px) 고려 */
transform: translate(-50%, -50%);
}
.page-chat.has-file-sidebar .chat-input-wrapper {
left: calc(50% + 90px); /* 더 축소된 파일 사이드바(180px)의 절반만큼 이동 */
max-width: calc(90vw - 260px); /* 메인 사이드바(80px) + 파일 사이드바(180px) 고려 */
width: calc(100% - 180px); /* 더 축소된 파일 사이드바 영역 제외 */
transform: translateX(-50%);
}
}
/* 채팅 메시지 스타일 */
.chat-message {
margin: 10px 0;
padding: 10px 15px;
border-radius: 15px;
max-width: 80%;
word-wrap: break-word;
position: relative;
}
.user-message {
background-color: #007bff;
color: white;
margin-left: auto;
border-bottom-right-radius: 5px;
}
.bot-message {
background-color: #FFF7ED; /* 주 배경 톤과 어울리는 밝은 주황 계열 */
color: #333;
margin-right: auto;
border-bottom-left-radius: 5px;
border: 1px solid #FFE0B2;
}
.message-content {
margin-bottom: 5px;
line-height: 1.4;
}
.message-time {
font-size: 0.75rem;
opacity: 0.7;
text-align: right;
}
.user-message .message-time {
text-align: left;
}
/* 로딩 애니메이션 */
@keyframes typing {
0%, 20% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
.bot-message.loading .message-content::after {
content: '...';
animation: typing 1.5s infinite;
}
/* 이미지 미리보기 영역 */
.image-preview-area {
margin-bottom: 8px;
width: 100%;
max-width: 100%;
}
.image-preview-box {
display: flex;
align-items: center;
background: #f8f9fa;
border: 1px solid #eee;
border-radius: 12px;
padding: 8px 12px;
position: relative;
min-width: 120px;
max-width: 220px;
box-shadow: 0 2px 8px #ffe0d3;
}
.image-preview-thumb {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 10px;
background: #fff;
border: 1px solid #eee;
}
.image-preview-info {
display: flex;
flex-direction: column;
font-size: 0.95rem;
color: #444;
}
.image-preview-name {
font-weight: 500;
margin-bottom: 2px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-preview-size {
font-size: 0.85rem;
color: #888;
}
.image-preview-remove {
position: absolute;
top: 6px;
right: 6px;
background: #fff0ee;
border: none;
color: #FF9800;
font-size: 1.1rem;
border-radius: 50%;
width: 22px;
height: 22px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.image-preview-remove:hover {
background: #FF9800;
color: #fff;
}
/* 도구 채팅 헤더 */
.tool-chat-header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
box-shadow: 0 2px 12px rgba(255, 107, 74, 0.1);
}
.tool-chat-title {
font-size: 1.4rem;
font-weight: bold;
color: #FF9800;
margin-bottom: 8px;
}
.tool-chat-desc {
font-size: 0.9rem;
color: #666;
line-height: 1.4;
}
.progress-bar {
width: 60px;
height: 6px;
background: #eee;
border-radius: 3px;
margin-top: 6px;
overflow: hidden;
}
.progress-bar-inner {
width: 100%;
height: 100%;
background: linear-gradient(90deg, #FF9800 0%, #FFD6C2 100%);
animation: progressBarAnim 1s linear infinite;
}
@keyframes progressBarAnim {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.model-toggle-group {
display: flex;
align-items: center;
gap: 4px;
margin-right: 8px;
}
.model-toggle-btn {
background: #FFE0D3;
border: 1.5px solid #FFD6C2;
color: #FF9800;
font-weight: 500;
border-radius: 16px;
padding: 4px 16px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s, color 0.2s, border 0.2s;
outline: none;
}
.model-toggle-btn.active,
.model-toggle-btn:focus {
background: #FF9800;
color: #fff;
border: 1.5px solid #FF9800;
}
.model-toggle-btn:hover:not(.active) {
background: #FFD6C2;
color: #FF9800;
}
.external-iframe {
width: 100%;
height: 100%;
border: none;
}
/* iframe 모드 시 채팅 영역 전체 확장 */
.chat-center.iframe-mode {
position: absolute;
top: 0;
left: 0;
transform: none;
width: 100%;
max-width: none;
height: 100%;
padding: 0;
}
/* 파일 사이드바 */
.file-sidebar {
position: absolute;
left: 0;
top: 0;
width: 240px;
height: 100%;
background: #fff8ef;
border-right: 1px solid #ffd6c2;
overflow-y: auto;
z-index: 3;
}
.file-sidebar-header {
padding: 12px;
font-weight: bold;
border-bottom: 1px solid #ffd6c2;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
}
.file-list li {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #ffe8d5;
display:flex;
justify-content: space-between;
align-items:center;
}
.file-del-btn{
background:none;
border:none;
color:#ff6b4a;
cursor:pointer;
font-size:1rem;
}
/* (추가) 긴 파일명도 버튼이 가려지지 않도록 처리 */
.file-list li span{
flex:1 1 auto; /* 남는 공간을 차지하면서 필요하면 축소 */
min-width:0; /* flex-shrink 가 동작하도록 최소폭 0 */
overflow:hidden; /* 넘치는 텍스트 숨김 */
text-overflow:ellipsis; /* 말줄임표 표시 */
white-space:nowrap; /* 한 줄 유지 */
}
.file-list li:hover { background: #fff1e6; }
/* 파일 슬라이드 팝업 */
.file-slide {
position: fixed;
left: 240px; /* 옆 사이드바 너비 */
bottom: -60%;
width: calc(100% - 240px);
height: 60%;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
overflow-y: auto;
transition: bottom 0.3s ease;
z-index: 4;
padding: 16px;
}
.file-slide.show { bottom: 0; }
.file-progress {
padding:8px 12px;
color:#aaa;
font-style:italic;
}
@keyframes pulseProgress {
0% { opacity: 0.3; }
50% { opacity: 1; }
100% { opacity: 0.3; }
}
.file-progress {
animation: pulseProgress 1.5s infinite;
}
.file-slide-close {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 6px; /* 팝업 내부 상단 */
width: 32px;
height: 32px;
background: #fff;
border: 2px solid #ff6b4a;
border-radius: 50%;
font-size: 1.3rem;
line-height: 28px;
text-align: center;
cursor: pointer;
color: #ff6b4a;
font-weight: bold;
z-index: 5;
}
.file-slide-close:hover { background:#ff6b4a; color:#fff; }
.file-slide-content {
white-space: pre-wrap;
font-family: "Noto Sans KR", sans-serif;
}
/* PDF 로딩 오버레이 */
.file-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.85);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: #ff6b4a;
font-style: italic;
z-index: 4; /* iframe 위에 표시 */
animation: pulseProgress 1.5s infinite;
}
/* --- Sidebar fixed width to prevent layout shift --- */
.file-sidebar {
flex: 0 0 240px;
}
.file-sidebar .file-list li {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- Community page adjustments --- */
.page-community {
overflow-y: visible; /* use outer body scrollbar only */
overflow-x: hidden;
}
.page-community.active {
display: block !important; /* show only when active; override .page.active flex */
}
#community-img { display:none; }
/* 커뮤니티 내부 레이아웃 */
.community-container { display:flex; height:100%; }
.community-sidebar { width:180px; background:#fff8ef; border-right:1px solid #ffd6c2; }
.community-sidebar ul{ list-style:none; margin:0; padding:0; }
.community-menu-item{ padding:12px 18px; cursor:pointer; color:#333; font-size:1rem; }
.community-menu-item.active,.community-menu-item:hover{ background:#fff1e6; color:#ff9800; font-weight:bold; }
.community-content{ flex:1; overflow-y:auto; }
.newsletter-list{ list-style:none; margin:0; padding:0 0 0 20px; }
.newsletter-list li{ padding:6px 0; }
.newsletter-list a{ color:#007acc; text-decoration:none; font-size:1rem; }
.newsletter-list a:hover{ text-decoration:underline; }
.newsletter-title{ margin:16px 0 8px 20px; font-size:1.4rem; }
.newsletter-content-area{ height: calc(100% - 56px); /* adapt below title */ }
.newsletter-pdf-wrapper{ position:relative; height:100%; }
.newsletter-iframe{ width:100%; height:100%; border:none; }
.newsletter-close{ position:absolute; top:-16px; left:50%; transform:translateX(-50%); width:32px; height:32px; background:#fff; border:2px solid #ff6b4a; border-radius:50%; font-size:1.3rem; line-height:26px; text-align:center; color:#ff6b4a; cursor:pointer; z-index:5; }
.newsletter-close:hover{ background:#ff6b4a; color:#fff; }
/* Lecture styles */
.lecture-title{ margin:16px 0 8px 20px; font-size:1.4rem; }
.lecture-topic{ margin:8px 0 12px 20px; font-size:1.2rem; color:#555; }
.lecture-video-list{ display:flex; flex-wrap:wrap; gap:12px; padding:0 0 0 20px; }
.lecture-video{ width:140px; display:block; }
.lecture-video img{ width:100%; border-radius:8px; box-shadow:0 2px 6px rgba(0,0,0,0.1); }
.lecture-video:hover img{ transform:scale(1.03); transition:transform .2s; }
/* QnA board */
.qna-title-head{ margin:16px 0 8px 20px; font-size:1.4rem; }
.qna-table{ width:calc(100% - 40px); margin:0 20px; border-collapse:collapse; }
.qna-table th,.qna-table td{ border-bottom:1px solid #eee; padding:10px; text-align:left; }
.qna-table th{ background:#fff8ef; font-weight:600; color:#333; }
.qna-table tr:hover td{ background:#fff1e6; }
.qna-title{ color:#007acc; cursor:pointer; }
.qna-title:hover{ text-decoration:underline; }
/* Document Translation styles */
.download-original-link,
.download-result-link {
color: #007acc;
text-decoration: none;
font-weight: bold;
margin-right: 6px;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #007acc;
font-size: 0.8rem;
display: inline-block;
}
.download-original-link:hover,
.download-result-link:hover {
background: #007acc;
color: #fff;
text-decoration: none;
}
.download-original-link {
background-color: #f8f9fa;
border-color: #6c757d;
color: #6c757d;
}
.download-original-link:hover {
background-color: #6c757d;
color: #fff;
}
.translating {
color: #ff6b4a;
font-weight: bold;
margin-right: 8px;
font-size: 0.8rem;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* File item layout for translation results */
.file-list li.translation-result-item {
display: block !important;
align-items: initial !important;
justify-content: initial !important;
}
.file-item-content {
width: 100%;
}
.original-file {
font-weight: bold;
color: #333;
margin-bottom: 2px;
font-size: 0.9rem;
}
.result-file {
color: #666;
font-size: 0.85rem;
margin-bottom: 4px;
font-style: italic;
}
.file-actions {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.file-actions .download-original-link,
.file-actions .download-result-link {
margin-right: 0;
}
.file-actions .file-del-btn {
margin-left: auto;
}
/* 파일 사이드바가 있을 때 채팅 영역 조정 */
.page-chat.has-file-sidebar .chat-center {
left: calc(50% + 120px); /* 파일 사이드바 넓이(240px)의 절반만큼 오른쪽으로 이동 */
max-width: calc(95vw - 320px); /* 메인 사이드바(80px) + 파일 사이드바(240px) 고려 */
transform: translate(-50%, -50%); /* 중앙 정렬 유지 */
}
.page-chat.has-file-sidebar .chat-input-wrapper {
left: calc(50% + 120px); /* 파일 사이드바 넓이(240px)의 절반만큼 오른쪽으로 이동 */
max-width: calc(95vw - 320px); /* 메인 사이드바(80px) + 파일 사이드바(240px) 고려 */
width: calc(100% - 240px); /* 파일 사이드바 영역 제외 */
transform: translateX(-50%); /* 중앙 정렬 유지 */
}

89
table_schema.sql Normal file
View File

@@ -0,0 +1,89 @@
CREATE TABLE users (
no SERIAL PRIMARY KEY, -- 고유 번호 (자동 증가)
user_id TEXT NOT NULL UNIQUE, -- 사용자 아이디
email TEXT NOT NULL UNIQUE, -- 이메일
password TEXT NOT NULL, -- 비밀번호 (해시 저장 권장)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 생성일
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 수정일
);
CREATE EXTENSION IF NOT EXISTS pgcrypto;
INSERT INTO users (user_id, email, password) VALUES ('admin', 'dosangyoon@gmail.com', crypt('dsyoon5004!', gen_salt('bf')));
select * from users;
CREATE TABLE qna_board (
id SERIAL PRIMARY KEY, -- 게시글 고유 번호 (자동 증가)
title TEXT NOT NULL, -- 제목
content TEXT NOT NULL, -- 내용
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 등록일
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 수정일
views INTEGER DEFAULT 0, -- 조회수, 기본값 0
author_id INTEGER NOT NULL, -- 작성자 ID (users.no 참조)
CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES users(no) ON DELETE CASCADE
);
select * from qna_board;
CREATE TABLE ai_news_board (
id SERIAL PRIMARY KEY, -- 뉴스 고유 번호 (자동 증가)
url TEXT NOT NULL, -- 뉴스 URL
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 등록일
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 수정일
views INTEGER DEFAULT 0, -- 조회수 (기본값 0)
author_id INTEGER NOT NULL, -- 작성자 ID (users.no 참조)
CONSTRAINT fk_ai_news_author FOREIGN KEY (author_id)
REFERENCES users(no) ON DELETE CASCADE
);
-- 챗봇(도구) 목록
CREATE TABLE chat_tools (
tool_id TEXT PRIMARY KEY, -- 예: 'chatgpt', 'dev_chatbot'
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO chat_tools (tool_id, name, description)
VALUES
-- 기본 챗봇
('chatgpt', 'ChatGPT', '일반 대화 및 모델 선택 기능을 제공합니다.'),
-- PDF 벡터검색 개발 챗봇
('dev_chatbot', '개발자 챗봇', 'PDF 업로드 후 벡터 검색 기반 Q&A 기능을 제공합니다.'),
-- 문서 번역 도구
('doc_translation','문서 번역', 'MS-Word 문서를 업로드하여 한글→영어 번역을 수행합니다.'),
-- LIMS Text-to-SQL
('lims_text2sql', 'LIMS Text2SQL', 'LIMS DB에 대한 자연어 질의를 SQL로 변환하여 결과를 제공합니다.'),
-- 연구·논문 QA
('research_qa', 'R&D 문헌 QA', '사내 논문·보고서 기반 질의응답 서비스를 제공합니다.');
-- 대화방(히스토리 목록 하나당 1 row)
CREATE TABLE chat_rooms (
room_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE,
tool_id TEXT REFERENCES chat_tools(tool_id) ON DELETE CASCADE,
title TEXT, -- 방 제목(첫 메시지 요약 등)
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_chat_rooms_user ON chat_rooms(user_id, updated_at DESC);
-- 실제 메시지
CREATE TABLE chat_messages (
msg_id BIGSERIAL PRIMARY KEY,
room_id UUID REFERENCES chat_rooms(room_id) ON DELETE CASCADE,
sender_type VARCHAR(8) NOT NULL, -- 'user' | 'bot' | 'system'
content TEXT NOT NULL,
content_type VARCHAR(16) DEFAULT 'text', -- 'text' | 'image' 등
metadata JSONB, -- 모델/프롬프트 등 추가 정보
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_chat_messages_room ON chat_messages(room_id, created_at ASC);