diff --git a/.gitignore b/.gitignore index 0dbf2f2..81477de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ # ---> Python +chroma_db/* +logs/* +uploads/* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -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/ diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..812ab5a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..02fe3be --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/ncuetalk_backend.iml b/.idea/ncuetalk_backend.iml new file mode 100644 index 0000000..8388dbc --- /dev/null +++ b/.idea/ncuetalk_backend.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..c73d46a --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 7b33755..62140e4 100644 --- a/README.md +++ b/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 +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 경험을 만들어보세요! 🚀 \ No newline at end of file diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..a5dc0ee --- /dev/null +++ b/backend/app.py @@ -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) \ No newline at end of file diff --git a/backend/engines/__init__.py b/backend/engines/__init__.py new file mode 100644 index 0000000..32700bb --- /dev/null +++ b/backend/engines/__init__.py @@ -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 \ No newline at end of file diff --git a/backend/engines/chatgpt_tool/__init__.py b/backend/engines/chatgpt_tool/__init__.py new file mode 100644 index 0000000..400e9ea --- /dev/null +++ b/backend/engines/chatgpt_tool/__init__.py @@ -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 전문지식과 신뢰할 수 있는 공개 자료를 바탕으로 설명하세요. 필요 시 표/리스트/소제목을 활용해 가독성을 높이십시오. " + "만약 기업의 위치나 연락처 등 요청이 오면 본사·연구소·공장 등 주요 거점을 빠짐없이 요약해 주세요." + ) +} \ No newline at end of file diff --git a/backend/engines/chatgpt_tool/controller/ChatGPTController.py b/backend/engines/chatgpt_tool/controller/ChatGPTController.py new file mode 100644 index 0000000..afc413d --- /dev/null +++ b/backend/engines/chatgpt_tool/controller/ChatGPTController.py @@ -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", + } \ No newline at end of file diff --git a/backend/engines/chatgpt_tool/controller/__init__.py b/backend/engines/chatgpt_tool/controller/__init__.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/backend/engines/chatgpt_tool/controller/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/engines/dev_chatbot/__init__.py b/backend/engines/dev_chatbot/__init__.py new file mode 100644 index 0000000..0c92c7e --- /dev/null +++ b/backend/engines/dev_chatbot/__init__.py @@ -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 \ No newline at end of file diff --git a/backend/engines/doc_translation/__init__.py b/backend/engines/doc_translation/__init__.py new file mode 100644 index 0000000..cb51c39 --- /dev/null +++ b/backend/engines/doc_translation/__init__.py @@ -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)}") \ No newline at end of file diff --git a/community/AI뉴스레터25년7월호_IT전략팀.pdf b/community/AI뉴스레터25년7월호_IT전략팀.pdf new file mode 100644 index 0000000..8cc3af5 Binary files /dev/null and b/community/AI뉴스레터25년7월호_IT전략팀.pdf differ diff --git a/frontend/dev_chatbot/AIService.js b/frontend/dev_chatbot/AIService.js new file mode 100644 index 0000000..19e26e6 --- /dev/null +++ b/frontend/dev_chatbot/AIService.js @@ -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; \ No newline at end of file diff --git a/frontend/dev_chatbot/ChatHandler.js b/frontend/dev_chatbot/ChatHandler.js new file mode 100644 index 0000000..1fe96f3 --- /dev/null +++ b/frontend/dev_chatbot/ChatHandler.js @@ -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; \ No newline at end of file diff --git a/frontend/dev_chatbot/ChatInput.jsx b/frontend/dev_chatbot/ChatInput.jsx new file mode 100644 index 0000000..59d87d6 --- /dev/null +++ b/frontend/dev_chatbot/ChatInput.jsx @@ -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 ( + + + {messages.map((msg, idx) => ( + + {msg.sender === 'user' ? '나' : 'AI'}: {msg.text} + {msg.files && msg.files.length > 0 && ( + + {msg.files.map((file, fIdx) => ( + {file.name} + ))} + + )} + + ))} + {loading && AI가 응답을 생성하고 있습니다...} + + + {/* 입력 영역 */} + + + {selectedFiles.length > 0 && ( + + {selectedFiles.map((file, idx) => ( + + {file.name} handleFileRemove(idx)}>삭제 + + ))} + + )} + 전송 + + ); +}; + +export default ChatInput; \ No newline at end of file diff --git a/frontend/dev_chatbot/OCRService.js b/frontend/dev_chatbot/OCRService.js new file mode 100644 index 0000000..4ff57bf --- /dev/null +++ b/frontend/dev_chatbot/OCRService.js @@ -0,0 +1,22 @@ +// OCRService.js – 브라우저에서 동작하는 간단 OCR 래퍼(Tesseract.js) +// ----------------------------------------------------------------------------- +// 이미지 File 객체를 받아 한국어+영어 텍스트를 추출하여 반환한다. +// 주의: 브라우저 워커 기반이므로 큰 이미지·다중 호출 시 성능 이슈가 있을 수 있다. +// ----------------------------------------------------------------------------- + +import Tesseract from 'tesseract.js'; + +/** + * @param {File} imageFile - 이미지 파일 객체 + * @returns {Promise} 추출 텍스트 (trim 처리) + */ +const OCRService = async (imageFile) => { + try { + const { data: { text } } = await Tesseract.recognize(imageFile, 'kor+eng'); + return text.trim(); + } catch (e) { + return ''; + } +}; + +export default OCRService; \ No newline at end of file diff --git a/frontend/lims_text2sql/README.md b/frontend/lims_text2sql/README.md new file mode 100644 index 0000000..81884bc --- /dev/null +++ b/frontend/lims_text2sql/README.md @@ -0,0 +1,5 @@ +# LIMS Text2SQL 프론트엔드 + +이 폴더는 LIMS Text2SQL 엔진을 위한 전용 프론트엔드 자바스크립트/컴포넌트를 보관합니다. +현재 메인 UI에서는 외부 서비스(iframe)로 연결하므로 파일이 비어 있습니다. +필요 시 이곳에 컴포넌트를 추가하세요. \ No newline at end of file diff --git a/frontend/research_qa/README.md b/frontend/research_qa/README.md new file mode 100644 index 0000000..bd19be9 --- /dev/null +++ b/frontend/research_qa/README.md @@ -0,0 +1,5 @@ +# 연구QA 프론트엔드 + +이 디렉터리에는 연구QA 엔진 전용 프론트엔드 코드가 들어갑니다. +현재 `index.html` 의 iframe 접근 방식으로 동작하므로 별도 UI 컴포넌트는 필요하지 않습니다. +향후 기능 고도화 시 이곳에 React/Vue 컴포넌트를 추가하세요. \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..94c2bba --- /dev/null +++ b/index.html @@ -0,0 +1,118 @@ + + + + + + 엔큐톡 AI엔진 + + + + + + + + + + 채팅 + 로그인 + + + + + 파일 리스트 + + + + + + + + + 엔큐톡 + + + + × + + + + + + + + + + Auto + GPT-5 + + + + 자체모델 + + + 지식베이스 + + + + + 자체모델 + 외부모델 + + + + ↑ + + + + + + + 도구 목록 + + 전체 + 즐겨찾기 + 오픈AI + 내부AI + + + + + + + + 강의 콘텐츠가 준비 중입니다. + + + + + + + 뉴스레터 콘텐츠가 준비 중입니다. + + + + + + + + \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..36ed7b6 --- /dev/null +++ b/main.js @@ -0,0 +1,1081 @@ +// main.js – index.html 전체 SPA 로직(jQuery 기반) +// 핵심 기능: 도구 목록 로드, 채팅 페이지 렌더, 파일 사이드바, 세션/메시지 관리 등. +// 코드가 600+ 라인으로 길기 때문에 함수별 세부 주석은 추후 리팩터링 시 모듈 분할과 함께 추가 권장. +// 여기서는 상단 설명만 삽입한다. +// ----------------------------------------------------------------------------- +$(function() { + // 도구 데이터 (서버에서 동적으로 가져옴) + let tools = []; + let favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); + + // 현재 선택된 도구와 세션 + let currentTool = null; + let currentSessionId = null; + + // 도구별 채팅 메시지 저장소 { sessionId: messages[] } + let toolChatMessages = {}; + // 도구별 마지막 세션 ID를 기억해 도구를 다시 선택했을 때 복원 + let lastSessionPerTool = {}; + // 업로드 progress interval 저장소 {placeholderId: intervalId} + let uploadIntervals = {}; + + // IME 조합 상태 플래그 + let isComposing = false; + let enterPressedWhileComposing = false; + + // IME 조합 시작/종료 이벤트 등록 + $('.chat-input').on('compositionstart', function() { + isComposing = true; + }); + $('.chat-input').on('compositionend', function(e) { + isComposing = false; + // 조합 중 Enter가 눌렸다면, 조합 끝난 후 전송 + if (enterPressedWhileComposing) { + enterPressedWhileComposing = false; + sendChat(); + } + }); + + // 도구 목록 가져오기 + async function loadTools() { + try { + const response = await fetch('http://localhost:8010/tools'); + if (response.ok) { + const toolsData = await response.json(); + tools = toolsData.map(tool => { + // 도구 유형(오픈AI / 내부AI)에 따라 카테고리 지정 + const category = ['전체']; + if (tool.id === 'dev_chatbot' || tool.id === 'chatgpt' || tool.id === 'doc_translation') { + category.push('오픈AI'); + } else if ([ + 'chatbot_gxp', + 'lims_text2sql', + 'research_qa' + ].includes(tool.id)) { + category.push('내부AI'); + } + return { + ...tool, + category, + id: tool.id + }; + }); + renderTools('all'); + // 도구 자동 선택 (최초 진입 시) + if (tools.length > 0 && !currentTool) { + selectTool(tools[0].id); + } + } else { + console.error('도구 목록을 가져오는데 실패했습니다.'); + } + } catch (error) { + console.error('도구 목록 로드 오류:', error); + } + } + + async function loadFileList() { + try { + const res = await fetch('http://localhost:8010/files'); + const data = await res.json(); + const $list = $('.file-list'); + $list.empty(); + data.files.forEach(f => { + let display = f; + const parts = f.split("_"); + if(parts.length>1 && /^\d{17,}$/.test(parts[0])){ + display = parts.slice(1).join("_"); + } + $list.append(`${display}✕`); + }); + } catch(e) { console.error('파일 목록 로드 오류', e); } + } + + // 문서번역 파일 리스트 로드 + async function loadDocTranslationFileList() { + try { + const res = await fetch('http://localhost:8010/doc_translation/files'); + const data = await res.json(); + const $list = $('.file-list'); + $list.empty(); + + // 파일 추가 버튼의 기능을 Word 파일 업로드로 변경 + $('.file-add-btn').off('click').on('click', function() { + const input = $(''); + $('body').append(input); + input.on('change', function() { + uploadDocTranslationFiles(this.files); + $(this).remove(); + }); + input.click(); + }); + + data.forEach(file => { + // 실제 파일명 추출 함수 + function extractRealFilename(filename) { + // 패턴: [doc_translation]_admin_YYYYMMDDHHMMSS[fff]_실제파일명 + const regex = /^\[doc_translation\]_admin_\d{14,17}_(.+)$/; + const match = filename.match(regex); + return match ? match[1] : filename; + } + + let displayName = extractRealFilename(file.filename); + + let listItem = ''; + if (file.has_result) { + // 결과 파일명도 정리 + let resultDisplayName = extractRealFilename(file.result_filename); + + // 번역 완료된 파일 - 원본 파일명과 결과 파일명을 세로로 표시 + listItem = ` + + + ${displayName} + → ${resultDisplayName} + + [원본다운] + [결과다운] + ✕ + + + + `; + } else { + // 번역 중인 파일 - 파일명 숨김 + listItem = ` + + 번역중... + ✕ + + `; + } + $list.append(listItem); + }); + } catch(e) { + console.error('문서번역 파일 목록 로드 오류', e); + } + } + + // Word 파일 업로드 함수 + async function uploadDocTranslationFiles(files) { + if (!files || files.length === 0) return; + + // MS Word 파일 검증 + for (let file of files) { + if (!file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx')) { + alert('MS Word 파일(.doc, .docx)만 업로드 가능합니다.'); + return; + } + } + + const formData = new FormData(); + for (let file of files) { + formData.append('files', file); + } + + try { + // 업로드 시작 시 로딩 상태 표시 + const $fileList = $('.file-list'); + const loadingItem = $('업로드 및 번역 중...'); + $fileList.prepend(loadingItem); + + const response = await fetch('http://localhost:8010/doc_translation/upload_doc', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + // 로딩 아이템 제거 + loadingItem.remove(); + + if (result.status === 'success') { + // 업로드와 번역이 동기적으로 완료됨 - 즉시 완료 상태로 표시 + loadDocTranslationFileList(); + } else { + alert('파일 업로드에 실패했습니다: ' + (result.detail || '알 수 없는 오류')); + } + } catch (error) { + // 로딩 아이템 제거 + $('.uploading-item').remove(); + console.error('파일 업로드 오류:', error); + alert('파일 업로드 중 오류가 발생했습니다.'); + } + } + + // --- PDF 뷰어 로딩 오버레이 표시/숨김 유틸 --- + function showPdfLoading() { + const $slide = $('.file-slide'); + let $overlay = $slide.find('.file-loading-overlay'); + if ($overlay.length === 0) { + $overlay = $('로딩중…'); + $slide.append($overlay); + } + $overlay.show(); + $('.file-viewer').hide(); + } + function hidePdfLoading() { + $('.file-loading-overlay').hide(); + $('.file-viewer').show(); + } + + // 삭제 버튼 클릭 (동적으로 처리 - 도구에 따라 다른 엔드포인트 사용) + $('.file-list').on('click', '.file-del-btn', async function(e) { + e.stopPropagation(); + const fname = $(this).data('fname'); + + // 문서번역의 경우 실제 파일명만 표시 + let displayName = fname; + if (currentTool && currentTool.id === 'doc_translation') { + // 실제 파일명 추출 함수 + function extractRealFilename(filename) { + const regex = /^\[doc_translation\]_admin_\d{14,17}_(.+)$/; + const match = filename.match(regex); + return match ? match[1] : filename; + } + displayName = extractRealFilename(fname); + } + + if(!confirm(displayName + ' 파일을 삭제하시겠습니까?')) return; + try { + let deleteUrl; + if (currentTool && currentTool.id === 'doc_translation') { + deleteUrl = `http://localhost:8010/doc_translation/files/${encodeURIComponent(fname)}`; + } else { + deleteUrl = `http://localhost:8010/file?filename=${encodeURIComponent(fname)}`; + } + + const res = await fetch(deleteUrl, { method: 'DELETE' }); + const data = await res.json(); + if(data.status==='success') { + if (currentTool && currentTool.id === 'doc_translation') { + loadDocTranslationFileList(); + } else { + loadFileList(); + } + // 슬라이드가 해당 파일이라면 닫기 + if($('.file-slide.show').length && $('.file-slide-content').text().includes(fname)) { + $('.file-slide').removeClass('show'); + } + } else { + alert('삭제 실패: '+data.message); + } + } catch(err){ alert('삭제 오류: '+err); } + }); + + // 문서번역 원본 파일 다운로드 링크 클릭 + $('.file-list').on('click', '.download-original-link', async function(e) { + e.preventDefault(); + e.stopPropagation(); + const originalFilename = $(this).data('original-fname'); + + // 실제 파일명 추출 함수 + function extractRealFilename(filename) { + const regex = /^\[doc_translation\]_admin_\d{14,17}_(.+)$/; + const match = filename.match(regex); + return match ? match[1] : filename; + } + + try { + const response = await fetch(`http://localhost:8010/doc_translation/download/${encodeURIComponent(originalFilename)}`); + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = extractRealFilename(originalFilename); // 실제 파일명으로 다운로드 + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } else { + alert('원본 파일 다운로드에 실패했습니다.'); + } + } catch (error) { + console.error('원본 파일 다운로드 오류:', error); + alert('원본 파일 다운로드 중 오류가 발생했습니다.'); + } + }); + + // 문서번역 결과 다운로드 링크 클릭 + $('.file-list').on('click', '.download-result-link', async function(e) { + e.preventDefault(); + e.stopPropagation(); + const resultFilename = $(this).data('result-fname'); + + // 실제 파일명 추출 함수 + function extractRealFilename(filename) { + const regex = /^\[doc_translation\]_admin_\d{14,17}_(.+)$/; + const match = filename.match(regex); + return match ? match[1] : filename; + } + + try { + const response = await fetch(`http://localhost:8010/doc_translation/download/${encodeURIComponent(resultFilename)}`); + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = extractRealFilename(resultFilename); // 실제 파일명으로 다운로드 + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } else { + alert('결과 파일 다운로드에 실패했습니다.'); + } + } catch (error) { + console.error('결과 파일 다운로드 오류:', error); + alert('결과 파일 다운로드 중 오류가 발생했습니다.'); + } + }); + + // 파일 클릭 -> 슬라이드 열기 (파일 내용 확인 요청은 실패해도 뷰어 열기) + $('.file-list').off('click', 'li').on('click', 'li', async function() { + const fname = $(this).data('fname'); + const url = `http://localhost:8010/pdf?filename=${encodeURIComponent(fname)}`; + + const $viewer = $('.file-viewer'); + + // 로딩 오버레이 표시 + showPdfLoading(); + + // 로드 완료 시 오버레이 숨김 + $viewer.off('load').on('load', function() { + hidePdfLoading(); + }); + + // PDF 로드 시작 + $viewer.attr('src', url); + $('.file-slide').css('display','block').addClass('show'); + + // 백그라운드로 파일 존재 여부를 확인하여 오류시 알림 + try { + const res = await fetch(`http://localhost:8010/file_content?filename=${encodeURIComponent(fname)}`); + const data = await res.json(); + if (data.status !== 'success') { + console.warn('파일 내용 조회 실패:', data.message); + } + } catch(err) { + console.error('파일 내용 조회 오류:', err); + } + }); + + // 닫기 버튼 + $('.file-slide').on('click', '.file-slide-close', function() { + $('.file-slide').removeClass('show').css('display','none'); + }); + + // 슬라이드 종료(배경 클릭) + $('.file-slide').click(function(e) { + if (e.target === this) { + $(this).removeClass('show').css('display','none'); + } + }); + + // 출처 링크 클릭 시: 기존 뷰어가 열려 있으면 닫았다가 새로 열어 원하는 페이지로 이동 + $('.chat-center').off('click', '.ref-link').on('click', '.ref-link', function(e){ + e.preventDefault(); + + const fname = $(this).data('fname'); + const page = $(this).data('page'); + const url = `http://localhost:8010/pdf?filename=${encodeURIComponent(fname)}#page=${page}`; + + const $slide = $('.file-slide'); + const $viewer = $('.file-viewer'); + + // 슬라이드가 이미 열려 있으면 우선 닫기 (애니메이션 제거 목적) + if ($slide.hasClass('show')) { + $slide.removeClass('show').css('display', 'none'); + } + + // 로딩 오버레이 표시 + showPdfLoading(); + + // 먼저 blank 로드로 강제 새로고침 + $viewer.off('load'); + $viewer.attr('src', 'about:blank'); + + // PDF 로드 시작 (약간의 딜레이 후 실행) + setTimeout(() => { + $viewer.off('load').on('load', function() { + hidePdfLoading(); + }); + $viewer.attr('src', url); + $slide.css('display', 'block').addClass('show'); + }, 50); + }); + + // 채팅 메시지 렌더링 함수 (loading 타입 처리) + function renderChatMessages(autoScroll=false) { + const $chatCenter = $('.chat-center'); + $chatCenter.empty(); + + if (!currentTool) { + $chatCenter.append(` + + 엔큐톡 + 도구를 선택하여 채팅을 시작하세요 + `); + return; + } + + // iframe을 사용하는 도구 매핑 + const iframeTools = { + 'research_qa': 'http://yongin-qa-chatbot.daewoongai.com/', + 'lims_text2sql': 'http://3.38.184.255:8080/' + }; + + if (iframeTools[currentTool.id]) { + $('.chat-input-wrapper').hide(); + $chatCenter.addClass('iframe-mode'); + $chatCenter.append(``); + return; + } else { + $('.chat-input-wrapper').show(); + $chatCenter.removeClass('iframe-mode'); + } + + $chatCenter.append(` + + ${currentTool.name} + ${currentTool.description} + + `); + + // 현재 세션 메시지 출력 + const sessionKeyToShow = getMsgKey(currentTool.id, currentSessionId); + if (toolChatMessages[currentTool.id] && toolChatMessages[currentTool.id][sessionKeyToShow]) { + let messages = toolChatMessages[currentTool.id][sessionKeyToShow]; + messages.forEach(msg => { + let messageClass = msg.type === 'user' ? 'user-message' : (msg.type === 'bot' ? 'bot-message' : (msg.type === 'progress' ? 'bot-message progress' : 'bot-message loading')); + let contentHtml = msg.content + // 구백엔드 형식: 파일.pdf (p1) + .replace(/([^\s]+\.pdf) \(p(\d+)\)/g, '$1 (p$2)') + // 신백엔드 형식: [p1](/pdf?filename=파일.pdf#page=1) + .replace(/\[p(\d+)\]\(\/pdf\?filename=([^#]+)#page=(\d+)\)/g, function(_, pageNum, fnameEnc, pageNum2){ + const fname = decodeURIComponent(fnameEnc); + return `p${pageNum}`; + }) + // gxp-chat 경로 링크 + .replace(/\[p(\d+)\]\(\/gxp-chat\/pdf\?filename=([^#]+)#page=(\d+)\)/g, function(_, pg, fnameEnc){ + const fname = decodeURIComponent(fnameEnc); + return `p${pg}`; + }) + contentHtml = contentHtml.replace(/\n/g, ""); + if (msg.type === 'progress') { + const label = msg.content || '생각중'; + contentHtml = `${label}`; + } else if (msg.type === 'loading') { + contentHtml = `${msg.content}`; + } + let timeDisplay = msg.time; + if (msg.type === 'loading') { + const elapsed = Math.floor((Date.now() - (msg.start||Date.now()))/1000); + timeDisplay = elapsed + 's'; + } + $chatCenter.append(`${contentHtml}${timeDisplay}`); + }); + } else if (toolChatMessages[currentTool.id] && toolChatMessages[currentTool.id][getMsgKey(currentTool.id,null)]) { + let messages = toolChatMessages[currentTool.id][getMsgKey(currentTool.id,null)]; + messages.forEach(msg => { + let messageClass = msg.type === 'user' ? 'user-message' : (msg.type === 'bot' ? 'bot-message' : (msg.type === 'progress' ? 'bot-message progress' : 'bot-message loading')); + let contentHtml = msg.content + // 구백엔드 형식: 파일.pdf (p1) + .replace(/([^\s]+\.pdf) \(p(\d+)\)/g, '$1 (p$2)') + // 신백엔드 형식: [p1](/pdf?filename=파일.pdf#page=1) + .replace(/\[p(\d+)\]\(\/pdf\?filename=([^#]+)#page=(\d+)\)/g, function(_, pageNum, fnameEnc, pageNum2){ + const fname = decodeURIComponent(fnameEnc); + return `p${pageNum}`; + }) + // gxp-chat 경로 링크 + .replace(/\[p(\d+)\]\(\/gxp-chat\/pdf\?filename=([^#]+)#page=(\d+)\)/g, function(_, pg, fnameEnc){ + const fname = decodeURIComponent(fnameEnc); + return `p${pg}`; + }) + contentHtml = contentHtml.replace(/\n/g, ""); + if (msg.type === 'progress') { + const label = msg.content || '생각중'; + contentHtml = `${label}`; + } else if (msg.type === 'loading') { + contentHtml = `${msg.content}`; + } + let timeDisplay = msg.time; + if (msg.type === 'loading') { + const elapsed = Math.floor((Date.now() - (msg.start||Date.now()))/1000); + timeDisplay = elapsed + 's'; + } + $chatCenter.append(`${contentHtml}${timeDisplay}`); + }); + } + // after messages appended + if(autoScroll){ + $chatCenter.scrollTop($chatCenter[0].scrollHeight); + } + } + + // Helper: send message to backend and get response + async function sendMessageToAI(toolId, sessionId, message, model, knowledgeMode){ + const url = toolId === 'chatgpt' ? 'http://localhost:8010/chatgpt/chat' : 'http://localhost:8010/chat'; + const formData = new FormData(); + formData.append('message', message); + formData.append('tool_id', toolId); + if(sessionId) formData.append('session_id', sessionId); + formData.append('model', model); + if(toolId === 'dev_chatbot'){ + formData.append('knowledge_mode', knowledgeMode); + } + const res = await fetch(url,{method:'POST',body:formData}); + if(!res.ok) throw new Error('HTTP '+res.status); + return await res.json(); + } + + // Modify sendChat implementation + function sendChat(){ + const message = $('.chat-input').text().trim(); + if(!message) return; + const now = new Date().toISOString(); + const sessionId = currentSessionId || 'temp-session'; + const toolId = currentTool.id; + const msgKey = getMsgKey(toolId, sessionId); + + if(!toolChatMessages[toolId]) toolChatMessages[toolId] = {}; + if(!toolChatMessages[toolId][msgKey]) toolChatMessages[toolId][msgKey]=[]; + toolChatMessages[toolId][msgKey].push({type:'user',content:message,time:now}); + const loadingMsg = {type:'loading',content:'생각중',start:Date.now()}; + toolChatMessages[toolId][msgKey].push(loadingMsg); + // 주기적으로 경과 시간 업데이트 + const loadingInterval = setInterval(()=>{ + // 종료 조건: 로딩 메시지가 더 이상 마지막이 아닐 때 + const msgs = toolChatMessages[toolId][msgKey]; + if(!msgs || msgs[msgs.length-1] !== loadingMsg){ + clearInterval(loadingInterval); + return; + } + renderChatMessages(true); + },1000); + $('.chat-input').text(''); + renderChatMessages(true); + + // Determine model & knowledgeMode + let model='gpt-oss:latest'; + if(currentTool.id==='chatgpt'){ + model = $('.model-select').val()||'auto'; + } else if(currentTool.id==='dev_chatbot'){ + model = $('.gc-model-select').val()||'woong'; + } else if(currentTool.id==='doc_translation'){ + model = $('.dt-model-select').val()||'external'; + } + const kMode = $('.knowledge-mode').val()||'hybrid'; + + sendMessageToAI(currentTool.id, currentSessionId, message, model, kMode) + .then(resp=>{ + // replace last loading message + const msgs = toolChatMessages[toolId][msgKey]; + msgs[msgs.length-1] = {type:'bot',content:resp.response,time:new Date().toISOString()}; + // update session id + if(!currentSessionId && resp.session_id){ + currentSessionId = resp.session_id; + lastSessionPerTool[currentTool.id]=resp.session_id; + const newKey = getMsgKey(toolId, resp.session_id); + toolChatMessages[toolId][newKey]=msgs; + delete toolChatMessages['temp']; // Remove temp session from toolChatMessages + delete toolChatMessages['temp-session']; // Remove temp session from toolChatMessages + } + renderChatMessages(true); + }) + .catch(err=>{ + const msgs=toolChatMessages[toolId][msgKey]; + msgs[msgs.length-1]={type:'bot',content:'서버 오류: '+err.message,time:new Date().toISOString()}; + renderChatMessages(true); + }); + } + + // 채팅 입력 핸들러 (Enter 전송, Shift+Enter 줄바꿈) + $('.chat-input').on('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey && !isComposing) { + e.preventDefault(); + sendChat(); + } + }); + + // 채팅 전송 버튼 클릭 핸들러 + $('.send-chat').on('click', function() { + sendChat(); + }); + + // 세션 변경 핸들러 + $('.session-selector').on('change', function() { + const selectedSessionId = $(this).val(); + if (selectedSessionId !== currentSessionId) { + currentSessionId = selectedSessionId; + renderChatMessages(); + } + }); + + // 도구 선택 핸들러 + function selectTool(toolId) { + currentTool = tools.find(tool => tool.id === toolId); + if (!currentTool) return; + + // 페이지 전환: 채팅 페이지 활성화 + $('.menu-item').removeClass('active'); + $('.menu-item[data-page="chat"]').addClass('active'); + $('.page').removeClass('active'); + $('.page-chat').addClass('active'); + $('.page-title').text('채팅'); + + // 세션 리셋 또는 기존 세션 복원 + currentSessionId = lastSessionPerTool[toolId] || null; + + // 모델/지식모드 드롭다운 표시 제어 + if (toolId === 'chatgpt') { + $('.model-select').show(); + $('.knowledge-mode').hide(); + $('.gc-model-select').hide(); + $('.dt-model-select').hide(); + // 파일 사이드바 숨김 (개발챗봇 전용) + $('.file-sidebar').hide(); + $('.page-chat').removeClass('has-file-sidebar'); + } else if (toolId === 'dev_chatbot') { + $('.model-select').hide(); + $('.knowledge-mode').show(); + $('.gc-model-select').show(); + $('.dt-model-select').hide(); + + // 개발챗봇에서만 PDF 파일 사이드바 표시 + $('.file-sidebar').show(); + $('.page-chat').addClass('has-file-sidebar'); + loadFileList(); + } else if (toolId === 'doc_translation') { + $('.model-select').hide(); + $('.knowledge-mode').hide(); + $('.gc-model-select').hide(); + $('.dt-model-select').show(); + + // 문서번역에서도 파일 사이드바 표시 (Word 파일용) + $('.file-sidebar').show(); + $('.page-chat').addClass('has-file-sidebar'); + loadDocTranslationFileList(); + } else { + $('.model-select').hide(); + $('.knowledge-mode').hide(); + $('.gc-model-select').hide(); + $('.dt-model-select').hide(); + + // 다른 도구 선택 시 파일 사이드바 숨김 + $('.file-sidebar').hide(); + $('.page-chat').removeClass('has-file-sidebar'); + } + + renderChatMessages(); + } + + // ---------- 도구 목록 렌더링 및 이벤트 ---------- + function renderTools(filter) { + let list = tools; + if (filter && filter !== 'all') { + if (filter === 'favorite') { + list = tools.filter(t => favorites.includes(t.id)); + } else { + list = tools.filter(t => t.category && t.category.includes(filter)); + } + } + const $list = $('.tools-list'); + $list.empty(); + if (list.length === 0) { + $list.append('도구가 없습니다.'); + return; + } + list.forEach(tool => { + const fav = favorites.includes(tool.id) ? 'active' : ''; + $list.append(` + + ${tool.name} + ${tool.description} + ★ + + `); + }); + } + + // 필터 버튼 클릭 -> 목록 갱신 + $(document).on('click', '.filter-btn', function() { + $('.filter-btn').removeClass('active'); + $(this).addClass('active'); + const filter = $(this).data('filter'); + renderTools(filter); + }); + + // 도구 카드 클릭 이벤트 (즐겨찾기 별 클릭 제외) + $(document).on('click', '.tool-card', function(e) { + if (!$(e.target).hasClass('tool-fav')) { + const toolId = $(this).data('tool-id'); + selectTool(toolId); + } + }); + + // 즐겨찾기 별 토글 + $(document).on('click', '.tool-fav', function(e) { + e.stopPropagation(); + const id = $(this).data('id'); + if (favorites.includes(id)) { + favorites = favorites.filter(f => f !== id); + } else { + favorites.push(id); + } + localStorage.setItem('favorites', JSON.stringify(favorites)); + const filter = $('.filter-btn.active').data('filter') || 'all'; + renderTools(filter); + }); + // ---------- 도구 목록 End ---------- + + // ---------- 파일 업로드 UI ---------- + // 파일 리스트 헤더 '+' 버튼 클릭 → 파일 선택창 오픈 + $('.file-add-btn').on('click', function(){ + $('.chat-file-input').click(); + }); + + // 파일 선택 후 업로드 요청 + $('.chat-file-input').on('change', async function(){ + const files = this.files; + if(!files || files.length===0) return; + + const formData = new FormData(); + Array.from(files).forEach(f => formData.append('files', f)); + + // ---- dev_chatbot: progress 메시지 표시 ---- + let placeholderIds = []; + if(currentTool && currentTool.id === 'dev_chatbot'){ + const $list = $('.file-list'); + Array.from(files).forEach(f=>{ + const pid = 'upload-'+Date.now()+Math.random().toString(36).slice(2,8); + placeholderIds.push(pid); + $list.append(`공부중...`); + let dotCnt = 3; // start with visible dots + uploadIntervals[pid] = setInterval(()=>{ + dotCnt = (dotCnt + 1) % 4; + const dots = '.'.repeat(dotCnt); + $(`li.uploading[data-temp="${pid}"] .uploading-dots`).text(dots); + }, 400); + }); + } + + try { + const res = await fetch('http://localhost:8010/upload_pdf', { + method: 'POST', + body: formData + }); + const data = await res.json(); + + // 업로드 결과 처리 + if(currentTool && currentTool.id === 'dev_chatbot'){ + if(data.status==='success'){ + const uploaded = data.files || []; + // 성공한 파일 대체 + uploaded.forEach((fname, idx)=>{ + const pid = placeholderIds[idx]; + const $li = $(`li.uploading[data-temp="${pid}"]`); + if($li.length){ + clearInterval(uploadIntervals[pid]); + delete uploadIntervals[pid]; + $li.removeClass('uploading'); + $li.removeAttr('data-temp'); + $li.html(`${fname}✕`); + } + }); + // 업로드되지 않은 placeholder 정리 + if(uploaded.length < placeholderIds.length){ + placeholderIds.slice(uploaded.length).forEach(pid=>{ + clearInterval(uploadIntervals[pid]); + delete uploadIntervals[pid]; + $(`li.uploading[data-temp="${pid}"]`).remove(); + }); + if(uploaded.length===0){ + alert('업로드할 PDF가 없습니다.'); + } + } + // 최종 파일 리스트 새로고침 + loadFileList(); + } else { + // 실패 시 placeholder 제거 + placeholderIds.forEach(pid=>{ + clearInterval(uploadIntervals[pid]); + delete uploadIntervals[pid]; + $(`li.uploading[data-temp="${pid}"]`).remove(); + }); + alert('업로드 실패: '+ (data.message||'알 수 없는 오류')); + } + } else { + // dev_chatbot 아닐 때는 알림 + if(data.status==='success'){ + alert('파일 업로드가 완료되었습니다.'); + } else { + alert('업로드 실패: '+ (data.message||'알 수 없는 오류')); + } + } + + if(data.status==='success'){ + loadFileList(); + } + } catch(err){ + if(currentTool && currentTool.id === 'dev_chatbot'){ + // 오류 시 placeholder 제거 및 경고 + placeholderIds.forEach(pid=>{ + clearInterval(uploadIntervals[pid]); + delete uploadIntervals[pid]; + $(`li.uploading[data-temp="${pid}"]`).remove(); + }); + alert('업로드 오류: ' + err.message); + } else { + alert('업로드 오류: ' + err.message); + } + } finally { + // 같은 파일을 다시 업로드할 수 있도록 값 초기화 + $(this).val(''); + } + }); + // ---------- 파일 업로드 End ---------- + + // ----- 강의 페이지 렌더링 ----- + function renderLecturePage(){ + const $page = $('.page-lecture'); + if($page.length===0) return; + $page.empty(); + const videos=[ + "https://www.youtube.com/watch?v=GlvOHXJT_gI&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=14&t=26s", + "https://www.youtube.com/watch?v=nl34M5bKkVM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=12", + "https://www.youtube.com/watch?v=0sRlrW_UyLk&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=11", + "https://www.youtube.com/watch?v=MtTAprzHOBg&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=10", + "https://www.youtube.com/watch?v=CyWcZWVwCjQ&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=9", + "https://www.youtube.com/watch?v=X7ycln4JREM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=8", + "https://www.youtube.com/watch?v=rrCmsOFt2UU&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=7", + "https://www.youtube.com/watch?v=7cyDMjzcdxM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=6", + "https://www.youtube.com/watch?v=Sr4MEivnt4M&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=5", + "https://www.youtube.com/watch?v=F4ExQ3P_A5w&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=4", + "https://www.youtube.com/watch?v=tiC5k9P93cE&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=3", + "https://www.youtube.com/watch?v=nfPXfsVz6jM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=2", + "https://www.youtube.com/watch?v=9NH3FhBX2ng&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=1" + ]; + const videoHtml1=videos.map(url=>{ + const id=url.match(/v=([\w-]{11})/); + const vid=id? id[1]:''; + const thumb=vid?`https://img.youtube.com/vi/${vid}/hqdefault.jpg`:''; + return ``; + }).join(''); + + // second list + const videos2=[ + "https://www.youtube.com/watch?v=menXWx89QFg&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=14", + "https://www.youtube.com/watch?v=6IsYJy3ussQ&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=13", + "https://www.youtube.com/watch?v=Mpk4LNZ_P4c&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=12", + "https://www.youtube.com/watch?v=r2UMvkwJTRc&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=11", + "https://www.youtube.com/watch?v=4uc58O4poJE&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=10", + "https://www.youtube.com/watch?v=njAu-W-JA6c&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=9", + "https://www.youtube.com/watch?v=SaYxoKVYgsk&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=8", + "https://www.youtube.com/watch?v=sDDW5w2jD2Q&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=7", + "https://www.youtube.com/watch?v=Ext47QeBCbY&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=6", + "https://www.youtube.com/watch?v=og0mGD29Hw8&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=5", + "https://www.youtube.com/watch?v=GkZgTth3guQ&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=4", + "https://www.youtube.com/watch?v=oSZBGZmaTTw&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=3", + "https://www.youtube.com/watch?v=t6Xc1Ey5PMY&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=2", + "https://www.youtube.com/watch?v=aFw4Z9F4Ens&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=1" + ]; + const videoHtml2=videos2.map(url=>{ + const id=url.match(/v=([\w-]{11})/); + const vid=id? id[1]:''; + const thumb=vid?`https://img.youtube.com/vi/${vid}/hqdefault.jpg`:''; + return ``; + }).join(''); + + // third list + const videos3=[ + "https://www.youtube.com/watch?v=kEAV-PqWD_4", + "https://www.youtube.com/watch?v=ha9kn0qe4Mc", + "https://www.youtube.com/watch?v=ISrYHGg2C2c", + "https://www.youtube.com/watch?v=cxOoV2guNQQ", + "https://www.youtube.com/watch?v=oAxunD8k0C8", + "https://www.youtube.com/watch?v=oZ1O6Z9HwW0", + "https://www.youtube.com/watch?v=nyZnrKVaIXU", + "https://www.youtube.com/watch?v=BzwhskWZ-CQ" + ]; + const videoHtml3=videos3.map(url=>{ + const id=url.match(/v=([\w-]{11})/); + const vid=id? id[1]:''; + const thumb=vid?`https://img.youtube.com/vi/${vid}/hqdefault.jpg`:''; + return ``; + }).join(''); + + $page.append(` + 강수진박사의 프롬프트 엔지니어링의 세계 + ${videoHtml1} + AI, 안해보면 모른다 + ${videoHtml2} + MCP를 배워보자 + ${videoHtml3} + `); + } + + // ---------- 사이드바 메뉴 전환 ---------- + $(document).on('click', '.menu-item', function(){ + $('.menu-item').removeClass('active'); + $(this).addClass('active'); + const page = $(this).data('page'); + $('.page').removeClass('active'); + $('.page-' + page).addClass('active'); + $('.page-title').text($(this).text()); + + // 채팅 페이지로 돌아올 때 현재 도구 상태 유지해서 메시지 다시 렌더링 + if(page === 'chat') { + renderChatMessages(); + } + if(page === 'community') { + $('.community-menu-item').removeClass('active'); + $('.community-menu-item[data-content="newsletter"]').addClass('active'); + showCommunityContent('newsletter'); + } + if(page === 'lecture') { + renderLecturePage(); + } + }); + // ---------- 메뉴 전환 End ---------- + + // ---------- 커뮤니티 서브 메뉴 ---------- + function showCommunityContent(type){ + const $content = $('.community-content'); + if($content.length===0) return; + $content.empty(); + if(type === 'newsletter'){ + $content.append(` + 뉴스레터 + + + 25년 7월호 + + + `); + } else if(type === 'lecture'){ + // 재사용을 위해 내부 함수 + function toThumb(url){ + const id=url.match(/v=([\w-]{11})/); + const vid=id? id[1]:''; + return vid?`https://img.youtube.com/vi/${vid}/hqdefault.jpg`:''; + } + const sec1=[ + "https://www.youtube.com/watch?v=GlvOHXJT_gI&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=14&t=26s", + "https://www.youtube.com/watch?v=nl34M5bKkVM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=12", + "https://www.youtube.com/watch?v=0sRlrW_UyLk&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=11", + "https://www.youtube.com/watch?v=MtTAprzHOBg&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=10", + "https://www.youtube.com/watch?v=CyWcZWVwCjQ&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=9", + "https://www.youtube.com/watch?v=X7ycln4JREM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=8", + "https://www.youtube.com/watch?v=rrCmsOFt2UU&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=7", + "https://www.youtube.com/watch?v=7cyDMjzcdxM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=6", + "https://www.youtube.com/watch?v=Sr4MEivnt4M&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=5", + "https://www.youtube.com/watch?v=F4ExQ3P_A5w&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=4", + "https://www.youtube.com/watch?v=tiC5k9P93cE&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=3", + "https://www.youtube.com/watch?v=nfPXfsVz6jM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=2", + "https://www.youtube.com/watch?v=9NH3FhBX2ng&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=1" + ]; + const sec2=[ + "https://www.youtube.com/watch?v=menXWx89QFg&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=14", + "https://www.youtube.com/watch?v=6IsYJy3ussQ&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=13", + "https://www.youtube.com/watch?v=Mpk4LNZ_P4c&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=12", + "https://www.youtube.com/watch?v=r2UMvkwJTRc&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=11", + "https://www.youtube.com/watch?v=4uc58O4poJE&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=10", + "https://www.youtube.com/watch?v=njAu-W-JA6c&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=9", + "https://www.youtube.com/watch?v=SaYxoKVYgsk&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=8", + "https://www.youtube.com/watch?v=sDDW5w2jD2Q&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=7", + "https://www.youtube.com/watch?v=Ext47QeBCbY&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=6", + "https://www.youtube.com/watch?v=og0mGD29Hw8&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=5", + "https://www.youtube.com/watch?v=GkZgTth3guQ&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=4", + "https://www.youtube.com/watch?v=oSZBGZmaTTw&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=3", + "https://www.youtube.com/watch?v=t6Xc1Ey5PMY&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=2", + "https://www.youtube.com/watch?v=aFw4Z9F4Ens&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=1" + ]; + const sec3=[ + "https://www.youtube.com/watch?v=kEAV-PqWD_4", + "https://www.youtube.com/watch?v=ha9kn0qe4Mc", + "https://www.youtube.com/watch?v=ISrYHGg2C2c", + "https://www.youtube.com/watch?v=cxOoV2guNQQ", + "https://www.youtube.com/watch?v=oAxunD8k0C8", + "https://www.youtube.com/watch?v=oZ1O6Z9HwW0", + "https://www.youtube.com/watch?v=nyZnrKVaIXU", + "https://www.youtube.com/watch?v=BzwhskWZ-CQ" + ]; + const makeHtml=list=>list.map(url=>{ + const thumb=toThumb(url); + return ``; + }).join(''); + + $content.append(` + 강수진박사의 프롬프트 엔지니어링의 세계 + ${makeHtml(sec1)} + AI, 안해보면 모른다 + ${makeHtml(sec2)} + MCP를 배워보자 + ${makeHtml(sec3)} + `); + } else if(type === 'qna'){ + const rows=[ + {no:1,title:'AI 모델 선택은 어떻게?',date:'2025-07-21',views:8}, + {no:2,title:'PDF 뷰어 오류 해결',date:'2025-07-20',views:21}, + {no:3,title:'질의 응답 게시판이 생성되었습니다.',date:'2025-07-19',views:12} + ]; + const rowHtml=rows.map(r=>`${r.no}${r.title}${r.date}${r.views}`).join(''); + $content.append(` + 질의응답 + + 번호제목등록일조회수 + ${rowHtml} + + `); + } + } + + // PDF 뷰 닫기 (X 버튼) + $(document).on('click', '.newsletter-close', function(){ + // 복원 리스트 영역 + const $area = $('.newsletter-content-area'); + if($area.length){ + $area.html(` + + 25년 7월호 + `); + } + }); + + // 뉴스레터 항목 클릭 → PDF 보기 + $(document).on('click', '.newsletter-link', function(e){ + e.preventDefault(); + const fname = $(this).data('file'); + const encoded = encodeURIComponent(fname); + const url = `community/${encoded}`; + const $area = $('.newsletter-content-area'); + if(!$area.length) return; + $area.html(` + + × + + + `); + }); + // 서브 메뉴 클릭 (뉴스레터/질의응답 등) + $(document).on('click', '.community-menu-item', function(){ + $('.community-menu-item').removeClass('active'); + $(this).addClass('active'); + const type = $(this).data('content'); + showCommunityContent(type); + }); + // ---------- 커뮤니티 서브 메뉴 End ---------- + + // ===== Helper ===== + function getMsgKey(toolId, sessionId){ + return sessionId && sessionId!=='temp-session' ? `${toolId}__${sessionId}` : `temp__${toolId}`; + } + // ------------------ + + // 초기 로딩 + loadTools(); + loadFileList(); + renderChatMessages(); +}); \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..adc8f79 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..530bcb0 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,3 @@ +# scripts + +오프라인 처리를 위한 쉘 스크립트 모음 \ No newline at end of file diff --git a/scripts/gxp/Guarnacci-Tobin_Intro-to-GxP-quality-management-systems.pdf b/scripts/gxp/Guarnacci-Tobin_Intro-to-GxP-quality-management-systems.pdf new file mode 100644 index 0000000..108acac Binary files /dev/null and b/scripts/gxp/Guarnacci-Tobin_Intro-to-GxP-quality-management-systems.pdf differ diff --git a/scripts/gxp/oci-gxp-advisory.pdf b/scripts/gxp/oci-gxp-advisory.pdf new file mode 100644 index 0000000..6ef002c Binary files /dev/null and b/scripts/gxp/oci-gxp-advisory.pdf differ diff --git a/scripts/gxp_bulk_ingest.py b/scripts/gxp_bulk_ingest.py new file mode 100644 index 0000000..d86a903 --- /dev/null +++ b/scripts/gxp_bulk_ingest.py @@ -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() \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..b34e45e --- /dev/null +++ b/style.css @@ -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%); /* 중앙 정렬 유지 */ +} \ No newline at end of file diff --git a/table_schema.sql b/table_schema.sql new file mode 100644 index 0000000..73623f8 --- /dev/null +++ b/table_schema.sql @@ -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); + + + +