init
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,8 @@
|
|||||||
# ---> Python
|
# ---> Python
|
||||||
|
chroma_db/*
|
||||||
|
logs/*
|
||||||
|
uploads/*
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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/
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
7
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
8
.idea/ncuetalk_backend.iml
generated
Normal 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
6
.idea/sqldialects.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
505
README.md
@@ -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
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
852
backend/app.py
Normal file
852
backend/app.py
Normal 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)
|
||||||
28
backend/engines/__init__.py
Normal file
28
backend/engines/__init__.py
Normal 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
|
||||||
11
backend/engines/chatgpt_tool/__init__.py
Normal file
11
backend/engines/chatgpt_tool/__init__.py
Normal 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 전문지식과 신뢰할 수 있는 공개 자료를 바탕으로 설명하세요. 필요 시 표/리스트/소제목을 활용해 가독성을 높이십시오. "
|
||||||
|
"만약 기업의 위치나 연락처 등 요청이 오면 본사·연구소·공장 등 주요 거점을 빠짐없이 요약해 주세요."
|
||||||
|
)
|
||||||
|
}
|
||||||
53
backend/engines/chatgpt_tool/controller/ChatGPTController.py
Normal file
53
backend/engines/chatgpt_tool/controller/ChatGPTController.py
Normal 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",
|
||||||
|
}
|
||||||
1
backend/engines/chatgpt_tool/controller/__init__.py
Normal file
1
backend/engines/chatgpt_tool/controller/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
192
backend/engines/dev_chatbot/__init__.py
Normal file
192
backend/engines/dev_chatbot/__init__.py
Normal 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
|
||||||
346
backend/engines/doc_translation/__init__.py
Normal file
346
backend/engines/doc_translation/__init__.py
Normal 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)}")
|
||||||
BIN
community/AI뉴스레터25년7월호_IT전략팀.pdf
Normal file
BIN
community/AI뉴스레터25년7월호_IT전략팀.pdf
Normal file
Binary file not shown.
57
frontend/dev_chatbot/AIService.js
Normal file
57
frontend/dev_chatbot/AIService.js
Normal 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;
|
||||||
40
frontend/dev_chatbot/ChatHandler.js
Normal file
40
frontend/dev_chatbot/ChatHandler.js
Normal 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;
|
||||||
93
frontend/dev_chatbot/ChatInput.jsx
Normal file
93
frontend/dev_chatbot/ChatInput.jsx
Normal 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;
|
||||||
22
frontend/dev_chatbot/OCRService.js
Normal file
22
frontend/dev_chatbot/OCRService.js
Normal 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;
|
||||||
5
frontend/lims_text2sql/README.md
Normal file
5
frontend/lims_text2sql/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# LIMS Text2SQL 프론트엔드
|
||||||
|
|
||||||
|
이 폴더는 LIMS Text2SQL 엔진을 위한 전용 프론트엔드 자바스크립트/컴포넌트를 보관합니다.
|
||||||
|
현재 메인 UI에서는 외부 서비스(iframe)로 연결하므로 파일이 비어 있습니다.
|
||||||
|
필요 시 이곳에 컴포넌트를 추가하세요.
|
||||||
5
frontend/research_qa/README.md
Normal file
5
frontend/research_qa/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 연구QA 프론트엔드
|
||||||
|
|
||||||
|
이 디렉터리에는 연구QA 엔진 전용 프론트엔드 코드가 들어갑니다.
|
||||||
|
현재 `index.html` 의 iframe 접근 방식으로 동작하므로 별도 UI 컴포넌트는 필요하지 않습니다.
|
||||||
|
향후 기능 고도화 시 이곳에 React/Vue 컴포넌트를 추가하세요.
|
||||||
118
index.html
Normal file
118
index.html
Normal 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>
|
||||||
34
requirements.txt
Normal file
34
requirements.txt
Normal 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
3
scripts/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# scripts
|
||||||
|
|
||||||
|
오프라인 처리를 위한 쉘 스크립트 모음
|
||||||
Binary file not shown.
BIN
scripts/gxp/oci-gxp-advisory.pdf
Normal file
BIN
scripts/gxp/oci-gxp-advisory.pdf
Normal file
Binary file not shown.
171
scripts/gxp_bulk_ingest.py
Normal file
171
scripts/gxp_bulk_ingest.py
Normal 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
839
style.css
Normal 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
89
table_schema.sql
Normal 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);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user