First commit
7
.env.example
Normal file
@@ -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
|
||||
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
result/
|
||||
post/ocr_results/
|
||||
post/tmp/
|
||||
*.zip
|
||||
api_keys.json
|
||||
.env
|
||||
21
Dockerfile
Normal file
@@ -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}"]
|
||||
122
README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# LLM Gateway 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` 파일을 참고하세요.
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
7
api_keys.json.example
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"your_api_key": {
|
||||
"client_name": "example_client",
|
||||
"created_at": "timestamp",
|
||||
"is_active": "true"
|
||||
}
|
||||
}
|
||||
30
delete_error_and_empty_files.py
Normal file
@@ -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)}")
|
||||
45
docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
llm_gateway_macro:
|
||||
build:
|
||||
context: .
|
||||
image: llm_gateway_macro
|
||||
container_name: llm_gateway_macro_8889
|
||||
volumes:
|
||||
- ./:/workspace
|
||||
ports:
|
||||
- "8889:8889"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
stdin_open: true
|
||||
restart: always
|
||||
tty: true
|
||||
networks:
|
||||
- llm_gateway_macro_net
|
||||
|
||||
llm_gateway_macro_redis:
|
||||
image: redis:7-alpine
|
||||
container_name: llm_gateway_macro_redis
|
||||
command:
|
||||
[
|
||||
"redis-server",
|
||||
"--maxmemory",
|
||||
"256mb",
|
||||
"--maxmemory-policy",
|
||||
"allkeys-lru",
|
||||
]
|
||||
ports:
|
||||
- "6666:6379"
|
||||
restart: always
|
||||
networks:
|
||||
- llm_gateway_macro_net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
llm_gateway_macro_net:
|
||||
driver: bridge
|
||||
35
post/filter.py
Normal file
@@ -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()
|
||||
132
post/main.py
Normal file
@@ -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)
|
||||
148
post/main_sync.py
Normal file
@@ -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)
|
||||
2
post/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests
|
||||
tqdm
|
||||
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
uvicorn[standard]
|
||||
fastapi
|
||||
python-multipart
|
||||
python-dotenv
|
||||
google-generativeai
|
||||
redis
|
||||
minio
|
||||
prometheus-fastapi-instrumentator
|
||||
snowflake-id
|
||||
httpx
|
||||
BIN
workspace/__pycache__/api.cpython-310.pyc
Normal file
BIN
workspace/__pycache__/tasks.cpython-310.pyc
Normal file
65
workspace/api.py
Normal file
@@ -0,0 +1,65 @@
|
||||
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.include_router(costs_router, dependencies=[api_key_dependency]) # ✅ 비용 계산 API
|
||||
|
||||
|
||||
@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")
|
||||
0
workspace/config/__init__.py
Normal file
BIN
workspace/config/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
workspace/config/__pycache__/setting.cpython-310.pyc
Normal file
16
workspace/config/setting.py
Normal file
@@ -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_gateway_macro_redis"
|
||||
PGN_REDIS_PORT = 6379
|
||||
PGN_REDIS_DB = 2
|
||||
BIN
workspace/routers/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/api_key_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/costs_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/download_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/dummy_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/extract_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/general_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/guide_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/llm_summation.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/model_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/ocr_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/stt_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/system_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/task_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/worker_router.cpython-310.pyc
Normal file
BIN
workspace/routers/__pycache__/yolo_router.cpython-310.pyc
Normal file
295
workspace/routers/costs_router.py
Normal file
@@ -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="/costs", tags=["Billing"])
|
||||
|
||||
|
||||
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)
|
||||
BIN
workspace/services/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/api_key_service.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/download_service.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/dummy_service.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/gemini_service.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/inference_service.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/model_service.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/ollama_service.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/pipeline_runner.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/prompt.cpython-310.pyc
Normal file
BIN
workspace/services/__pycache__/report.cpython-310.pyc
Normal file
167
workspace/services/api_key_service.py
Normal file
@@ -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
|
||||
22
workspace/services/gemini_service.py
Normal file
@@ -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
|
||||
|
||||
27
workspace/services/ollama_service.py
Normal file
@@ -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}"
|
||||
42
workspace/static/dummy_response.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
83
workspace/static/html/extract_guide.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>📄 공문 추출·번역 API 가이드</title>
|
||||
<style>
|
||||
body { font-family: 'Arial', sans-serif; margin: 40px; line-height: 1.6; }
|
||||
h1, h2 { color: #2c3e50; }
|
||||
code, pre { background: #f4f4f4; padding: 10px; display: block; white-space: pre-wrap; border-left: 4px solid #3498db; }
|
||||
.warn { color: #c0392b; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📄 문서 추출·번역 API 가이드</h1>
|
||||
<p>
|
||||
🔹 아래는 <strong>/extract</strong> 계열 API에 프롬프트를 작성하고 사용하는 방법에 대한 안내입니다.
|
||||
</p>
|
||||
|
||||
<h3>📌 사용 가능한 API 종류</h3>
|
||||
<P>
|
||||
🔹 <strong>/extract/inner</strong>: 내부 모델을 사용<br>
|
||||
🔹 <strong>/extract/outer</strong>: 외부 모델을 사용<br>
|
||||
🔹 <strong>/extract/all</strong>: 내부 + 외부 모델을 동시에 사용<br>
|
||||
🔹 <strong>/extract/structured</strong>: 고정된 JSON 필드로 정형 응답
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>✅ "/extract/inner", "/extract/outer", "/extract/all"</h2>
|
||||
<p>
|
||||
🔹 문서 추출 항목을 다양하게 변경하며 시도할 경우에 사용합니다.<br>
|
||||
🔹 해당 API의 업로드 파일은 2가지로 구성됩니다:
|
||||
</p>
|
||||
<img src="static/image/FastAPI_extract_swagger.png" width="600" style="border: 2px solid #ccc; border-radius: 4px;"/>
|
||||
<h3>📌 API 첨부 파일 설명</h3>
|
||||
<ul>
|
||||
<li><strong>files</strong>: <span class="warn">(필수)</span> PDF, 이미지 등 추론 대상 파일을 업로드합니다.</li>
|
||||
<li><strong>prompt_file</strong>: <span class="warn">(선택)</span> 질문이 포함된 질문이 포함된 프롬프트 텍스트(.txt)를 업로드합니다.
|
||||
<ul>
|
||||
<li><strong>업로드⭕</strong>: 사용자 정의 프롬프트 사용</li>
|
||||
<li><strong>업로드❌</strong>: 내부에 정의된 기본 프롬프트를 사용</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="warn">Tip. 프롬프트 업로드⭕ 경우, <strong>"JSON으로 작성해주세요"</strong> 문구는 자동으로 삽입되므로 직접 <strong>작성할 필요가 없습니다.</strong><p>
|
||||
<p>→ 따라서, <strong>프롬프트 작성은 아래처럼 항목 설명만 작성</strong>하면 됩니다:</p>
|
||||
|
||||
<code> 1. 공문번호: 문서 번호를 기입하세요.
|
||||
2. 공문일자: 공문 발행일을 작성하세요.
|
||||
3. 수신처: 수신 기관이나 부서명을 작성하세요.
|
||||
4. 수신자: 수신자의 이름 또는 직책을 기입하세요.
|
||||
...</code>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>✅ "extract/structured"</h2>
|
||||
<p>
|
||||
🔹 문서 추출 항목을 고정하여 정해진 필드 형식으로 응답 받기 위해 사용합니다.<br>
|
||||
🔹 해당 API의 업로드 파일은 3가지로 구성됩니다:
|
||||
</p>
|
||||
<img src="static/image/FastAPI_extract_structured_swagger.png" width="600" style="border: 2px solid #ccc; border-radius: 4px;"/>
|
||||
<h3>📌 API 첨부 파일 설명</h3>
|
||||
<ul>
|
||||
<li><strong>files</strong>: <span class="warn">(필수)</span> PDF, 이미지 등 추론 대상 파일을 업로드합니다.</li>
|
||||
<li><strong>schema_file</strong>: <span class="warn">(선택)</span> 응답 구조를 정의한 스키마 파일(.json)을 업로드합니다
|
||||
<ul>
|
||||
<li><strong>업로드⭕</strong>: 사용자 정의 필드 사용</li>
|
||||
<li><strong>업로드❌</strong>: 내부에 정의된 기본 필드를 사용</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>prompt_file</strong>: <span class="warn">(선택)</span> 질문이 포함된 질문이 포함된 프롬프트 텍스트(.txt)를 업로드합니다.
|
||||
<ul>
|
||||
<li><strong>업로드⭕</strong>: 사용자 정의 프롬프트 사용</li>
|
||||
<li><strong>업로드❌</strong>: 내부에 정의된 기본 프롬프트를 사용</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="warn">※ schemna json 작성은 "Guide Book" 첫 번째인 "schema_file_guide"를 참고해주세요.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
32
workspace/static/html/extraction_structured_guide.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>📄 /extract/structured 프롬프트 가이드</title>
|
||||
<style>
|
||||
body { font-family: 'Arial', sans-serif; margin: 40px; line-height: 1.6; }
|
||||
h1, h2 { color: #2c3e50; }
|
||||
code, pre { background: #f4f4f4; padding: 10px; display: block; white-space: pre-wrap; border-left: 4px solid #3498db; }
|
||||
.warn { color: #c0392b; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📄 /extract/structured 프롬프트 가이드</h1>
|
||||
<p>아래는 <strong>/extract</strong> 계열 API에 프롬프트를 작성하고 사용하는 방법에 대한 안내입니다.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>✅ 항목은 고정하되, 항목별 '지시문' 을 수정하고 싶은 경우</h2>
|
||||
<h3>🖥️ 사용 API: <strong>/extract/structured</strong></h3>
|
||||
<p>🔹 항목은 16개로 <strong>고정</strong>되어 있으며 <strong>추가/삭제/변경 불가</strong>합니다.</p>
|
||||
<p>🔹 <strong>각 항목에 대한 '지시문' 설명만 작성</strong>할 수 있습니다.</p>
|
||||
|
||||
<code>
|
||||
1. 공문번호: 공문서 상단에 표기된 문서 번호를 추출합니다.
|
||||
2. 공문일자: 공문이 발행된 날짜를 추출합니다.
|
||||
3. 수신처: 문서를 수신하는 기관 또는 부서를 식별합니다.
|
||||
...
|
||||
16. 첨부문서수: 찾은 첨부문서 개수를 알려주세요.
|
||||
</code>
|
||||
</body>
|
||||
</html>
|
||||
176
workspace/static/html/general_guide.html
Normal file
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>🧾 일반 추론 API 가이드</title>
|
||||
<style>
|
||||
body { font-family: 'Arial', sans-serif; margin: 40px; line-height: 1.6; }
|
||||
h1, h2 { color: #2c3e50; }
|
||||
code, pre { background: #f4f4f4; padding: 10px; display: block; white-space: pre-wrap; border-left: 4px solid #3498db; }
|
||||
.warn { color: #c0392b; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧾 일반 추론 API 가이드</h1>
|
||||
<p>
|
||||
🔹 <strong>/general</strong> 계열 API를 활용하여 문서 기반 질문-응답 요약을 수행하는 방법을 안내합니다.<br>
|
||||
🔹 공문 외에 다양한 도메인에 적용 가능하며, 사용자는 <strong>URL(Markdwon)</strong> 또는 <strong>JSON</strong> 구조로 답변을 받습니다.
|
||||
</p>
|
||||
|
||||
<h3>📌 사용 가능한 API 종류</h3>
|
||||
<p>
|
||||
🔹 <strong>/general/inner</strong>: 내부 모델을 사용하여 일반 요약 수행<br>
|
||||
🔹 <strong>/general/outer</strong>: 외부 모델(GPT, Claude, Gemini 등)을 사용하여 요약 수행
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>✅ 프롬프트 작성 예시</h2>
|
||||
<p>
|
||||
🔹 <strong>프롬프트 파일은 반드시 업로드</strong>해야 합니다.<br>
|
||||
🔹 [예시] 질문은 다음과 같이 구성할 수 있습니다:
|
||||
</p>
|
||||
|
||||
<code>문서 분석
|
||||
|
||||
[Q1] 이 문서의 주요 내용을 요약해주세요.
|
||||
|
||||
[Q2] 발신자와 수신자 정보를 정리해주세요.
|
||||
|
||||
[Q3] 문서에서 요청하는 주요 조치를 요약해주세요.
|
||||
|
||||
[Q4] 날짜, 장소, 인명 등 주요 엔티티를 추출해주세요.
|
||||
|
||||
[Q5] 이 문서의 목적이나 배경을 기술해주세요.
|
||||
</code>
|
||||
|
||||
<hr>
|
||||
<h2>✅ Schema JSON 작성 예시</h2>
|
||||
<p>
|
||||
🔹 <strong>schema_file은 선택사항</strong>이며, JSON 형식으로 답변 받기 위해선 작성이 필요합니다.<br>
|
||||
🔹 추출이 필요한 항목과 항목의 답변을 정의할 때 사용합니다.<br>
|
||||
🔹 특수 항목은 <strong>enum</strong> 또는 <strong>type</strong> 값을 값정할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<pre>
|
||||
{
|
||||
"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": [
|
||||
"공문번호", "공문일자", "수신체", "수신자", "수신자_약자",
|
||||
"발신체", "발신자", "발신자_약자", "공문제목", "공문제목요약",
|
||||
"공문내용요약", "공문종류", "공문유형", "첨부문서제목", "첨부문서수"
|
||||
]
|
||||
}</pre>
|
||||
<h3>📌 주요 키·속성 설명</h3>
|
||||
<p>🔹 위 JSON 예시는 <strong>Schema 구조</strong>를 정의하는 방식으로 작성되어 있으며, 각 키의 의미는 다음과 같습니다:</p>
|
||||
<ul>
|
||||
<li><strong>title</strong>: 스키마의 이름 또는 제목을 정의합니다. 주로 문서나 데이터 객체의 이름을 지정하는 데 사용됩니다.<br>
|
||||
[예시]: <strong>"title": "DocumentSummary"</strong> → 이 JSON은 DocumentSummary라는 이름의 구조입니다.</li>
|
||||
<br>
|
||||
<li><strong>type</strong>: 이 JSON 구조 자체가 어떤 형태의 데이터인지 정의합니다.<br>
|
||||
[예시]: <strong>"type": "object"</strong> → 이 스키마는 key-value 쌍으로 이루어진 객체(object)입니다.</li>
|
||||
<br>
|
||||
<li><strong>properties</strong>: 객체 안에 포함된 각 필드(속성)를 정의하는 부분입니다.<br>
|
||||
이 안에는 각각의 필드 이름(key)과 해당 값의 <strong>type</strong> 및 <strong>enum</strong> 등 상세 정보가 포함됩니다.<br>
|
||||
[예시]: <strong>"공문번호": { "type": "string" }</strong> → 공문번호는 문자열 타입이어야 함을 의미합니다.</li>
|
||||
<br>
|
||||
<ul>
|
||||
<li><strong>type</strong>: 해당 값의 데이터 유형을 지정합니다. 주요 유형은 다음과 같습니다:
|
||||
<ul>
|
||||
<li><strong>string</strong>: 문자열 (예: "서울특별시")</li>
|
||||
<li><strong>integer</strong>: 정수 (예: 3, 25)</li>
|
||||
<li><strong>boolean</strong>: 참/거짓 값 (예: true, false)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>enum</strong>: 해당 필드가 가질 수 있는 값을 목록으로 제한합니다. 지정된 값 외에는 허용되지 않습니다.
|
||||
<br>[예시]: <strong>"공문종류": { "type": "string", "enum": ["행정/일반", "기술/성과물", "회의/기타"] }</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<br>
|
||||
|
||||
<li><strong>required</strong>: 필수로 입력되어야 하는 항목들의 리스트입니다.<br>
|
||||
이 배열에 나열된 필드가 누락될 경우, JSON이 유효하지 않은 것으로 간주됩니다.<br>
|
||||
[예시]: <strong>"required": ["공문번호", "공문일자", ...]</strong> → 이 필드들은 반드시 포함되어야 합니다.</li>
|
||||
</ul>
|
||||
<p class="warn">Tip. schemna json을 사용하는 경우, <strong>프롬프트의 각 항목에 대한 지시문(description)을 각분으로 설정</strong>해주면 더 좋습니다.</p>
|
||||
<code> 1. 공문번호: 문서 번호를 기입하세요. (예시: Ref. No. SYJV-250031)
|
||||
2. 공문일자: 공문 발행일을 작성하세요. (예시: Mar / 28 / 2025)
|
||||
3. 수신처: 수신 기관이나 부서명을 작성하세요. (예시: Department of Public Works and Highways)
|
||||
...
|
||||
16. 첨부문서수: 첨부문서제목을 바탕으로 문서의 개수를 작성하세요.
|
||||
</code>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>✅ 사용 절차 안내</h2>
|
||||
<p>
|
||||
🔹 해당 API에 업로드 가능한 파일은 3가지로 구성됩니다:
|
||||
</p>
|
||||
<img src="static/image/FastAPI_general.png" alt="FastAPI general 입력 화면 예시" width="600" style="border: 2px solid #ccc; border-radius: 4px;"/>
|
||||
<h3>📌 API 첨부 파일 설명</h3>
|
||||
<ul>
|
||||
<li><strong>input_file</strong>: <span class="warn">(필수)</span> PDF, 이미지 등 추론 대상 파일을 업로드합니다.</li>
|
||||
<li><strong>prompt_file</strong>: <span class="warn">(필수)</span> 질문이 포함된 질문이 포함된 프롬프트 텍스트(.txt)를 업로드합니다.</li>
|
||||
<li><strong>schema_file</strong>: <span class="warn">(선택)</span> 응답 구조를 정의한 스키마 파일(.json)을 업로드합니다.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>1️⃣ Markdown 형식 응답 예시(schema file 미업로드)</h2>
|
||||
<p>
|
||||
🔹 모델은 질문에 대해 <strong>줄글 형식의 응답을 생성</strong>하며, 응답 JSON에는 다음 필드가 포함됩니다:
|
||||
</p>
|
||||
<img src="static/image/FastAPI_general_response.png" alt="FastAPI general 결과 화면 예시" width="600" style="border: 2px solid #ccc; border-radius: 4px;"/>
|
||||
<h3>📌 주요 답변 키 설명</h3>
|
||||
<ul>
|
||||
<li><strong>generated</strong>: 마크다운 형식의 응답 텍스트</li>
|
||||
<li><strong>summary_html</strong>: 마크다운을 HTML로 변환하여 저장한 URL</li>
|
||||
🔗<a href="http://172.16.10.176:8888/view/generated_html/Contract_for_Main_Office.html" target="_blank">
|
||||
http://172.16.10.176:8888/view/generated_html/Contract_for_Main_Office.html
|
||||
</a>
|
||||
</ul>
|
||||
<img src="static/image/FastAPI_general_result.png" alt="FastAPI general 결과 화면 예시" width="600" style="border: 2px solid #ccc; border-radius: 4px;"/>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>2️⃣ 구조화 JSON 형식 응답 예시(schema file 업로드)</h2>
|
||||
<p>
|
||||
🔹 /general API에 <strong>schema_file</strong>을 함께 업로드한 경우, 모델은 지정된 JSON Schema에 따라 항목별 응답을 생성합니다.
|
||||
</p>
|
||||
<img src="static/image/FastAPI_general_JSONresult.png" alt="FastAPI structured 응답 예시" width="600" style="border: 2px solid #ccc; border-radius: 4px;"/>
|
||||
<h3>📌 주요 답변 키 설명</h3>
|
||||
<ul>
|
||||
<li><strong>generated</strong>: JSON 구조의 응답 데이터</li>
|
||||
<li><strong>processed</strong>: 구조화된 응답이므로 별도의 후처리는 생략되며, 안내 메시지만 포함됩니다.</li>
|
||||
</ul>
|
||||
<p class="warn">※ Claude 모델은 <strong>영문 필드명만 허용</strong>합니다.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
<p>다음 문서에 대한 분석 결과는 아래와 같습니다.</p>
|
||||
|
||||
<p>[Q1] 이 공문의 주요 내용을 한 문단으로 요약해주세요.
|
||||
한국종합기술이 (주)삼안에 발송한 이 공문은, 양사가 2019년 2월 15일 공동으로 계약하여 진행 중인 'CONSULTING SERVICES FOR THE DETAILED FEASIBILITY STUDIES AND DESIGNS OF THE WATER SUPPLY AND SANITATION IMPROVEMENT PROJECT IN THE PRIORITISED URBAN CENTRES OF NORTH WESTERN, SOUTHERN AND LU KANGA WATER AND SEWERAGE COMPANIES' 프로젝트에 대한 2-1차 하도급 기성금의 지급을 요청하는 내용을 담고 있습니다. 공문은 프로젝트의 원활한 수행을 위해 총 USD 74,188.6에 해당하는 기성금을 하도급 업체인 Andosa에게 지급해 줄 것을 요구하며, 이 중 한국종합기술 부담분 USD 48,475.7과 (주)삼안 부담분 USD 25,712.9을 명시하고 있습니다.</p>
|
||||
|
||||
<p>[Q2] 이 공문은 누구에게 보내졌고, 핵심 전달 사항은 무엇인가요?
|
||||
이 공문은 (주)삼안, 구체적으로는 상하수도부 부서장에게 발송되었습니다. 이 공문의 핵심 전달 사항은 한국종합기술과 (주)삼안이 공동으로 수행 중인 '물 공급 및 위생 개선 프로젝트'와 관련하여, 원활한 사업 수행을 위해 2-1차 하도급 기성금 총 USD 74,188.6을 하도급 업체인 Andosa에게 조속히 지급해달라는 요청입니다.</p>
|
||||
|
||||
<p>[Q3] 임대료와 관련된 내용만 추려서 요약해주세요.
|
||||
이 공문에는 임대료와 관련된 내용은 전혀 언급되어 있지 않습니다. 문서의 주요 내용은 양사가 공동으로 진행하는 프로젝트에 대한 '2-1차 하도급 기성금' 지급 요청으로, 임대료와는 무관한 비용입니다.</p>
|
||||
|
||||
<p>[Q4] 이 공문에서 요청한 액션 항목을 정리해주세요.
|
||||
이 공문에서 (주)삼안에 요청하는 핵심 액션 항목은, 현재 공동으로 수행 중인 프로젝트의 원활한 진행을 위해 2-1차 하도급 기성금 총 USD 74,188.6을 하도급 업체인 Andosa에게 지급하라는 것입니다. 특히 (주)삼안은 자신의 부담분인 USD 25,712.9을 포함하여 해당 기성금이 Andosa에게 제대로 지급되도록 필요한 조치를 취해야 합니다.</p>
|
||||
|
||||
<p>[Q5] 공문에 나오는 날짜, 담당자 이름, 조직명을 정리해주세요.
|
||||
이 공문이 발송된 날짜는 2019년 12월 17일입니다. 공문에 명시된 주요 조직명으로는 발신자인 '한국종합기술', 수신자인 '(주)삼안', 그리고 기성금을 지급받을 '하도급 업체 Andosa'가 있습니다. 공문에 기재된 담당자 이름은 대표이사 이상민, 그리고 협조자로 참여한 이상훈(차장), 이창호(팀장/부사장), 김정현(부서장/전무), 조관희(본부장/부사장)입니다.</p>
|
||||
@@ -0,0 +1,38 @@
|
||||
<h2>분석 결과</h2>
|
||||
|
||||
<p><strong>[Q1] 이 공문의 주요 내용을 한 문단으로 요약해주세요.</strong></p>
|
||||
|
||||
<p>본 공문은 서영엔지니어링 컨소시엄에 IDC (독립 디자인 검토) 프로젝트의 사무실 임대료 관련 재검토를 요청하는 내용입니다. 서영엔지니어링 컨소시엄이 제출한 제안서상의 사무실 임대료와 실제 계약 조건상의 임대료가 상이하여, 제안서상의 임대료를 기준으로 사무실 공간 견적 및 비용 비교 자료를 제출하여 확인을 요청하고 있습니다.</p>
|
||||
|
||||
<p><strong>[Q2] 이 공문은 누구에게 보내졌고, 핵심 전달 사항은 무엇인가요?</strong></p>
|
||||
|
||||
<p>본 공문은 서영엔지니어링 컨소시엄의 팀 리더 김종학 님에게 발송되었습니다. 핵심 전달 사항은, IDC 프로젝트의 사무실 임대료가 제안서와 계약 조건상 상이하므로, 제안서상의 임대료를 기준으로 사무실 공간 견적 및 비용 비교 자료를 제출하여 임대료를 확인해 달라는 것입니다.</p>
|
||||
|
||||
<p><strong>[Q3] 임대료와 관련된 내용만 추려서 요약해주세요.</strong></p>
|
||||
|
||||
<p>본 공문에서는 서영엔지니어링 컨소시엄이 제출한 제안서에 기재된 사무실 임대료가 월 PHP 87,000 (200 평방미터 기준)임을 언급하고 있습니다. 하지만 실제 계약 조건상의 월 임대료가 제안서상의 금액보다 높다는 점을 지적하며, 제안서상의 금액을 기준으로 사무실 공간 견적 및 비용 비교 자료 제출을 요청하고 있습니다.</p>
|
||||
|
||||
<p><strong>[Q4] 이 공문에서 요청한 액션 항목을 정리해주세요.</strong></p>
|
||||
|
||||
<ul>
|
||||
<li>서영엔지니어링 컨소시엄은 제안서상의 임대료 (PHP 87,000/월, 200 평방미터 기준)를 기준으로 사무실 공간 견적을 작성해야 합니다.</li>
|
||||
<li>제출된 견적에는 각 사무실 공간의 비용 비교 자료가 포함되어야 합니다.</li>
|
||||
<li>작성된 견적 및 비용 비교 자료를 발신인에게 제출해야 합니다.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>[Q5] 공문에 나오는 날짜, 담당자 이름, 조직명을 정리해주세요.</strong></p>
|
||||
|
||||
<ul>
|
||||
<li><strong>날짜:</strong> 2025년 3월 6일 (March 6, 2025)</li>
|
||||
<li><strong>담당자 이름:</strong> 김종학 (KIM, JONG HAK)</li>
|
||||
<li><strong>조직명:</strong>
|
||||
<ul>
|
||||
<li>Republic of the Philippines, DEPARTMENT OF PUBLIC WORKS AND HIGHWAYS (발신 조직)</li>
|
||||
<li>Seoyoung Engineering Co., Ltd. (서영엔지니어링)</li>
|
||||
<li>SAMAN Corporation</li>
|
||||
<li>JINWOO Engineering Korea Co., Ltd.</li>
|
||||
<li>Kyong Dong Engineering Co., Ltd.</li>
|
||||
<li>Angel Lazaro & Associates International (수신 조직 - 서영엔지니어링 컨소시엄)</li>
|
||||
<li>Unified Project Management Office (공문 발신 부서)</li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
@@ -0,0 +1,16 @@
|
||||
<p>각 항목에 대해 아래와 같이 답변합니다.</p>
|
||||
|
||||
<p>[Q1] 이 공문의 주요 내용을 한 문단으로 요약해주세요.
|
||||
이 공문은 필리핀 공공사업도로부(DPWH)에서 파나이-기마라스-네그로스 아일랜드 교량 프로젝트의 독립 설계 검토(IDC)를 위한 주 사무소 계약과 관련하여 김종학 팀장에게 보내는 것입니다. 주요 내용은 김 팀장 측이 제출한 재정 제안서 상의 사무실 임대료(월 PHP 87,000, 200m² 기준)가 계약 조건에 명시된 원래 입찰가보다 높다는 점을 상기시키고 있습니다. 이에, 김 팀장 측은 조사한 사무실 공간에 대한 비용 비교 자료를 제출하여 검토 및 참고할 수 있도록 요청받고 있습니다.</p>
|
||||
|
||||
<p>[Q2] 이 공문은 누구에게 보내졌고, 핵심 전달 사항은 무엇인가요?
|
||||
이 공문은 필리핀 공공사업도로부(DPWH) 중앙 사무소의 도로 관리 클러스터 1(양자) / 통합 프로젝트 관리 사무소의 프로젝트 디렉터인 벤자민 A. 바우티스타(Benjamin A. Bautista)가 발송했습니다. 수신자는 서영 엔지니어링 주식회사, 사만(SAMAN) 법인, 진우 엔지니어링 코리아 주식회사, 경동 엔지니어링 주식회사 연합 법인의 팀장이자 엔젤 라자로 & 어소시에이츠 인터내셔널과 협력하는 김종학 씨입니다. 핵심 전달 사항은 김 팀장 측이 제출한 주 사무소 임대료 제안이 계약 조건에 명시된 원래 입찰 가격보다 높으므로, 관련 사무실 공간에 대한 비용 비교 자료를 제출해 달라는 요청입니다.</p>
|
||||
|
||||
<p>[Q3] 임대료와 관련된 내용만 추려서 요약해주세요.
|
||||
공문에서 임대료와 관련된 내용은 김종학 팀장 측의 재정 제안서에 명시된 사무실 임대 비용이 200제곱미터 면적당 월 PHP 87,000 수준임을 상기시키고 있습니다. 그러나 이 금액이 계약 조건에 따라 원래 입찰한 월별 요금보다 더 높다는 점을 지적하고 있습니다. 이에 따라, 김 팀장 측은 조사한 사무실 공간에 대한 비용 비교 자료를 공공사업도로부에 제출하여 검토하고 참고할 수 있도록 요청받고 있습니다.</p>
|
||||
|
||||
<p>[Q4] 이 공문에서 요청한 액션 항목을 정리해주세요.
|
||||
이 공문에서 김종학 팀장에게 요청한 핵심 액션 항목은, 현재 제안된 사무실 임대료가 계약 조건상의 원래 입찰가보다 높다는 점을 감안하여, 김 팀장 측이 조사한 사무실 공간들에 대한 비용 비교 자료를 필리핀 공공사업도로부(DPWH)에 제출하는 것입니다. 이 자료는 DPWH의 검토 및 참고를 위한 것이며, 적절한 후속 조치를 취할 것을 기대하고 있습니다.</p>
|
||||
|
||||
<p>[Q5] 공문에 나오는 날짜, 담당자 이름, 조직명을 정리해주세요.
|
||||
이 공문에 나오는 날짜는 발신일인 2025년 3월 6일과, 이전 서신을 참조한 2025년 2월 28일이 있습니다. 공문의 발신 담당자 이름은 벤자민 A. 바우티스타(BENJAMIN A. BAUTISTA)이며, 직책은 프로젝트 디렉터입니다. 발신 조직명은 필리핀 공공사업도로부(Department of Public Works and Highways) 중앙 사무소 산하의 도로 관리 클러스터 1(양자) (Roads Management Cluster 1 (Bilateral)) 및 통합 프로젝트 관리 사무소(Unified Project Management Office)입니다. 수신 담당자 이름은 김종학(Mr. KIM, JONG HAK) 팀장이며, 수신 조직명은 서영 엔지니어링 주식회사(Seoyoung Engineering Co., Ltd.)를 비롯하여 사만(SAMAN) 법인, 진우 엔지니어링 코리아 주식회사, 경동 엔지니어링 주식회사와의 연합 법인이며, 엔젤 라자로 & 어소시에이츠 인터내셔널과도 협력 관계에 있습니다.</p>
|
||||
@@ -0,0 +1,20 @@
|
||||
<p><code>json
|
||||
{
|
||||
"공문 번호": "19.1 SDZR/AERA/RCM",
|
||||
"공문 일자": "March 12, 2025",
|
||||
"수신처": "Seoyoung Engineering Co., Ltd.",
|
||||
"수신자": "Team Leader",
|
||||
"수신자(약자)": "TL",
|
||||
"발신처": "DEPARTMENT OF PUBLIC WORKS AND HIGHWAYS",
|
||||
"발신자": "Roads Management Cluster 1 (Bilateral)",
|
||||
"발신자(약자)": "RMC",
|
||||
"공문 제목": "Finalization of Contract Documents for Consulting Services for the Independent Design Check (IDC) of Panay-Guimaras-Negros Island Bridges Project",
|
||||
"공문 제목 요약": "IDC 계약 문서 최종본",
|
||||
"공문 내용 요약": "Panay-Guimaras-Negros Island Bridges Project의 IDC 계약 문서 제출을 요청하는 공문입니다. 2024년 12월 11일 수령한 계약 건에 대한 후속 조치를 촉구하고 있습니다.",
|
||||
"공문간 연계": "없음",
|
||||
"공문 종류": "기술/성과물",
|
||||
"공문 유형": "요청",
|
||||
"첨부문서제목": "없음",
|
||||
"첨부문서수": 0
|
||||
}
|
||||
</code></p>
|
||||
@@ -0,0 +1,39 @@
|
||||
<p>아래는 주어진 공문의 OCR 원시 텍스트를 바탕으로 각 항목별 분석입니다.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p><strong>[Q1] 이 공문의 주요 내용을 한 문단으로 요약해주세요.</strong></p>
|
||||
|
||||
<p>이 공문은 필리핀 공공사업고속도로부에서 Seoyoung Engineering이 주도하는 컨소시엄에 보낸 확인서로, Panay-Guimaras-Negros Island Bridges Project와 관련하여, 독립 설계 점검(Independent Design Check, IDC) 업무의 사전 동원(Advance Mobilization) 기간 동안 임시 사무실 임대 및 차량 렌탈 계약 진행에 대해 이의를 제기하지 않으며, NTP(Notice to Proceed) 발급 전에는 실적에 한해 청구가 가능함을 안내합니다.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p><strong>[Q2] 이 공문은 누구에게 보내졌고, 핵심 전달 사항은 무엇인가요?</strong></p>
|
||||
|
||||
<p>이 공문은 김종학 팀장(Mr. KIM, JONG HAK)과 그의 회사인 Seoyoung Engineering Co., Ltd.(삼안, 진우엔지니어링, 경동엔지니어링 및 ALI와 조인트 벤처 포함)에 보내졌습니다. 핵심 전달 사항은 Panay-Guimaras-Negros Island Bridges Project의 독립 설계 점검(컨설팅 서비스)과 관련하여 임시 사무실과 차량 임대 계약 건에 대해 DPWH(필리핀 공공사업고속도로부)는 이의가 없으며, NTP 발급 전에는 실제 수행한 업무에 대해서만 청구할 수 있다는 점을 재확인하는 것입니다.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p><strong>[Q3] 임대료와 관련된 내용만 추려서 요약해주세요.</strong></p>
|
||||
|
||||
<p>이 공문에서는 IDC JV(컨소시엄)의 신속한 프로젝트 수행을 위해 임시 사무실 임대 계약 및 차량 렌탈 계약에 대해 안내하며, 이에 대해 DPWH측은 이의를 제기하지 않음을 밝혔습니다. 단, 사전 동원 기간 동안 실적분에 대하여만 나중에 NTP 발급 뒤 청구가 가능함을 명시하고 있습니다.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p><strong>[Q4] 이 공문에서 요청한 액션 항목을 정리해주세요.</strong></p>
|
||||
|
||||
<p>공문에서 요청한 액션은 “For your appropriate action(적절한 후속 조치 바람)”이라는 문구로, 수신자가 본 공문에 따라 공지된 조건(임시 사무실 임대 및 차량 렌탈에 대해 DPWH가 이의 없음, NTP 발급 전 실적분 청구 제한) 하에 관련 행동 및 절차를 진행할 것을 요청하고 있습니다.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p><strong>[Q5] 공문에 나오는 날짜, 담당자 이름, 조직명을 정리해주세요.</strong></p>
|
||||
|
||||
<ul>
|
||||
<li>날짜: 2025년 3월 6일 </li>
|
||||
<li>담당자 이름: Mr. KIM, JONG HAK (수신) / 발신자 서명은 단체명(Management Cluster 1, Bilateral)만 표기되어 있으며 개인 이름은 미기재 </li>
|
||||
<li>조직명:
|
||||
<ul>
|
||||
<li>발신: Department of Public Works and Highways (DPWH, 필리핀 공공사업고속도로부) 중앙사무소 </li>
|
||||
<li>수신: Seoyoung Engineering Co., Ltd., SAMAN Corporation, JINWOO Engineering Korea Co., Ltd., Kyong Dong Engineering Co., Ltd. (조인트 벤처), Angel Lazaro & Associates International (협력)</li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
98
workspace/static/html/schema_file_guide.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>🧾 스키마 파일 작성 가이드</title>
|
||||
<style>
|
||||
body { font-family: 'Arial', sans-serif; margin: 40px; line-height: 1.6; }
|
||||
h1, h2 { color: #2c3e50; }
|
||||
code, pre { background: #f4f4f4; padding: 10px; display: block; white-space: pre-wrap; border-left: 4px solid #3498db; }
|
||||
.warn { color: #c0392b; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧾 JSON Schema file 작성 가이드</h2>
|
||||
<p>
|
||||
🔹 JSON Schema는 AI 모델이 생성해야 할 <strong>응답의 구조를 정의</strong>할 때 사용됩니다.<br>
|
||||
🔹 schema_file을 설정하면 문서에서 추출해야 할 항목과 각 항목의 데이터 형식을 명확하게 지정할 수 있습니다.
|
||||
</p>
|
||||
<h3>📌 사용 되는 API 종류</h3>
|
||||
<p>
|
||||
🔹 <strong>/extract/structed</strong>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
<h2>✅ Schema JSON 작성 예시</h2>
|
||||
<p>🔹 [예시] 공문 요약을 위한 JSON Schema 작성 예시입니다:</p>
|
||||
|
||||
<pre>{
|
||||
"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": [
|
||||
"공문번호", "공문일자", "수신체", "수신자", "수신자_약자",
|
||||
"발신체", "발신자", "발신자_약자", "공문제목", "공문제목요약",
|
||||
"공문내용요약", "공문종류", "공문유형", "첨부문서제목", "첨부문서수"
|
||||
]
|
||||
}</pre>
|
||||
|
||||
<hr>
|
||||
|
||||
<h3>📌 주요 키 설명</h3>
|
||||
<p>🔹 위 JSON 예시는 <strong>Schema 구조</strong>를 정의하는 방식으로 작성되어 있으며, 각 키의 의미는 다음과 같습니다:</p>
|
||||
<ul>
|
||||
<li><strong>title</strong>: JSON 스키마의 이름 또는 제목을 정의합니다. 일반적으로 문서나 데이터 객체의 이름으로 사용됩니다.</li>
|
||||
<li><strong>type</strong>: 이 JSON 전체 구조가 어떤 데이터 형태인지 지정합니다. 예: object, array, string 등.</li>
|
||||
<li><strong>properties</strong>: 객체 내부에 포함된 각 항목(필드)을 정의하는 공간입니다. 각 항목에 대해 <strong>type</strong>이나 <strong>enum</strong>을 지정할 수 있습니다.</li>
|
||||
<li><strong>required</strong>: 필수로 입력되어야 할 항목을 배열 형태로 나열합니다. 이 <strong>항목들이 누락되면 JSON 유효성 검사에서 실패</strong>하게 됩니다.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h3>📌 필드 속성 설명</h3>
|
||||
<p>🔹 각 항목에 정의되는 <strong>type</strong>과 <strong>enum</strong>의 의미는 다음과 같습니다:</p>
|
||||
<ul>
|
||||
<li><strong>type</strong>: 해당 필드의 데이터 유형을 명시합니다. 주요 유형은 다음과 같습니다:
|
||||
<ul>
|
||||
<li><strong>string</strong>: 문자열 값 (예: "서울특별시")</li>
|
||||
<li><strong>integer</strong>: 정수 값 (예: 3, 25)</li>
|
||||
<li><strong>boolean</strong>: 참/거짓 논리값 (예: true, false)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>enum</strong>: 해당 항목이 가질 수 있는 값을 제한할 때 사용합니다. 배열로 허용 가능한 값을 정의하며, 그 외 값은 허용되지 않습니다.<br>
|
||||
예: <strong>"공문종류"는 "행정/일반", "기술/성과물", "회의/기타" 중 하나여야 함</strong>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="warn">Tip. 프롬프트 작성 시 각 항목에 대한 <strong>지시문(description)</strong>을 따로 설정하면 AI 응답의 품질이 더욱 향상됩니다.</p>
|
||||
<code> 1. 공문번호: 문서 번호를 기입하세요. (예시: Ref. No. SYJV-250031)
|
||||
2. 공문일자: 공문 발행일을 작성하세요. (예시: Mar / 28 / 2025)
|
||||
3. 수신처: 수신 기관이나 부서명을 작성하세요. (예시: Department of Public Works and Highways)
|
||||
...
|
||||
16. 첨부문서수: 첨부문서제목을 바탕으로 문서의 개수를 작성하세요.
|
||||
</code>
|
||||
</body>
|
||||
</html>
|
||||
BIN
workspace/static/image/FastAPI_extract_structured_swagger.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
workspace/static/image/FastAPI_extract_swagger.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
workspace/static/image/FastAPI_general.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
workspace/static/image/FastAPI_general_JSONresult.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
workspace/static/image/FastAPI_general_response.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
workspace/static/image/FastAPI_general_result.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
workspace/static/image/logo.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
30
workspace/static/prompt/d6c_test_prompt_eng.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
Instructions:
|
||||
- Accuracy is critically important.
|
||||
- The reference number must be extracted only from the line that starts with "Ref. No."
|
||||
- For items marked as “Korean”, the answer must be written in Korean.
|
||||
- Output only the following 13 fields, no more, no less.
|
||||
- If the information is unknown, write "확인필요". If it is clearly missing, write "없음".
|
||||
|
||||
1. 공문 번호: Extract only the "Ref. No." line in the format "ENG-NUM"
|
||||
2. 공문 일자: YYYY.MM.DD
|
||||
3. 수신자: Extract the job title of the recipient only
|
||||
4. 수신자 약자: Abbreviation of recipient's title
|
||||
5. 발신자: Extract the job title of the sender only from the signature block at the end of the document
|
||||
- Do not extract contact persons mentioned in the body
|
||||
- Do not include the organization names
|
||||
6.. 발신자 약자: Abbreviation of sender's title
|
||||
7. 공문 제목: Extract only the first line or the first bold phrase directly following the “Subject:” label, Do NOT include secondary lines or explanatory phrases, such as project names or descriptions.
|
||||
8. 공문 제목 요약: Write a 10–20 character summary in Korean
|
||||
9. 공문 내용 요약: Provide a brief summary in Korean
|
||||
10. 공문간 연계: Write "있음" only if the content of the document explicitly mentions, references, or responds to another document, Do not infer linkage based on date similarity, numbering (e.g., “PH-00”), or reference format alone.
|
||||
If no explicit mention of another document is found in the content, write "없음"
|
||||
11. 공문 종류: Choose one of the following
|
||||
-행정/일반=for administrative topics such as personnel, dispatch, budget, contracts
|
||||
-기술/성과물=for technical discussions, schedules, deliverables, technical meetings
|
||||
12. 공문 유형: Choose one from
|
||||
-보고=One-way communication of facts or plans
|
||||
-요청=Requests or inquiries to the recipient
|
||||
-지시=Orders or commands from authority
|
||||
-회신=Replies or feedback to prior documents
|
||||
-계약=Official correspondence related to contract terms
|
||||
13. 첨부문서 수: Provide the number only
|
||||
31
workspace/static/prompt/default_prompt_v0.1.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
다음은 스캔된 문서에서 OCR로 추출된 원시 텍스트입니다.
|
||||
오타나 줄바꿈 오류가 있을 수 있으니 의미를 유추하여 정확한 정보를 추출해주세요.
|
||||
정확성이 매우 중요하므로 반드시 공문에 포함된 텍스트만 사용하여 작성해주세요.
|
||||
|
||||
다음 주어진 항목을 반드시 JSON 형식(```json)으로 작성해주세요:
|
||||
|
||||
1. 공문 번호: 공문 번호로 Ref. No.를 의미합니다. 없는 경우는 없음으로 표기해주세요. (예시: Ref. No. SYJV-250031)
|
||||
2. 공문 일자: 공문에 적혀 있는 날짜입니다. 번역하지 않고 그대로 표기해주세요. (예시: Mar / 28 / 2025)
|
||||
3. 수신처: 공문을 받는 사람이 속한 조직명 (예시: Department of Public Works and Highways)
|
||||
4. 수신자: 공문을 받은 사람의 직책 (예시: Project Director)
|
||||
5. 수신자(약자): 수신자 직책 약자 (예시: PD)
|
||||
6. 발신처: 공문을 보낸 사람이 속한 조직명 (예시: SEOYOUNG JOINT VENTURE)
|
||||
7. 발신자: 공문을 보낸 사람의 직책 (예시: Team Leader)
|
||||
8. 발신자(약자): 발신자 직책 약자 (예시: TL)
|
||||
9. 공문 제목: 공문의 제목으로 SUBJECT 의미합니다. 적당한 길이로 끊어야 하는데 윗 문장이 프로젝트 이름으로 판단되는 경우, 9.1 프로젝트 항목을 신설해 리턴 (예시: Submission of Comment Matrix for Design Deliverable)
|
||||
10. 공문 제목 요약: 공문 제목을 10~20자 사이로 요약해주세요. 반드시 한글로 작성합니다.
|
||||
11. 공문 내용 요약: 공문 내용을 요약해주세요. 반드시 한글로 작성합니다.
|
||||
12. 공문간 연계: 연계된 공문이 있으면 공문번호를 알려주세요. 공문번호만 필요합니다. 없는 경우는 없음으로 표기해주세요.
|
||||
13. 공문 종류: 공문 종류는 공문의 내용을 분석해서 다음 3가지 중 반드시 하나를 선택합니다.
|
||||
* 행정/일반 – 인사, 파견, 조직, 비용(예산), 계약 등 경영/행정 관련
|
||||
* 기술/성과물 – 일정 협의, 작업계획, 성과물 제출, 기술적 업무 회의, 성과물 전달 등
|
||||
* 회의/기타 – 회의록 등 위에 내용 이외의 것
|
||||
14. 공문 유형: 공문 유형은 공문의 내용을 분석해서 다음의 5가지 중 반드시 하나를 선택합니다.
|
||||
* 보고 : 완료된 사실이나 계획을 일방적으로 알리는 공문
|
||||
* 요청 : 상대방의 행동 또는 답변을 유도하는 공문
|
||||
* 지시 : 권한 있는 주체가 수행을 명령하는 공문
|
||||
* 회신 : 기존 공무에 대해 응답하거나 의견을 제공하는 공문
|
||||
* 계약 : 계약조건 변경과 관련된 공식 공문
|
||||
15. 첨부문서제목: 공문의 첨부 문서는 Enclosures: 를 의미합니다. 없는 경우는 없음으로 표기해주세요. (예시: 1. Comment Matrix_4.4.2 Draft Detailed Engineer Design Report (Section A) )
|
||||
16. 첨부문서수: 찾은 첨부문서 개수를 알려주세요.
|
||||
17. 번역본: 원문 본문 전체를 의미 왜곡 없이 한국어로 번역해 주세요. 원문이 이미 한국어라면 원문을 그대로 사용합니다. 고유명사/기관명/직책/Ref. No./날짜/첨부명 등은 원문 표기(대소문자·구두점 포함) 유지하고, 목록·번호·줄바꿈 등 서식은 가능한 한 보존하세요. OCR 하이픈 분리/비정상 줄바꿈은 자연스럽게 복구합니다.
|
||||
24
workspace/static/prompt/i18n_test_prompt_kor.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
주의:
|
||||
- **정확성이 매우 중요합니다.**
|
||||
- 한글로 작성하라고 명시된 항목은 반드시 한글로 작성해야 합니다.
|
||||
- 반드시 아래 **1~10번 항목만** 출력하며, 절대 누락하지 마세요.
|
||||
- 항목을 알 수 없으면 "확인필요", 항목이 문서에 존재하지 않으면 "없음"이라고 작성하세요.
|
||||
|
||||
1. 공문 번호
|
||||
2. 공문 일자: YYYY.MM.DD
|
||||
3. 수신자
|
||||
4. 발신자: 담당
|
||||
5. 공문 제목
|
||||
6. 공문 내용 요약: **한글로** 간단하게 요약
|
||||
7. 공문간 연계: 다른 공문과의 연관이 명시되어 있으면 "있음", 없으면 "없음"으로 작성
|
||||
8. 공문 종류: 아래 중 하나를 선택
|
||||
- 행정/일반: 인사, 파견, 조직, 예산, 계약 등 행정 관련 내용
|
||||
- 기술/성과물: 일정, 작업계획, 성과물 제출, 기술 업무 등
|
||||
9. 공문 유형: 아래 중 하나를 선택
|
||||
- 보고: 완료된 사실이나 계획을 알리는 경우
|
||||
- 요청: 상대방의 행동이나 응답을 요구하는 경우
|
||||
- 지시: 권한 있는 주체가 수행을 명령하는 경우
|
||||
- 회신: 기존 공문에 대한 응답이나 의견인 경우
|
||||
- 계약: 계약 조건 변경과 관련된 공문
|
||||
10. 첨부문서 수: 숫자만 작성
|
||||
|
||||
29
workspace/static/prompt/structured_prompt_v0.1.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
다음은 스캔된 문서에서 OCR로 추출된 원시 텍스트입니다.
|
||||
오타나 줄바꿈 오류가 있을 수 있으니 의미를 유추하여 정확한 정보를 추출해주세요.
|
||||
정확성이 매우 중요하므로 반드시 공문에 포함된 텍스트만 사용하여 작성해주세요.
|
||||
|
||||
1. 공문 번호: 공문 번호로 Ref. No.를 의미합니다. 없는 경우는 없음으로 표기해주세요. (예시: Ref. No. SYJV-250031)
|
||||
2. 공문 일자: 공문에 적혀 있는 날짜입니다. 번역하지 않고 그대로 표기해주세요. (예시: Mar / 28 / 2025)
|
||||
3. 수신처: 공문을 받는 사람이 속한 조직명 (예시: Department of Public Works and Highways)
|
||||
4. 수신자: 공문을 받은 사람의 직책 (예시: Project Director)
|
||||
5. 수신자(약자): 수신자 직책 약자 (예시: PD)
|
||||
6. 발신처: 공문을 보낸 사람이 속한 조직명 (예시: SEOYOUNG JOINT VENTURE)
|
||||
7. 발신자: 공문을 보낸 사람의 직책 (예시: Team Leader)
|
||||
8. 발신자(약자): 발신자 직책 약자 (예시: TL)
|
||||
9. 공문 제목: 공문의 제목으로 SUBJECT 의미합니다. 적당한 길이로 끊어야 하는데 윗 문장이 프로젝트 이름으로 판단되는 경우, 9.1 프로젝트 항목을 신설해 리턴 (예시: Submission of Comment Matrix for Design Deliverable)
|
||||
10. 공문 제목 요약: 공문 제목을 10~20자 사이로 요약해주세요. 반드시 한글로 작성합니다.
|
||||
11. 공문 내용 요약: 공문 내용을 요약해주세요. 반드시 한글로 작성합니다.
|
||||
12. 공문간 연계: 연계된 공문이 있으면 공문번호를 알려주세요. 공문번호만 필요합니다. 없는 경우는 없음으로 표기해주세요.
|
||||
13. 공문 종류: 공문 종류는 공문의 내용을 분석해서 다음 3가지 중 반드시 하나를 선택합니다.
|
||||
* 행정/일반 – 인사, 파견, 조직, 비용(예산), 계약 등 경영/행정 관련
|
||||
* 기술/성과물 – 일정 협의, 작업계획, 성과물 제출, 기술적 업무 회의, 성과물 전달 등
|
||||
* 회의/기타 – 회의록 등 위에 내용 이외의 것
|
||||
14. 공문 유형: 공문 유형은 공문의 내용을 분석해서 다음의 5가지 중 반드시 하나를 선택합니다.
|
||||
* 보고 : 완료된 사실이나 계획을 일방적으로 알리는 공문
|
||||
* 요청 : 상대방의 행동 또는 답변을 유도하는 공문
|
||||
* 지시 : 권한 있는 주체가 수행을 명령하는 공문
|
||||
* 회신 : 기존 공무에 대해 응답하거나 의견을 제공하는 공문
|
||||
* 계약 : 계약조건 변경과 관련된 공식 공문
|
||||
15. 첨부문서제목: 공문의 첨부 문서는 Enclosures: 를 의미합니다. 없는 경우는 없음으로 표기해주세요. (예시: 1. Comment Matrix_4.4.2 Draft Detailed Engineer Design Report (Section A) )
|
||||
16. 첨부문서수: 찾은 첨부문서 개수를 알려주세요.
|
||||
17. 번역본: 원문 본문 전체를 의미 왜곡 없이 한국어로 번역해 주세요. 원문이 이미 한국어라면 원문을 그대로 사용합니다. 고유명사/기관명/직책/Ref. No./날짜/첨부명 등은 원문 표기(대소문자·구두점 포함) 유지하고, 목록·번호·줄바꿈 등 서식은 가능한 한 보존하세요. OCR 하이픈 분리/비정상 줄바꿈은 자연스럽게 복구합니다.
|
||||
34
workspace/static/structured_schema.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"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": [
|
||||
"공문번호", "공문일자", "수신처", "수신자", "수신자_약자",
|
||||
"발신처", "발신자", "발신자_약자", "공문제목", "공문제목요약",
|
||||
"공문내용요약", "공문종류", "공문유형", "첨부문서제목", "첨부문서수"
|
||||
]
|
||||
}
|
||||
|
||||
BIN
workspace/utils/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/checking_files.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/checking_keys.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/image_converter.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/logging_utils.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/minio_utils.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/prompt_cache.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/redis_utils.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/request_utils.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/text_extractor.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/text_formatter.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/text_generator.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/text_processor.cpython-310.pyc
Normal file
BIN
workspace/utils/__pycache__/upload_file_to_minio.cpython-310.pyc
Normal file
133
workspace/utils/checking_keys.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Union
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
from services.api_key_service import validate_api_key
|
||||
from snowflake import SnowflakeGenerator
|
||||
from snowflake.snowflake import MAX_INSTANCE # ✅ 범위 보정에 사용
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
load_dotenv()
|
||||
|
||||
# .env 파일에서 관리자 API 키를 로드
|
||||
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY")
|
||||
|
||||
# 헤더 설정
|
||||
api_key_header = APIKeyHeader(
|
||||
name="X-API-KEY", auto_error=False, description="Client-specific API Key"
|
||||
)
|
||||
admin_api_key_header = APIKeyHeader(
|
||||
name="X-Admin-KEY", auto_error=False, description="Key for administrative tasks"
|
||||
)
|
||||
|
||||
|
||||
def get_api_key(api_key: str = Security(api_key_header)):
|
||||
"""요청 헤더의 X-API-KEY가 유효한지 Redis를 통해 검증합니다."""
|
||||
if not validate_api_key(api_key):
|
||||
logger.warning(f"유효하지 않은 API 키로 접근 시도: {api_key}")
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing API Key")
|
||||
return api_key
|
||||
|
||||
|
||||
def get_admin_key(admin_key: str = Security(admin_api_key_header)):
|
||||
"""관리자용 API 키를 검증합니다."""
|
||||
if not ADMIN_API_KEY:
|
||||
logger.error(
|
||||
"ADMIN_API_KEY가 서버에 설정되지 않았습니다. 관리자 API를 사용할 수 없습니다."
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Server configuration error")
|
||||
|
||||
if not admin_key or admin_key != ADMIN_API_KEY:
|
||||
logger.warning("유효하지 않은 관리자 키로 관리 API 접근 시도.")
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this operation")
|
||||
return admin_key
|
||||
|
||||
|
||||
class APIKeyLoader:
|
||||
@staticmethod
|
||||
def load_gemini_key() -> str:
|
||||
key = os.getenv("GEMINI_API_KEY")
|
||||
if not key:
|
||||
logger.error("GEMINI_API_KEY 환경 변수가 설정되지 않았습니다.")
|
||||
raise ValueError("GEMINI_API_KEY 환경 변수가 설정되지 않았습니다.")
|
||||
return key
|
||||
|
||||
@staticmethod
|
||||
def load_claude_key() -> str:
|
||||
key = os.getenv("ANTHROPIC_API_KEY")
|
||||
if not key:
|
||||
logger.error("ANTHROPIC_API_KEY 환경 변수가 설정되지 않았습니다.")
|
||||
raise ValueError("ANTHROPIC_API_KEY 환경 변수가 설정되지 않았습니다.")
|
||||
return key
|
||||
|
||||
@staticmethod
|
||||
def load_gpt_key() -> str:
|
||||
key = os.getenv("OPENAI_API_KEY")
|
||||
if not key:
|
||||
logger.error("OPENAI_API_KEY 환경 변수가 설정되지 않았습니다.")
|
||||
raise ValueError("OPENAI_API_KEY 환경 변수가 설정되지 않았습니다.")
|
||||
return key
|
||||
|
||||
|
||||
def _clamp_instance(value: int) -> int:
|
||||
"""0..MAX_INSTANCE 범위를 벗어나지 않도록 보정"""
|
||||
if value < 0:
|
||||
return 0
|
||||
if value > MAX_INSTANCE:
|
||||
return MAX_INSTANCE
|
||||
return value
|
||||
|
||||
|
||||
def _hash_to_instance(text: str, salt: Optional[str] = None) -> int:
|
||||
"""
|
||||
임의 문자열(IP/호스트명 등)을 Snowflake 인스턴스 정수에 매핑.
|
||||
- 파이썬 내장 hash는 런마다 바뀌므로 sha256 사용.
|
||||
- salt를 섞어 워커/파드 간 충돌 확률을 추가로 낮출 수 있음.
|
||||
"""
|
||||
base = text.strip().strip("[]") # IPv6 bracket 등 정리
|
||||
if salt:
|
||||
base = f"{base}-{salt}"
|
||||
digest = int(hashlib.sha256(base.encode("utf-8")).hexdigest(), 16)
|
||||
return digest % (MAX_INSTANCE + 1)
|
||||
|
||||
|
||||
def _normalize_instance(node: Union[int, str, None]) -> int:
|
||||
"""
|
||||
SnowflakeGenerator가 요구하는 정수 인스턴스로 변환:
|
||||
- int: 범위 보정
|
||||
- 숫자 문자열: int 변환 후 보정
|
||||
- 기타 문자열(IP/호스트명 등): 해시 매핑
|
||||
- None/기타: 0
|
||||
"""
|
||||
# 충돌 완화를 위한 선택적 솔트 (예: POD_NAME / HOSTNAME / PID)
|
||||
salt = os.getenv("SNOWFLAKE_SALT") or os.getenv("POD_NAME") or str(os.getpid())
|
||||
|
||||
if isinstance(node, int):
|
||||
return _clamp_instance(node)
|
||||
|
||||
if isinstance(node, str):
|
||||
s = node.strip()
|
||||
if s.isdigit():
|
||||
try:
|
||||
return _clamp_instance(int(s))
|
||||
except Exception:
|
||||
pass
|
||||
# 숫자 문자열이 아니면 해시로 매핑
|
||||
return _hash_to_instance(s, salt=salt)
|
||||
|
||||
# None 또는 기타 타입
|
||||
return 0
|
||||
|
||||
|
||||
def create_key(node: Union[int, str, None] = 1) -> str:
|
||||
"""
|
||||
Snowflake 알고리즘 기반 고유 키 생성기 (request_id용).
|
||||
node는 int/str/IP/호스트명/None 등 무엇이 오든 안전하게 정수 인스턴스로 매핑됩니다.
|
||||
"""
|
||||
instance = _normalize_instance(node)
|
||||
generator = SnowflakeGenerator(instance)
|
||||
return str(next(generator))
|
||||
22
workspace/utils/redis_utils.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# utils/redis_utils.py
|
||||
|
||||
import redis
|
||||
from config.setting import PGN_REDIS_DB, PGN_REDIS_HOST, PGN_REDIS_PORT
|
||||
|
||||
|
||||
def get_redis_client():
|
||||
"""
|
||||
Redis 클라이언트를 반환합니다. decode_responses=True 설정으로 문자열을 자동 디코딩합니다.
|
||||
"""
|
||||
try:
|
||||
redis_client = redis.Redis(
|
||||
host=PGN_REDIS_HOST,
|
||||
port=PGN_REDIS_PORT,
|
||||
db=PGN_REDIS_DB,
|
||||
decode_responses=True,
|
||||
)
|
||||
# 연결 확인 (ping)
|
||||
redis_client.ping()
|
||||
return redis_client
|
||||
except redis.ConnectionError as e:
|
||||
raise RuntimeError(f"Redis 연결 실패: {e}")
|
||||
90
workspace/utils/text_processor.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def safe_filename(filename: str) -> str:
|
||||
# 확장자 제거
|
||||
print(f"[FILE NAME] {filename}")
|
||||
base = Path(filename).stem
|
||||
base = unicodedata.normalize("NFKC", base)
|
||||
base = base.replace(" ", "_")
|
||||
base = re.sub(r"[^\w\-\.가-힣]", "_", base, flags=re.UNICODE)
|
||||
base = re.sub(r"_+", "_", base).strip("._-")
|
||||
|
||||
# 비어있으면 안전한 기본값
|
||||
if not base:
|
||||
base = f"result_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
return f"{base}.html"
|
||||
|
||||
|
||||
def post_process(input_json, generated_text, llm_model):
|
||||
result_dict = {}
|
||||
# ✅ JSON 코드블럭 형식 처리
|
||||
if "```json" in generated_text:
|
||||
try:
|
||||
logger.debug("[PROCESS-JSON] JSON 코드블럭 형식 후처리 진행합니다.")
|
||||
json_block = re.search(
|
||||
r"```json\s*(\{.*?\})\s*```", generated_text, re.DOTALL
|
||||
)
|
||||
if json_block:
|
||||
parsed_json = json.loads(json_block.group(1))
|
||||
result_dict = {
|
||||
re.sub(r"[^ㄱ-ㅎ가-힣a-zA-Z]", "", k): v
|
||||
for k, v in parsed_json.items()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("[PROCESS-ERROR] JSON 코드블럭 파싱 실패:", e)
|
||||
|
||||
# ✅ 길이 초과 메시지 감지 및 처리
|
||||
elif "입력 텍스트가" in generated_text and "모델 호출 생략" in generated_text:
|
||||
result_dict = {
|
||||
"message": "⚠️ 입력 텍스트가 너무 깁니다. LLM 모델 호출을 생략했습니다.",
|
||||
"note": "OCR로 추출된 원본 텍스트(parsed)를 참고해 주세요.",
|
||||
}
|
||||
|
||||
else:
|
||||
# ✅ "1.제목:" 또는 "1. 제목:" 형식 처리
|
||||
logger.debug("[PROCESS-STRING] JSON 코드블럭 형식이 아닙니다.")
|
||||
blocks = re.split(r"\n(?=\d+\.\s*[^:\n]+:)", generated_text.strip())
|
||||
|
||||
for block in blocks:
|
||||
if ":" in block:
|
||||
key_line, *rest = block.split(":", 1)
|
||||
key = re.sub(r"^\d+\.\s*", "", key_line).strip()
|
||||
cleaned_key = re.sub(r"[^ㄱ-ㅎ가-힣a-zA-Z]", "", key)
|
||||
|
||||
value = rest[0].strip() if rest else ""
|
||||
value = re.sub(r"^[^\w가-힣a-zA-Z]+", "", value).strip()
|
||||
|
||||
result_dict[cleaned_key] = value
|
||||
|
||||
input_json["result"] = result_dict
|
||||
input_json["llm_model"] = llm_model
|
||||
|
||||
# final_result
|
||||
logger.info(json.dumps(input_json["result"], indent=2, ensure_ascii=False))
|
||||
|
||||
return input_json
|
||||
|
||||
|
||||
def ocr_process(filename, ocr_model, coord, text, start_time, end_time):
|
||||
json_data = {
|
||||
"filename": filename,
|
||||
"model": {"ocr_model": ocr_model},
|
||||
"time": {
|
||||
"duration_sec": f"{end_time - start_time:.2f}",
|
||||
"started_at": start_time,
|
||||
"ended_at": end_time,
|
||||
},
|
||||
"fields": coord,
|
||||
"parsed": text,
|
||||
}
|
||||
|
||||
return json_data
|
||||