diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2dec137 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Administrator's Master Key for API Management +ADMIN_API_KEY=your_admin_api_key + +# API Keys for LLM Providers +GEMINI_API_KEY=your_gemini_api_key +ANTHROPIC_API_KEY=your_anthropic_api_key +OPENAI_API_KEY=your_openai_api_key diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df676d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.10-slim + +WORKDIR /workspace + +# 시스템 패키지 설치 +RUN apt-get update && \ + apt-get install -y tree curl && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Python 의존성 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 코드 복사 +COPY workspace/ ./workspace/ + +ENV PYTHONPATH=/workspace/workspace + +# uvicorn 실행 +CMD ["sh", "-c", "uvicorn workspace.api:app --workers 4 --host 0.0.0.0 --port ${PORT:-8889}"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..65630e7 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# LLM Macro + +OCR(광학 문자 인식) 결과를 파싱하고, 지정된 LLM(거대 언어 모델)을 통해 처리한 후, 정제된 결과를 반환하는 API 게이트웨이입니다. + +다양한 LLM 공급자를 지원하며, Docker를 통해 쉽게 배포하고 실행할 수 있습니다. + +## 주요 기능 + +- OCR 결과 텍스트 정제 및 분석 +- 다중 LLM 공급자 지원 (Gemini, Anthropic, OpenAI 등) +- RESTful API 엔드포인트 제공 +- Docker Compose를 이용한 간편한 실행 환경 + +## 프로젝트 구조 + +``` +/ +├── .env # API 키 및 환경 변수 설정 +├── api_keys.json # 클라이언트 API 키 관리 +├── docker-compose.yml # Docker Compose 설정 +├── Dockerfile # Docker 이미지 빌드 설정 +├── requirements.txt # Python 의존성 목록 +├── post/ # OCR 결과 후처리 관련 모듈 +├── result/ # 처리 결과 저장 디렉토리 (Git 무시) +└── workspace/ # API 서버 로직 (FastAPI 기반) +``` + +## 시작하기 + +### 사전 요구사항 + +- Git +- Docker +- Docker Compose + +### 설치 및 실행 + +1. **프로젝트 클론** + + ```bash + git clone https://github.com/your-username/llm_gateway_macro.git + cd llm_gateway_macro + ``` + +2. **환경 변수 설정** + `.env.example` 파일을 복사하여 `.env` 파일을 생성하고, 내부에 실제 API 키들을 입력합니다. + + ```bash + cp .env.example .env + ``` + + ```dotenv + # .env + ADMIN_API_KEY=your_admin_api_key + GEMINI_API_KEY=your_gemini_api_key + ANTHROPIC_API_KEY=your_anthropic_api_key + OPENAI_API_KEY=your_openai_api_key + ``` + +3. **클라이언트 API 키 설정** + `api_keys.json.example` 파일을 복사하여 `api_keys.json` 파일을 생성하고, API를 호출할 클라이언트의 키를 설정합니다. + + ```bash + cp api_keys.json.example api_keys.json + ``` + +4. **Docker 컨테이너 실행** + 아래 명령어를 실행하여 애플리케이션을 백그라운드에서 시작합니다. + + ```bash + docker-compose up -d --build + ``` + +5. **로그 확인 (선택 사항)** + ```bash + docker-compose logs -f + ``` + +## API 사용법 + +### OCR 결과 처리 요청 + +- **Endpoint:** `POST /api/v1/process` +- **Header:** `X-API-KEY: {your_client_api_key}` +- **Body:** + +```json +{ + "ocr_text": "여기에 OCR 결과로 추출된 텍스트를 입력합니다.", + "llm_provider": "gemini" // "gemini", "openai", "anthropic" 중 선택 +} +``` + +### cURL 예시 + +```bash +curl -X POST "http://localhost:8000/api/v1/process" \ +-H "Content-Type: application/json" \ +-H "X-API-KEY: your_client_api_key" \ +-d '{ + "ocr_text": "처리할 OCR 텍스트입니다.", + "llm_provider": "gemini" +}' +``` + +### 응답 예시 + +```json +{ + "original_text": "처리할 OCR 텍스트입니다.", + "processed_text": "LLM에 의해 처리된 결과입니다.", + "provider": "gemini" +} +``` + +## 라이선스 + +이 프로젝트는 [라이선스 이름] 라이선스를 따릅니다. 자세한 내용은 `LICENSE` 파일을 참고하세요. + +``` + +``` diff --git a/api_keys.json.example b/api_keys.json.example new file mode 100644 index 0000000..08e0312 --- /dev/null +++ b/api_keys.json.example @@ -0,0 +1,7 @@ +{ + "your_api_key": { + "client_name": "example_client", + "created_at": "timestamp", + "is_active": "true" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ff77975 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + llm_macro: + build: + context: . + image: llm_macro + container_name: llm_macro_api + volumes: + - ./:/workspace + ports: + - "8889:8889" + env_file: + - .env + environment: + - TZ=Asia/Seoul + stdin_open: true + restart: always + tty: true + networks: + - llm_macro_net + + llm_macro_redis: + image: redis:7-alpine + container_name: llm_macro_redis + command: + [ + "redis-server", + "--maxmemory", + "256mb", + "--maxmemory-policy", + "allkeys-lru", + ] + ports: + - "6666:6379" + restart: always + networks: + - llm_macro_net + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +networks: + llm_macro_net: + driver: bridge diff --git a/post/filter.py b/post/filter.py new file mode 100644 index 0000000..ec1699b --- /dev/null +++ b/post/filter.py @@ -0,0 +1,35 @@ +# filter.py +import shutil +from pathlib import Path + +# 경로 설정 +DATA_ORIGIN_DIR = Path("data_origin") +DATA_DIR = Path("data") +DATA_JSON_DIR = Path("data_json") + + +def main(): + # data_json 폴더 없으면 생성 + DATA_JSON_DIR.mkdir(parents=True, exist_ok=True) + + # data_origin의 파일명(확장자 제외) 집합 생성 + origin_names = {f.stem for f in DATA_ORIGIN_DIR.iterdir() if f.is_file()} + print(f"[INFO] data_origin 파일명(확장자 제외) 개수: {len(origin_names)}") + + moved_count = 0 + + # data 폴더 순회 + for file_path in DATA_DIR.iterdir(): + if not file_path.is_file(): + continue + if file_path.stem in origin_names: + target_path = DATA_JSON_DIR / file_path.name + shutil.move(str(file_path), target_path) + moved_count += 1 + print(f"[MOVE] {file_path.name} -> {target_path}") + + print(f"[DONE] 이동된 파일 수: {moved_count}") + + +if __name__ == "__main__": + main() diff --git a/post/main.py b/post/main.py new file mode 100644 index 0000000..90d493c --- /dev/null +++ b/post/main.py @@ -0,0 +1,132 @@ +# python3 main.py --endpoint gemma3 +import argparse +import os +import time + +import requests + +# API 키 +API_KEY = "sk-dade0cb396c744ec431357cedd5784c2" + +# 지원하는 파일 확장자 +SUPPORTED_EXTENSIONS = (".pdf", ".png", ".jpg", ".jpeg", ".docx", ".json") + + +def send_request(api_endpoint, file_path, model_name, prompt_file_path, source_dir): + """지정된 파일과 옵션으로 API에 멀티모달 추출 요청을 보냅니다.""" + print( + f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] '{os.path.basename(file_path)}' 파일 전송 중..." + ) + + try: + headers = {"x-api-key": API_KEY} + + with open(file_path, "rb") as input_file: + files = {"input_file": (os.path.basename(file_path), input_file)} + data = {"source_dir": source_dir} + + if model_name: + data["model"] = model_name + + if prompt_file_path: + try: + # 프롬프트 파일을 열어 files 딕셔너리에 추가 + prompt_file_opened = open(prompt_file_path, "rb") + files["prompt_file"] = ( + os.path.basename(prompt_file_path), + prompt_file_opened, + ) + except FileNotFoundError: + print( + f" [오류] 프롬프트 파일을 찾을 수 없습니다: {prompt_file_path}" + ) + return + + response = requests.post( + api_endpoint, headers=headers, files=files, data=data, timeout=30 + ) + + # 프롬프트 파일이 열려있다면 닫기 + if "prompt_file" in files and not files["prompt_file"][1].closed: + files["prompt_file"][1].close() + + response.raise_for_status() # HTTP 오류 발생 시 예외 발생 + + result = response.json() + request_id = result.get("request_id") + print(f" [성공] 요청 ID: {request_id}") + + except requests.exceptions.RequestException as e: + print(f" [오류] API 요청 중 문제가 발생했습니다: {e}") + except Exception as e: + print(f" [오류] 알 수 없는 오류가 발생했습니다: {e}") + + +def main(model_name, prompt_file_path, endpoint_name): + """데이터 디렉토리를 순회하며 지원하는 각 파일에 대해 API 요청을 보냅니다.""" + api_endpoint = f"http://localhost:8889/costs/{endpoint_name}" + base_data_directory = "ocr_results" + target_dirs = ["pp-ocr", "pp-structure", "upstage"] + total_file_count = 0 + skipped_file_count = 0 + + for dir_name in target_dirs: + data_directory = os.path.join(base_data_directory, dir_name) + # Correctly construct the result directory path relative to the project root + result_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'result', f"{endpoint_name}-{dir_name}") + + print(f"대상 디렉토리: {data_directory}") + print(f"결과 확인 디렉토리: {result_dir}") + print("-" * 30) + + if not os.path.isdir(data_directory): + print(f"[오류] 디렉토리를 찾을 수 없습니다: {data_directory}") + continue + + file_count = 0 + for root, _, files in os.walk(data_directory): + for file in files: + if file.lower().endswith(SUPPORTED_EXTENSIONS): + # 결과 파일 존재 여부 확인 + result_file_path = os.path.join(result_dir, file) + if os.path.exists(result_file_path): + print(f" [건너뛰기] 이미 결과가 존재합니다: {file}") + skipped_file_count += 1 + continue + + file_path = os.path.join(root, file) + send_request(api_endpoint, file_path, model_name, prompt_file_path, dir_name) + print(" [대기] 다음 요청까지 30초 대기...") + time.sleep(30) + file_count += 1 + + total_file_count += file_count + print(f"'{dir_name}' 디렉토리의 {file_count}개 파일 요청 완료.") + + print("-" * 30) + print(f"총 {total_file_count}개의 파일에 대한 요청을 완료했으며, {skipped_file_count}개를 건너뛰었습니다.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="멀티모달 API에 파일들을 순차적으로 요청하는 스크립트" + ) + + parser.add_argument( + "--endpoint", + type=str, + default="gemini", + help="호출할 API 엔드포인트 이름 (예: gemini, gemma3)", + dest="endpoint_name" + ) + parser.add_argument( + "--model", type=str, help="사용할 LLM 모델 이름" + ) + parser.add_argument( + "--prompt_file", + type=str, + help="구조화에 사용할 커스텀 .txt 프롬프트 파일의 경로", + ) + + args = parser.parse_args() + main(args.model, args.prompt_file, args.endpoint_name) diff --git a/post/main_sync.py b/post/main_sync.py new file mode 100644 index 0000000..43441fb --- /dev/null +++ b/post/main_sync.py @@ -0,0 +1,148 @@ +import argparse +import json +import os +import time + +import requests + +# API 키 +API_KEY = "sk-dade0cb396c744ec431357cedd5784c2" + +# 지원하는 파일 확장자 +SUPPORTED_EXTENSIONS = (".pdf", ".png", ".jpg", ".jpeg", ".docx", ".json") + + +def send_sync_request( + api_endpoint, file_path, model_name, prompt_file_path, source_dir, result_file_path +): + """지정된 파일과 옵션으로 API에 동기 요청을 보내고 결과를 저장합니다.""" + print( + f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] '{os.path.basename(file_path)}' 파일 동기 전송 중..." + ) + try: + headers = {"x-api-key": API_KEY} + with open(file_path, "rb") as input_file: + files = {"input_file": (os.path.basename(file_path), input_file)} + data = {"source_dir": source_dir} + if model_name: + data["model"] = model_name + + if prompt_file_path: + try: + prompt_file_opened = open(prompt_file_path, "rb") + files["prompt_file"] = ( + os.path.basename(prompt_file_path), + prompt_file_opened, + ) + except FileNotFoundError: + print( + f" [오류] 프롬프트 파일을 찾을 수 없습니다: {prompt_file_path}" + ) + if ( + "prompt_file_opened" in locals() + and not prompt_file_opened.closed + ): + prompt_file_opened.close() + return + + # 동기 요청이므로 작업이 끝날 때까지 기다립니다. 타임아웃을 넉넉하게 설정합니다. + response = requests.post( + api_endpoint, headers=headers, files=files, data=data, timeout=300 + ) # 5분 타임아웃 + + if "prompt_file" in files and not files["prompt_file"][1].closed: + files["prompt_file"][1].close() + + response.raise_for_status() + + result_data = response.json() + + # 서버로부터 받은 최종 결과를 파일에 저장합니다. + os.makedirs(os.path.dirname(result_file_path), exist_ok=True) + with open(result_file_path, "w", encoding="utf-8") as f: + json.dump(result_data, f, ensure_ascii=False, indent=4) + + print(f" [성공] 결과 저장 완료: {os.path.basename(result_file_path)}") + + except requests.exceptions.Timeout: + print(" [오류] 요청 시간이 초과되었습니다 (Timeout).") + except requests.exceptions.RequestException as e: + print(f" [오류] API 요청 중 문제가 발생했습니다: {e}") + except Exception as e: + print(f" [오류] 알 수 없는 오류가 발생했습니다: {e}") + + +def main(model_name, prompt_file_path, endpoint_name): + """데이터 디렉토리를 순회하며 지원하는 각 파일에 대해 API 요청을 보냅니다.""" + api_endpoint = f"http://localhost:8889/costs/{endpoint_name}/sync" + base_data_directory = "ocr_results" + target_dirs = ["pp-ocr", "pp-structure", "upstage"] + total_file_count = 0 + skipped_file_count = 0 + + for dir_name in target_dirs: + data_directory = os.path.join(base_data_directory, dir_name) + result_dir = os.path.join("..", "result", f"{endpoint_name}-{dir_name}") + + print(f"대상 디렉토리: {data_directory}") + print(f"결과 확인 디렉토리: {os.path.abspath(result_dir)}") + print("-" * 30) + + if not os.path.isdir(data_directory): + print(f"[오류] 디렉토리를 찾을 수 없습니다: {data_directory}") + continue + + file_count = 0 + for root, _, files in os.walk(data_directory): + for file in files: + if file.lower().endswith(SUPPORTED_EXTENSIONS): + result_file_path = os.path.join(result_dir, file) + if os.path.exists(result_file_path): + print(f" [건너뛰기] 이미 결과가 존재합니다: {file}") + skipped_file_count += 1 + continue + + file_path = os.path.join(root, file) + send_sync_request( + api_endpoint, + file_path, + model_name, + prompt_file_path, + dir_name, + result_file_path, + ) + # 서버 부하 감소를 위한 최소한의 대기 시간 + print(" [대기] 다음 요청까지 2초 대기...") + time.sleep(2) + file_count += 1 + + total_file_count += file_count + print(f"'{dir_name}' 디렉토리의 {file_count}개 파일 요청 완료.") + + print("-" * 30) + print( + f"총 {total_file_count}개의 파일에 대한 요청을 완료했으며, {skipped_file_count}개를 건너뛰었습니다." + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="멀티모달 API에 파일들을 동기적으로 요청하는 스크립트" + ) + + parser.add_argument( + "--endpoint", + type=str, + default="gemma3", + help="호출할 API 엔드포인트 이름 (예: gemini, gemma3)", + dest="endpoint_name", + ) + parser.add_argument("--model", type=str, help="사용할 LLM 모델 이름") + parser.add_argument( + "--prompt_file", + type=str, + help="구조화에 사용할 커스텀 .txt 프롬프트 파일의 경로", + ) + + args = parser.parse_args() + main(args.model, args.prompt_file, args.endpoint_name) diff --git a/post/requirements.txt b/post/requirements.txt new file mode 100644 index 0000000..2c8b74e --- /dev/null +++ b/post/requirements.txt @@ -0,0 +1,2 @@ +requests +tqdm \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e413e0a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +uvicorn[standard] +fastapi +python-multipart +python-dotenv +google-generativeai +redis +minio +prometheus-fastapi-instrumentator +snowflake-id +httpx diff --git a/tools/delete_error_and_empty_files.py b/tools/delete_error_and_empty_files.py new file mode 100644 index 0000000..92c285a --- /dev/null +++ b/tools/delete_error_and_empty_files.py @@ -0,0 +1,30 @@ +import os +import json + +dir_path = "result/gemma3-upstage/" +files_to_delete = [] + +for filename in os.listdir(dir_path): + if filename.endswith(".json"): + file_path = os.path.join(dir_path, filename) + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if "result" in data: + result_data = data["result"] + # Check if result is an empty dictionary or contains "Error" key + if not result_data or "Error" in result_data: + files_to_delete.append(file_path) + + except (json.JSONDecodeError, KeyError) as e: + print(f"Error processing file {filename}: {e}") + +for file_path in files_to_delete: + try: + os.remove(file_path) + print(f"Deleted: {os.path.basename(file_path)}") + except OSError as e: + print(f"Error deleting file {os.path.basename(file_path)}: {e}") + +print(f"\nTotal deleted files: {len(files_to_delete)}") diff --git a/workspace/api.py b/workspace/api.py new file mode 100644 index 0000000..3ba7f02 --- /dev/null +++ b/workspace/api.py @@ -0,0 +1,66 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, HTTPException +from fastapi.staticfiles import StaticFiles +from routers.costs_router import router as costs_router +from services.api_key_service import load_api_keys_from_file +from utils.checking_keys import get_admin_key, get_api_key +from utils.redis_utils import get_redis_client + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s - %(message)s" +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 애플리케이션 시작 시 파일에서 API 키 로드 + print("Loading API keys from file...") + load_api_keys_from_file() + yield + + +app = FastAPI( + title="LLM GATEWAY", + description="LLM 모델이 업로드된 문서를 분석하여 구조화된 JSON으로 변환하는 API 서비스입니다.", + docs_url="/docs", + lifespan=lifespan, +) + + +# API 키 검증을 위한 의존성 설정 +api_key_dependency = Depends(get_api_key) +admin_key_dependency = Depends(get_admin_key) + + +# 커스텀 라벨 콜백 함수 +def custom_labels(info): + # info.request 는 Starlette의 Request 객체 + return {"job_id": info.request.headers.get("X-Job-ID", "unknown")} + + +app.mount( + "/static", StaticFiles(directory="/workspace/workspace/static"), name="static" +) + + +@app.get("/health/API") +async def health_check(): + """애플리케이션 상태 확인""" + return {"status": "API ok"} + + +@app.get("/health/Redis") +def redis_health_check(): + client = get_redis_client() + if client is None: + raise HTTPException(status_code=500, detail="Redis connection failed") + try: + client.ping() + return {"status": "Redis ok"} + except Exception: + raise HTTPException(status_code=500, detail="Redis ping failed") + + +app.include_router(costs_router, dependencies=[api_key_dependency]) diff --git a/workspace/config/__init__.py b/workspace/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workspace/config/setting.py b/workspace/config/setting.py new file mode 100644 index 0000000..14cfc8f --- /dev/null +++ b/workspace/config/setting.py @@ -0,0 +1,16 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] # /workspace +STATIC_DIR = PROJECT_ROOT / "static" + +# 프롬프트 & 스키마 경로 +DEFAULT_PROMPT_PATH = STATIC_DIR / "prompt" / "default_prompt_v0.1.txt" +STRUCTURED_PROMPT_PATH = STATIC_DIR / "prompt" / "structured_prompt_v0.1.txt" +I18N_PROMPT_PATH = STATIC_DIR / "prompt" / "i18n_test_prompt_kor.txt" +D6C_PROMPT_PATH = STATIC_DIR / "prompt" / "d6c_test_prompt_eng.txt" +STRUCTURED_SCHEMA_PATH = STATIC_DIR / "structured_schema.json" + +# llm_gateway 서비스 Redis 설정 +PGN_REDIS_HOST = "llm_macro_redis" +PGN_REDIS_PORT = 6379 +PGN_REDIS_DB = 2 diff --git a/workspace/routers/costs_router.py b/workspace/routers/costs_router.py new file mode 100644 index 0000000..2f39180 --- /dev/null +++ b/workspace/routers/costs_router.py @@ -0,0 +1,295 @@ +import asyncio +import io +import json +import os +from typing import Optional + +from config.setting import ( + DEFAULT_PROMPT_PATH, + PGN_REDIS_DB, + PGN_REDIS_HOST, + PGN_REDIS_PORT, +) +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse +from redis import Redis +from services.gemini_service import GeminiService +from services.ollama_service import OllamaService +from utils.checking_keys import create_key, get_api_key +from utils.text_processor import post_process + +# Redis 클라이언트 +redis_client = Redis( + host=PGN_REDIS_HOST, port=PGN_REDIS_PORT, db=PGN_REDIS_DB, decode_responses=True +) + +router = APIRouter(prefix="/llm", tags=["target model"]) + + +def clone_upload_file(upload_file: UploadFile) -> io.BytesIO: + """UploadFile을 메모리 내에서 복제하여 백그라운드 작업에 전달합니다.""" + file_content = upload_file.file.read() + upload_file.file.seek(0) # 원본 파일 포인터를 재설정 + return io.BytesIO(file_content) + + +async def run_gemini_background_task( + result_id: str, + input_file_name: str, + input_file_clone: io.BytesIO, + model: str, + source_dir: Optional[str], +): + """Gemini API 호출 및 결과 저장을 처리하는 백그라운드 작업""" + try: + # 1. Read the default prompt + with open(DEFAULT_PROMPT_PATH, "r", encoding="utf-8") as f: + default_prompt = f.read() + + # 2. Read and parse the cloned input_file + input_data = input_file_clone.read() + input_json = json.loads(input_data) + parsed_value = input_json.get("parsed", "") + + # 3. Combine prompt and parsed value + combined_prompt = f"{default_prompt}\n\n{parsed_value}" + + # 4. Call Gemini API + gemini_service = GeminiService() + gemini_response = await gemini_service.generate_content( + [combined_prompt], model=model + ) + + # 5. Post-process the response + processed_result = post_process(input_json, gemini_response, model) + + # 6. Save the result to Redis + redis_key = f"pipeline_result:{result_id}" + redis_client.set( + redis_key, json.dumps(processed_result, ensure_ascii=False), ex=3600 + ) + + # 7. Save the result to a local file + if source_dir: + output_dir = os.path.join("result", f"gemini-{source_dir}") + else: + output_dir = "result" + os.makedirs(output_dir, exist_ok=True) + output_filename = f"{input_file_name}" + output_path = os.path.join(output_dir, output_filename) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(processed_result, f, ensure_ascii=False, indent=4) + + except Exception as e: + # 에러 발생 시 Redis에 에러 메시지 저장 + redis_key = f"pipeline_result:{result_id}" + redis_client.set(redis_key, json.dumps({"error": str(e)}), ex=3600) + + +@router.post( + "/gemini", + summary="해외 문서 테스트용 (백그라운드)", +) +async def costs_gemini_background( + request_info: Request, + input_file: UploadFile = File(...), + model: Optional[str] = Form(default="gemini-2.5-flash"), + source_dir: Optional[str] = Form(default=None), + api_key: str = Depends(get_api_key), +): + request_id = create_key(request_info.client.host) + result_id = create_key(request_id) + + # 파일 복제 + input_file_clone = clone_upload_file(input_file) + + # 백그라운드 작업 시작 + asyncio.create_task( + run_gemini_background_task( + result_id=result_id, + input_file_name=input_file.filename, + input_file_clone=input_file_clone, + model=model, + source_dir=source_dir, + ) + ) + + # 요청 ID와 결과 ID를 매핑하여 Redis에 저장 + redis_client.hset("pipeline_result_mapping", request_id, result_id) + + return JSONResponse( + content={ + "message": "문서 처리 작업이 백그라운드에서 시작되었습니다.", + "request_id": request_id, + "status_check_url": f"/costs/progress/{request_id}", + } + ) + + +@router.post( + "/gemma3", + summary="Gemma3 모델 테스트용 (백그라운드)", +) +async def costs_gemma3_background( + request_info: Request, + input_file: UploadFile = File(...), + model: Optional[str] = Form(default="gemma3:27b"), + source_dir: Optional[str] = Form(default=None), + api_key: str = Depends(get_api_key), +): + request_id = create_key(request_info.client.host) + result_id = create_key(request_id) + + # 파일 복제 + input_file_clone = clone_upload_file(input_file) + + # 백그라운드 작업 시작 + asyncio.create_task( + run_gemma3_background_task( + result_id=result_id, + input_file_name=input_file.filename, + input_file_clone=input_file_clone, + model=model, + source_dir=source_dir, + ) + ) + + # 요청 ID와 결과 ID를 매핑하여 Redis에 저장 + redis_client.hset("pipeline_result_mapping", request_id, result_id) + + return JSONResponse( + content={ + "message": "Gemma3 문서 처리 작업이 백그라운드에서 시작되었습니다.", + "request_id": request_id, + "status_check_url": f"/costs/progress/{request_id}", + } + ) + + +@router.get("/progress/{request_id}", summary="작업 진행 상태 및 결과 확인") +async def get_progress(request_id: str, api_key: str = Depends(get_api_key)): + """ + request_id를 사용하여 작업의 진행 상태를 확인하고, 완료 시 결과를 반환합니다. + """ + result_id = redis_client.hget("pipeline_result_mapping", request_id) + if not result_id: + raise HTTPException(status_code=404, detail="잘못된 요청 ID입니다.") + + redis_key = f"pipeline_result:{result_id}" + result = redis_client.get(redis_key) + + if result: + # 결과가 Redis에 있으면, JSON으로 파싱하여 반환 + return JSONResponse(content=json.loads(result)) + else: + # 결과가 아직 없으면, 처리 중임을 알림 + return JSONResponse( + content={"status": "processing", "message": "작업이 아직 처리 중입니다."}, + status_code=202, + ) + + +@router.post( + "/gemma3/sync", + summary="Gemma3 모델 동기 처리", +) +async def costs_gemma3_sync( + request_info: Request, + input_file: UploadFile = File(...), + model: Optional[str] = Form(default="gemma3:27b"), + source_dir: Optional[str] = Form(default=None), + api_key: str = Depends(get_api_key), +): + """Ollama 동기 처리 및 결과 반환""" + try: + # 1. Read the default prompt + with open(DEFAULT_PROMPT_PATH, "r", encoding="utf-8") as f: + default_prompt = f.read() + + # 2. Read and parse the input_file + input_data = await input_file.read() + input_json = json.loads(input_data) + parsed_value = input_json.get("parsed", "") + + # 3. Combine prompt and parsed value + combined_prompt = f"{default_prompt}\n\n{parsed_value}" + + # 4. Call Gemma API + ollama_service = OllamaService() + gemma_response = await ollama_service.generate_content( + combined_prompt, model=model + ) + + # 5. Post-process the response + processed_result = post_process(input_json, gemma_response, model) + + # 6. Save the result to a local file on the server + if source_dir: + output_dir = os.path.join("result", f"gemma3-{source_dir}") + else: + output_dir = "result" + os.makedirs(output_dir, exist_ok=True) + output_filename = f"{input_file.filename}" + output_path = os.path.join(output_dir, output_filename) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(processed_result, f, ensure_ascii=False, indent=4) + + return JSONResponse(content=processed_result) + + except Exception as e: + # Log the exception for debugging + print(f"Error in gemma3/sync endpoint: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +async def run_gemma3_background_task( + result_id: str, + input_file_name: str, + input_file_clone: io.BytesIO, + model: str, + source_dir: Optional[str], +): + """Gemma API 호출 및 결과 저장을 처리하는 백그라운드 작업""" + try: + # 1. Read the default prompt + with open(DEFAULT_PROMPT_PATH, "r", encoding="utf-8") as f: + default_prompt = f.read() + + # 2. Read and parse the cloned input_file + input_data = input_file_clone.read() + input_json = json.loads(input_data) + parsed_value = input_json.get("parsed", "") + + # 3. Combine prompt and parsed value + combined_prompt = f"{default_prompt}\n\n{parsed_value}" + + # 4. Call Gemma API + ollama_service = OllamaService() + gemma_response = await ollama_service.generate_content( + combined_prompt, model=model + ) + + # 5. Post-process the response + processed_result = post_process(input_json, gemma_response, model) + + # 6. Save the result to Redis + redis_key = f"pipeline_result:{result_id}" + redis_client.set( + redis_key, json.dumps(processed_result, ensure_ascii=False), ex=3600 + ) + + # 7. Save the result to a local file + if source_dir: + output_dir = os.path.join("result", f"gemma3-{source_dir}") + else: + output_dir = "result" + os.makedirs(output_dir, exist_ok=True) + output_filename = f"{input_file_name}" + output_path = os.path.join(output_dir, output_filename) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(processed_result, f, ensure_ascii=False, indent=4) + + except Exception as e: + # 에러 발생 시 Redis에 에러 메시지 저장 + redis_key = f"pipeline_result:{result_id}" + redis_client.set(redis_key, json.dumps({"error": str(e)}), ex=3600) diff --git a/workspace/services/api_key_service.py b/workspace/services/api_key_service.py new file mode 100644 index 0000000..0f6404d --- /dev/null +++ b/workspace/services/api_key_service.py @@ -0,0 +1,167 @@ +import json +import os +import secrets +import time + +from utils.redis_utils import get_redis_client + +# Redis에 API 키를 저장할 때 사용할 접두사 +API_KEY_PREFIX = "api_key:" +# Docker 컨테이너의 /workspace 디렉토리에 파일을 저장하도록 절대 경로 사용 +API_KEYS_FILE = "/workspace/api_keys.json" + + +def _read_keys_from_file(): + """Helper function to read all keys from the JSON file.""" + if not os.path.exists(API_KEYS_FILE): + return {} + with open(API_KEYS_FILE, "r") as f: + try: + return json.load(f) + except json.JSONDecodeError: + return {} + + +def _write_keys_to_file(keys): + """Helper function to write all keys to the JSON file.""" + with open(API_KEYS_FILE, "w") as f: + json.dump(keys, f, indent=4) + + +import redis + + +def load_api_keys_from_file(): + """ + JSON 파일에서 API 키를 읽어 Redis에 로드합니다. + Redis 연결 실패 시 몇 초간 재시도하여 시작 시점의 문제를 해결합니다. + """ + keys_from_file = _read_keys_from_file() + if not keys_from_file: + print("API key file not found or empty. Skipping loading.") + return + + redis_client = get_redis_client() + max_retries = 5 + retry_delay = 2 # 초 + + for i in range(max_retries): + try: + # Redis 연결 테스트 + redis_client.ping() + + # 연결 성공 시 키 로드 + for key_name, key_data in keys_from_file.items(): + if not redis_client.exists(key_name): + redis_client.hset(key_name, mapping=key_data) + print(f"Loaded API key from file: {key_name}") + + print("Successfully loaded all keys into Redis.") + return # 성공 시 함수 종료 + + except redis.exceptions.ConnectionError as e: + print(f"Could not connect to Redis (attempt {i+1}/{max_retries}): {e}") + if i < max_retries - 1: + print(f"Retrying in {retry_delay} seconds...") + time.sleep(retry_delay) + else: + print("Failed to load API keys into Redis after multiple retries.") + break + + +def generate_api_key(prefix="sk") -> str: + """안전한 API 키를 생성합니다. (예: sk-xxxxxxxx)""" + return f"{prefix}-{secrets.token_hex(16)}" + + +def create_api_key(client_name: str, key_prefix="sk") -> dict: + """ + 새로운 API 키를 생성하고 Redis와 파일에 저장합니다. + """ + api_key = generate_api_key(prefix=key_prefix) + redis_client = get_redis_client() + + key_storage_name = f"{API_KEY_PREFIX}{api_key}" + key_data = { + "client_name": client_name, + "created_at": str(int(time.time())), + "is_active": "true", + } + + # Redis에 저장 (hset 사용) + redis_client.hset(key_storage_name, mapping=key_data) + + # 파일에 즉시 저장 + all_keys = _read_keys_from_file() + all_keys[key_storage_name] = key_data + _write_keys_to_file(all_keys) + + return {"api_key": api_key, **key_data} + + +def validate_api_key(api_key: str) -> bool: + """ + 제공된 API 키가 유효한지 검증합니다. decode_responses=True로 인해 모든 값은 문자열입니다. + 1. Redis에서 먼저 확인합니다. + 2. Redis에 없으면 api_keys.json 파일에서 확인합니다. + 3. 파일에서 유효한 키를 찾으면 Redis에 다시 동기화합니다. + """ + if not api_key: + return False + + redis_client = get_redis_client() + key_storage_name = f"{API_KEY_PREFIX}{api_key}" + + # 1. Redis에서 확인 (decode_responses=True이므로 반환값은 문자열) + is_active_in_redis = redis_client.hget(key_storage_name, "is_active") + if is_active_in_redis == "true": + return True + + # 2. Redis에 없으면 파일에서 확인 + all_keys_from_file = _read_keys_from_file() + key_data_from_file = all_keys_from_file.get(key_storage_name) + + if key_data_from_file and key_data_from_file.get("is_active") == "true": + # 3. 파일에 유효한 키가 있으면 Redis에 다시 기록 (Self-healing, hset 사용) + redis_client.hset(key_storage_name, mapping=key_data_from_file) + print(f"Key '{key_storage_name}' not found in Redis, but restored from file.") + return True + + return False + + +def revoke_api_key(api_key: str) -> bool: + """ + API 키를 Redis와 파일에서 삭제하여 폐기합니다. + """ + redis_client = get_redis_client() + key_storage_name = f"{API_KEY_PREFIX}{api_key}" + + # Redis에서 삭제 + result = redis_client.delete(key_storage_name) + + if result > 0: + # 파일에서도 삭제 + all_keys = _read_keys_from_file() + if key_storage_name in all_keys: + del all_keys[key_storage_name] + _write_keys_to_file(all_keys) + return True + return False + + +def list_api_keys() -> list: + """ + 저장된 모든 API 키의 목록을 반환합니다. + (주의: 실제 환경에서는 키 자체를 노출하지 않는 것이 좋습니다) + """ + redis_client = get_redis_client() + keys = [] + + # decode_responses=True이므로 모든 키와 값은 문자열. + for key_name in redis_client.scan_iter(f"{API_KEY_PREFIX}*"): + key_data = redis_client.hgetall(key_name) + key_data["api_key"] = key_name.replace(API_KEY_PREFIX, "", 1) + keys.append(key_data) + + return keys diff --git a/workspace/services/gemini_service.py b/workspace/services/gemini_service.py new file mode 100644 index 0000000..a1cc0cd --- /dev/null +++ b/workspace/services/gemini_service.py @@ -0,0 +1,22 @@ +import os +import google.generativeai as genai +from dotenv import load_dotenv +from typing import List + +load_dotenv() + +class GeminiService: + def __init__(self): + self.api_key = os.getenv("GEMINI_API_KEY") + if not self.api_key: + raise ValueError("GEMINI_API_KEY not found in .env file") + genai.configure(api_key=self.api_key) + + async def generate_content(self, prompts: List[str], model: str = "gemini-2.5-flash"): + """ + Generates content using the Gemini API. + """ + model_instance = genai.GenerativeModel(model) + response = await model_instance.generate_content_async(prompts) + return response.text + diff --git a/workspace/services/ollama_service.py b/workspace/services/ollama_service.py new file mode 100644 index 0000000..b59b35f --- /dev/null +++ b/workspace/services/ollama_service.py @@ -0,0 +1,27 @@ +import httpx + +OLLAMA_API_URL = "http://172.16.10.176:11534/api/generate" + + +class OllamaService: + async def generate_content(self, prompt: str, model: str = "gemma:latest"): + """Ollama API를 호출하여 콘텐츠를 생성합니다.""" + async with httpx.AsyncClient(timeout=120.0) as client: + try: + payload = { + "model": model, + "prompt": prompt, + "stream": False, + "keep_alive": "30m", + } + response = await client.post(OLLAMA_API_URL, json=payload) + response.raise_for_status() + + response_json = response.json() + return response_json.get("response", "") + except httpx.RequestError as e: + print(f"Ollama API 요청 중 오류 발생: {e}") + return f"Error: {e}" + except Exception as e: + print(f"Ollama 서비스에서 예기치 않은 오류 발생: {e}") + return f"An unexpected error occurred: {e}" diff --git a/workspace/static/dummy_response.json b/workspace/static/dummy_response.json new file mode 100644 index 0000000..e40400d --- /dev/null +++ b/workspace/static/dummy_response.json @@ -0,0 +1,42 @@ +[ + { + "filename": "250107_out_SYJV-250001_Advanced Mobilization.pdf", + "outer_model": { + "ocr_model": "OCR not used", + "llm_model": "gpt-4.1", + "api_url": "OpenAI Python SDK" + }, + "time": { + "duration_sec": "8.24", + "started_at": 1747614863.8500028, + "ended_at": 1747614872.089025 + }, + "fields": [], + "parsed": "SEOYOUNG JOINT VENTURE \n \n \nRef. No. SYJV-250001 \nJan / 07 / 2025 \n \nMr. BENJAMIN A. BAUTISTA \nProject Director \nRoads Management Cluster 1 (Bilateral) – UPMO \nDepartment of Public Works and Highways \n2nd Street, Port Area, Manila \n \nThru \n: \nANTONIO ERWIN R. ARANAZ \n \n \nProject Manager \n \nSubject \n: \nAdvanced Mobilization of Experts \n \n \nConsulting Services for the Independent Design Check of the Panay-Guimaras-\nNegros Island Bridges Project [Loan Agreement No.: PHL-23] \n \nDear Mr. Bautista, \n \nWith reference to the above-mentioned consulting services, we respectfully inform the \nadvanced mobilization of Experts. We, SEOYOUNG JV, listed below the mobilized experts in \naccordance with the provisions of the time schedule. \n \nIt will be appreciated if we can receive your response the soonest possible time. Your \nfavorable consideration hereof is highly appreciated. \n \n \nVery Truly Yours, \n \n \nJONG HAK, KIM \nTeam Leader \nIDC Services for PGN Bridges Project, SEOYOUNG JV \n \nEnclosures : \n 1. Mobilization of International Key Experts in Home (Korea) \n 2. Mobilization of International Non-Key Experts in Home (Korea) \n 3. Mobilization of Local Key Experts in Field (Philippines) \n 4. Mobilization of Local Non-Key Experts in Field (Philippines) \n 5. CVs of Experts \n \n \nSEOYOUNG JOINT VENTURE \n \n \n1. Mobilization of International Key Experts \nNo. \nName \nPosition \nActual Date of \nMobilization \n1 \nKIM, JONG HAK \nTeam Leader \nJan 05, 2025 \n2 \nLEE, SANG HEE \nBridge Structural Engineer \nJan 05, 2025 \n3 \nJANG, SEI CHANG \nBridge Analysis Engineer \nJan 05, 2025 \n4 \nLEE, JIN WOO \nBridge Foundation Engineer \nJan 05, 2025 \n5 \nLEE, KEUN HO \nBridge Seismic Engineer \nJan 05, 2025 \n6 \nKIM, YOUNG SOO \nBridge Engineer (Pylon) \nJan 05, 2025 \n7 \nSONG, HYE GUM \nBridge Engineer (Cable) \nJan 05, 2025 \n8 \nLEE, JAE SUNG \nBridge Engineer (Wind) \nJan 05, 2025 \n9 \nSHIN, GYOUNG SEOB \nGeotechnical Engineer \nJan 05, 2025 \n \n2. Mobilization of International Non-Key Experts \nNo. \nName \nPosition \nActual Date of \nMobilization \n1 \nKOH, JONG UP \nHighway Engineer \nJan 05, 2025 \n2 \nPARK, JAE JIN \nTraffic Analysis Specialist \nJan 05, 2025 \n3 \nSONG, YONG CHUL \nOffshore Engineer \nJan 05, 2025 \n4 \nHA, MIN KYU \nDrainage Design Engineer \nJan 05, 2025 \n5 \nJANG, MYUNG HEE \nGeologist \nJan 05, 2025 \n6 \nKIM, IK HWAN \nQuantity Engineer \nJan 05, 2025 \n \n3. Mobilization of Local Key Experts \nNo. \nName \nPosition \nActual Date of \nMobilization \n1 \nMark Anthony V. Apelo \nBridge Engineer (Analysis) \nJan 05, 2025 \n2 \nMelodina F. Tuano \nBridge Engineer (Substructure) \nJan 05, 2025 \n3 \nAurora T. Fabro \nBridge Engineer (Superstructure1) \nJan 05, 2025 \n4 \nRogelio T Sumbe \nBridge Engineer (Superstructure2) \nJan 05, 2025 \n5 \nGuillermo Gregorio A. Mina \nBridge Engineer (Foundation) \nJan 05, 2025 \n \n4. Mobilization of Local Non-Key Experts \nNo. \nName \nPosition \nActual Date of \nMobilization \n1 \nElvira G. Guirindola \nHighway Engineer 1 \nJan 05, 2025 \n2 \nDaniel S. Baptista \nHighway Engineer 2 \nJan 05, 2025 \n3 \nMario M. Quimboy \nQuantity Engineer 1 \nJan 05, 2025 \n4 \nAnaliza C. Bauda \nQuantity Engineer 2 \nJan 05, 2025 \n \n", + "generated": "```json\n{\n \"공문 번호\": \"SYJV-250001\",\n \"공문 일자\": \"Jan / 07 / 2025\",\n \"수신처\": \"Department of Public Works and Highways\",\n \"수신자\": \"Project Director\",\n \"수신자 약자\": \"PD\",\n \"발신처\": \"SEOYOUNG JOINT VENTURE\",\n \"발신자\": \"Team Leader\",\n \"발신자 약자\": \"TL\",\n \"공문 제목\": \"Advanced Mobilization of Experts\",\n \"공문 제목 요약\": \"전문가 사전 동원 보고\",\n \"공문 내용 요약\": \"프로젝트에 필요한 전문가들이 사전 동원되었음을 알림\",\n \"공문간 연계\": \"없음\",\n \"공문 종류\": \"기술/성과물\",\n \"공문 유형\": \"보고\",\n \"첨부문서 제목\": [\n \"Mobilization of International Key Experts in Home (Korea)\",\n \"Mobilization of International Non-Key Experts in Home (Korea)\",\n \"Mobilization of Local Key Experts in Field (Philippines)\",\n \"Mobilization of Local Non-Key Experts in Field (Philippines)\",\n \"CVs of Experts\"\n ],\n \"첨부문서 수\": 5\n}\n```", + "processed": { + "공문번호": "SYJV-250001", + "공문일자": "Jan / 07 / 2025", + "수신처": "Department of Public Works and Highways", + "수신자": "Project Director", + "수신자약자": "PD", + "발신처": "SEOYOUNG JOINT VENTURE", + "발신자": "Team Leader", + "발신자약자": "TL", + "공문제목": "Advanced Mobilization of Experts", + "공문제목요약": "전문가 사전 동원 보고", + "공문내용요약": "프로젝트에 필요한 전문가들이 사전 동원되었음을 알림", + "공문간연계": "없음", + "공문종류": "기술/성과물", + "공문유형": "보고", + "첨부문서제목": [ + "Mobilization of International Key Experts in Home (Korea)", + "Mobilization of International Non-Key Experts in Home (Korea)", + "Mobilization of Local Key Experts in Field (Philippines)", + "Mobilization of Local Non-Key Experts in Field (Philippines)", + "CVs of Experts" + ], + "첨부문서수": 5 + } + } +] \ No newline at end of file diff --git a/workspace/static/html/extract_guide.html b/workspace/static/html/extract_guide.html new file mode 100644 index 0000000..8eb7ea6 --- /dev/null +++ b/workspace/static/html/extract_guide.html @@ -0,0 +1,83 @@ + + +
+ ++🔹 아래는 /extract 계열 API에 프롬프트를 작성하고 사용하는 방법에 대한 안내입니다. +
+ +
+ 🔹 /extract/inner: 내부 모델을 사용
+ 🔹 /extract/outer: 외부 모델을 사용
+ 🔹 /extract/all: 내부 + 외부 모델을 동시에 사용
+ 🔹 /extract/structured: 고정된 JSON 필드로 정형 응답
+
+ 🔹 문서 추출 항목을 다양하게 변경하며 시도할 경우에 사용합니다.
+ 🔹 해당 API의 업로드 파일은 2가지로 구성됩니다:
+
+Tip. 프롬프트 업로드⭕ 경우, "JSON으로 작성해주세요" 문구는 자동으로 삽입되므로 직접 작성할 필요가 없습니다.
+
→ 따라서, 프롬프트 작성은 아래처럼 항목 설명만 작성하면 됩니다:
+ + 1. 공문번호: 문서 번호를 기입하세요.
+ 2. 공문일자: 공문 발행일을 작성하세요.
+ 3. 수신처: 수신 기관이나 부서명을 작성하세요.
+ 4. 수신자: 수신자의 이름 또는 직책을 기입하세요.
+ ...
+
+
+ 🔹 문서 추출 항목을 고정하여 정해진 필드 형식으로 응답 받기 위해 사용합니다.
+ 🔹 해당 API의 업로드 파일은 3가지로 구성됩니다:
+
+※ schemna json 작성은 "Guide Book" 첫 번째인 "schema_file_guide"를 참고해주세요.
+ + + diff --git a/workspace/static/html/extraction_structured_guide.html b/workspace/static/html/extraction_structured_guide.html new file mode 100644 index 0000000..b767f94 --- /dev/null +++ b/workspace/static/html/extraction_structured_guide.html @@ -0,0 +1,32 @@ + + + + +아래는 /extract 계열 API에 프롬프트를 작성하고 사용하는 방법에 대한 안내입니다.
+ +🔹 항목은 16개로 고정되어 있으며 추가/삭제/변경 불가합니다.
+🔹 각 항목에 대한 '지시문' 설명만 작성할 수 있습니다.
+ +
+1. 공문번호: 공문서 상단에 표기된 문서 번호를 추출합니다.
+2. 공문일자: 공문이 발행된 날짜를 추출합니다.
+3. 수신처: 문서를 수신하는 기관 또는 부서를 식별합니다.
+...
+16. 첨부문서수: 찾은 첨부문서 개수를 알려주세요.
+
+
+
diff --git a/workspace/static/html/general_guide.html b/workspace/static/html/general_guide.html
new file mode 100644
index 0000000..ca04bd2
--- /dev/null
+++ b/workspace/static/html/general_guide.html
@@ -0,0 +1,176 @@
+
+
+
+
+
+🔹 /general 계열 API를 활용하여 문서 기반 질문-응답 요약을 수행하는 방법을 안내합니다.
+🔹 공문 외에 다양한 도메인에 적용 가능하며, 사용자는 URL(Markdwon) 또는 JSON 구조로 답변을 받습니다.
+
+🔹 /general/inner: 내부 모델을 사용하여 일반 요약 수행
+🔹 /general/outer: 외부 모델(GPT, Claude, Gemini 등)을 사용하여 요약 수행
+
+🔹 프롬프트 파일은 반드시 업로드해야 합니다.
+🔹 [예시] 질문은 다음과 같이 구성할 수 있습니다:
+
문서 분석
+
+[Q1] 이 문서의 주요 내용을 요약해주세요.
+
+[Q2] 발신자와 수신자 정보를 정리해주세요.
+
+[Q3] 문서에서 요청하는 주요 조치를 요약해주세요.
+
+[Q4] 날짜, 장소, 인명 등 주요 엔티티를 추출해주세요.
+
+[Q5] 이 문서의 목적이나 배경을 기술해주세요.
+
+
+
+🔹 schema_file은 선택사항이며, JSON 형식으로 답변 받기 위해선 작성이 필요합니다.
+🔹 추출이 필요한 항목과 항목의 답변을 정의할 때 사용합니다.
+🔹 특수 항목은 enum 또는 type 값을 값정할 수 있습니다.
+
+{
+ "title": "DocumentSummary",
+ "type": "object",
+ "properties": {
+ "공문번호": { "type": "string" },
+ "공문일자": { "type": "string" },
+ "수신체": { "type": "string" },
+ "수신자": { "type": "string" },
+ "수신자_약자": { "type": "string" },
+ "발신체": { "type": "string" },
+ "발신자": { "type": "string" },
+ "발신자_약자": { "type": "string" },
+ "공문제목": { "type": "string" },
+ "공문제목요약": { "type": "string" },
+ "공문내용요약": { "type": "string" },
+ "공문간연계": { "type": "string" },
+ "공문종류": {
+ "type": "string",
+ "enum": ["행정/일반", "기술/성과물", "회의/기타"]
+ },
+ "공문유형": {
+ "type": "string",
+ "enum": ["보고", "요청", "지시", "회신", "계약"]
+ },
+ "첨부문서제목": { "type": "string" },
+ "첨부문서수": { "type": "integer" }
+ },
+ "required": [
+ "공문번호", "공문일자", "수신체", "수신자", "수신자_약자",
+ "발신체", "발신자", "발신자_약자", "공문제목", "공문제목요약",
+ "공문내용요약", "공문종류", "공문유형", "첨부문서제목", "첨부문서수"
+ ]
+}
+🔹 위 JSON 예시는 Schema 구조를 정의하는 방식으로 작성되어 있으며, 각 키의 의미는 다음과 같습니다:
+Tip. schemna json을 사용하는 경우, 프롬프트의 각 항목에 대한 지시문(description)을 각분으로 설정해주면 더 좋습니다.
+ 1. 공문번호: 문서 번호를 기입하세요. (예시: Ref. No. SYJV-250031)
+ 2. 공문일자: 공문 발행일을 작성하세요. (예시: Mar / 28 / 2025)
+ 3. 수신처: 수신 기관이나 부서명을 작성하세요. (예시: Department of Public Works and Highways)
+ ...
+ 16. 첨부문서수: 첨부문서제목을 바탕으로 문서의 개수를 작성하세요.
+
+
+
+
++🔹 해당 API에 업로드 가능한 파일은 3가지로 구성됩니다: +
+
++🔹 모델은 질문에 대해 줄글 형식의 응답을 생성하며, 응답 JSON에는 다음 필드가 포함됩니다: +
+
+
+
++🔹 /general API에 schema_file을 함께 업로드한 경우, 모델은 지정된 JSON Schema에 따라 항목별 응답을 생성합니다. +
+
+※ Claude 모델은 영문 필드명만 허용합니다.
+ +