원 레포랑 완전 분리
This commit is contained in:
0
workspace/services/__init__.py
Normal file
0
workspace/services/__init__.py
Normal file
167
workspace/services/api_key_service.py
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
|
||||
38
workspace/services/download_service.py
Normal file
38
workspace/services/download_service.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from fastapi.responses import FileResponse
|
||||
from config.setting import DEFAULT_PROMPT_PATH, STRUCTURED_PROMPT_PATH, STRUCTURED_SCHEMA_PATH
|
||||
|
||||
class DownloadService:
|
||||
@staticmethod
|
||||
def download_default_prompt():
|
||||
return FileResponse(
|
||||
DEFAULT_PROMPT_PATH,
|
||||
media_type="text/plain",
|
||||
filename="default_prompt.txt",
|
||||
headers=DownloadService._no_cache_headers()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def download_structured_prompt():
|
||||
return FileResponse(
|
||||
STRUCTURED_PROMPT_PATH,
|
||||
media_type="text/plain",
|
||||
filename="structured_prompt.txt",
|
||||
headers=DownloadService._no_cache_headers()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def download_structured_schema():
|
||||
return FileResponse(
|
||||
STRUCTURED_SCHEMA_PATH,
|
||||
media_type="application/json",
|
||||
filename="structured_schema.json",
|
||||
headers=DownloadService._no_cache_headers()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _no_cache_headers():
|
||||
return {
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0"
|
||||
}
|
||||
20
workspace/services/dummy_service.py
Normal file
20
workspace/services/dummy_service.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import json
|
||||
from fastapi.responses import JSONResponse
|
||||
from config.setting import STATIC_DIR
|
||||
|
||||
class DummyService:
|
||||
@staticmethod
|
||||
async def extract_dummy():
|
||||
"""
|
||||
static 디렉터리의 더미 JSON 응답 파일을 반환합니다.
|
||||
"""
|
||||
dummy_path = STATIC_DIR / "dummy_response.json"
|
||||
try:
|
||||
with open(dummy_path, "r", encoding="utf-8") as f:
|
||||
dummy_data = json.load(f)
|
||||
return JSONResponse(content=dummy_data)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"❌ 더미 파일을 불러오지 못했습니다: {e}"}
|
||||
)
|
||||
281
workspace/services/inference_service.py
Normal file
281
workspace/services/inference_service.py
Normal file
@@ -0,0 +1,281 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from config.setting import (
|
||||
DEFAULT_PROMPT_PATH,
|
||||
MINIO_BUCKET_NAME,
|
||||
PGN_REDIS_DB,
|
||||
PGN_REDIS_HOST,
|
||||
PGN_REDIS_PORT,
|
||||
STRUCTURED_PROMPT_PATH,
|
||||
UPLOAD_DIR,
|
||||
)
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from redis import Redis
|
||||
from utils.logging_utils import log_user_request
|
||||
from utils.minio_utils import save_result_to_minio, upload_file_to_minio_v2
|
||||
from utils.prompt_cache import compute_file_hash, save_prompt_file_if_not_exists
|
||||
|
||||
from services.dummy_service import DummyService
|
||||
from services.model_service import ModelInfoService
|
||||
from services.pipeline_runner import PipelineRunner
|
||||
|
||||
# Redis 클라이언트 (LLM Gateway 전용)
|
||||
redis_client = Redis(
|
||||
host=PGN_REDIS_HOST, port=PGN_REDIS_PORT, db=PGN_REDIS_DB, decode_responses=True
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InferenceHandler:
|
||||
# ☑️ /general-공통처리함수
|
||||
@staticmethod
|
||||
async def handle_general_background(
|
||||
request_id: str,
|
||||
result_id: str,
|
||||
input_file: UploadFile,
|
||||
prompt_file: UploadFile,
|
||||
mode: str,
|
||||
model: str,
|
||||
api_key: str,
|
||||
schema_file: Optional[UploadFile] = None,
|
||||
request_info: Optional[str] = None,
|
||||
):
|
||||
logger.info(f"[INPUT_FILE_NAME]: {input_file.filename}")
|
||||
|
||||
try:
|
||||
# ✅ prompt_file이 없으면 사용자 에러 응답 반환
|
||||
if not prompt_file or not prompt_file.filename:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="❌ 프롬프트 파일(prompt_file)은 반드시 업로드해야 합니다.",
|
||||
)
|
||||
|
||||
# ✅ 파일 저장
|
||||
filename = input_file.filename
|
||||
file_path = os.path.join(UPLOAD_DIR, filename)
|
||||
with open(file_path, "wb") as f:
|
||||
shutil.copyfileobj(input_file.file, f)
|
||||
|
||||
# 🔽 프롬프트 해시 처리 + 캐시 저장 + 내용 읽기
|
||||
file_hash = compute_file_hash(prompt_file)
|
||||
cached_prompt_path = save_prompt_file_if_not_exists(file_hash, prompt_file)
|
||||
|
||||
with open(cached_prompt_path, encoding="utf-8") as f:
|
||||
prompt = f.read()
|
||||
|
||||
custom_mode = True
|
||||
|
||||
# ✅ schema_file 있으면 structured 모드로 전환 및 로딩
|
||||
schema_override = None
|
||||
if schema_file and schema_file.filename:
|
||||
schema_override = json.loads(await schema_file.read())
|
||||
mode = "structured" # override
|
||||
|
||||
# ✅ 모델 정보 수집
|
||||
info_response = await ModelInfoService.get_model_info()
|
||||
info = json.loads(info_response.body.decode("utf-8"))
|
||||
inner_models = info["models"]["inner_model"]["model_list"]
|
||||
outer_models = info["models"]["outer_model"]["model_list"]
|
||||
model_url_map = await ModelInfoService.get_ollama_model_map()
|
||||
|
||||
# presigned_url = upload_file_to_minio(input_file, request_id)
|
||||
presigned_url = upload_file_to_minio_v2(
|
||||
file=input_file,
|
||||
bucket_name=MINIO_BUCKET_NAME,
|
||||
object_name=f"{request_id}/{filename}",
|
||||
)
|
||||
logger.info(f"[MinIO] presigned URL 생성 완료: {presigned_url}")
|
||||
|
||||
# ✅ run_pipeline 재사용 (schema_override는 일반 추론이므로 None)
|
||||
results_minio = await PipelineRunner.run_pipeline(
|
||||
request_info=request_info,
|
||||
request_id=request_id,
|
||||
file_path=presigned_url,
|
||||
filename=filename,
|
||||
prompt=prompt,
|
||||
prompt_filename=Path(cached_prompt_path).name,
|
||||
custom_mode=custom_mode,
|
||||
mode=mode,
|
||||
model=model,
|
||||
inner_models=inner_models,
|
||||
outer_models=outer_models,
|
||||
model_url_map=model_url_map,
|
||||
api_key=api_key,
|
||||
schema_override=schema_override,
|
||||
prompt_mode="general",
|
||||
)
|
||||
# ✅ 결과 Redis 저장
|
||||
results_redis = {k: v for k, v in results_minio.items() if k != "fields"}
|
||||
redis_key = f"pipeline_result:{result_id}"
|
||||
redis_client.set(
|
||||
redis_key, json.dumps(results_redis, ensure_ascii=False), ex=60 * 60
|
||||
)
|
||||
logger.info(f"[REDIS] 결과 Redis 저장 완료: {result_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[PIPELINE] ❌ result_id={result_id} 처리 실패: {e}")
|
||||
redis_client.set(
|
||||
f"pipeline_result:{result_id}",
|
||||
json.dumps({"error": str(e)}),
|
||||
ex=60 * 60,
|
||||
)
|
||||
|
||||
# ✅ 결과 MinIO 저장 (전체본)
|
||||
try:
|
||||
minio_key = f"{request_id}/{input_file.filename.rsplit('.', 1)[0]}.json"
|
||||
presigned_url = save_result_to_minio(
|
||||
result_dict=results_minio,
|
||||
object_name=minio_key,
|
||||
)
|
||||
logger.info(f"[MinIO] 결과 MinIO 저장 완료: {presigned_url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MinIO] 결과 저장 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="결과 파일 저장 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def handle_extract_background(
|
||||
request_id: str,
|
||||
result_id: str,
|
||||
input_file: UploadFile,
|
||||
schema_file: Optional[UploadFile],
|
||||
prompt_file: Optional[UploadFile],
|
||||
mode: str,
|
||||
model_list: List[str],
|
||||
api_key: str,
|
||||
request_info: Optional[str] = None,
|
||||
):
|
||||
# ✅ dummy 요청 처리
|
||||
if model_list == ["dummy"]:
|
||||
try:
|
||||
log_user_request(
|
||||
request_info=request_info,
|
||||
endpoint="dummy/extract/outer",
|
||||
input_filename="None",
|
||||
model="dummy",
|
||||
prompt_filename="None",
|
||||
context_length=0,
|
||||
api_key=api_key,
|
||||
)
|
||||
return await DummyService.extract_dummy()
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to log 'dummy/extract/outer' request: {e}")
|
||||
|
||||
try:
|
||||
if prompt_file and prompt_file.filename:
|
||||
file_hash = compute_file_hash(prompt_file)
|
||||
cached_prompt_path = save_prompt_file_if_not_exists(
|
||||
file_hash, prompt_file
|
||||
)
|
||||
with open(cached_prompt_path, encoding="utf-8") as f:
|
||||
prompt = f.read()
|
||||
custom_mode = True
|
||||
prompt_filename = Path(cached_prompt_path).name
|
||||
else:
|
||||
prompt_path = (
|
||||
STRUCTURED_PROMPT_PATH
|
||||
if mode == "structured"
|
||||
else DEFAULT_PROMPT_PATH
|
||||
)
|
||||
with open(prompt_path, encoding="utf-8") as f:
|
||||
prompt = f.read()
|
||||
custom_mode = False
|
||||
prompt_filename = Path(prompt_path).name
|
||||
|
||||
if schema_file and schema_file.filename:
|
||||
# clone_upload_file()로 복제된 파일은 UploadFile과 달리 await .read() 지원 안 함
|
||||
schema_content = schema_file.file.read() # 파일 핸들로 읽기
|
||||
schema_override = json.loads(schema_content.decode("utf-8"))
|
||||
else:
|
||||
# with open(STRUCTURED_SCHEMA_PATH, "r", encoding="utf-8") as f:
|
||||
# schema_override = json.load(f)
|
||||
schema_override = None
|
||||
|
||||
info_response = await ModelInfoService.get_model_info()
|
||||
info = json.loads(info_response.body.decode("utf-8"))
|
||||
inner_models = info["models"]["inner_model"]["model_list"]
|
||||
outer_models = info["models"]["outer_model"]["model_list"]
|
||||
model_url_map = await ModelInfoService.get_ollama_model_map()
|
||||
|
||||
# presigned_url = upload_file_to_minio(input_file, request_id)
|
||||
presigned_url = upload_file_to_minio_v2(
|
||||
file=input_file,
|
||||
bucket_name=MINIO_BUCKET_NAME,
|
||||
object_name=f"{request_id}/{input_file.filename}",
|
||||
)
|
||||
logger.info(f"[MinIO] presigned URL 생성 완료: {presigned_url}")
|
||||
|
||||
tasks = []
|
||||
for model in model_list:
|
||||
tasks.append(
|
||||
PipelineRunner.run_pipeline(
|
||||
request_info=request_info,
|
||||
request_id=request_id,
|
||||
file_path=presigned_url,
|
||||
filename=input_file.filename,
|
||||
prompt=prompt,
|
||||
prompt_filename=prompt_filename,
|
||||
custom_mode=custom_mode,
|
||||
mode=mode,
|
||||
model=model,
|
||||
inner_models=inner_models,
|
||||
outer_models=outer_models,
|
||||
model_url_map=model_url_map if model in inner_models else {},
|
||||
api_key=api_key,
|
||||
schema_override=schema_override,
|
||||
prompt_mode="extract",
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await asyncio.gather(*tasks)
|
||||
results_minio = []
|
||||
results_redis = []
|
||||
|
||||
for result in result_set:
|
||||
results_minio.append(result)
|
||||
# 'fields' 키 제외한 버전 생성
|
||||
result_filtered = {k: v for k, v in result.items() if k != "fields"}
|
||||
results_redis.append(result_filtered)
|
||||
|
||||
# ✅ 결과 Redis 저장 (요약본)
|
||||
redis_key = f"pipeline_result:{result_id}"
|
||||
redis_client.set(
|
||||
redis_key,
|
||||
json.dumps(results_redis, ensure_ascii=False),
|
||||
ex=60 * 60,
|
||||
)
|
||||
logger.info(f"[REDIS] 결과 Redis 저장 완료: {result_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[PIPELINE] ❌ result_id={result_id} 처리 실패: {e}")
|
||||
redis_client.set(
|
||||
f"pipeline_result:{result_id}",
|
||||
json.dumps({"error": str(e)}),
|
||||
ex=60 * 60,
|
||||
)
|
||||
|
||||
# ✅ 결과 MinIO 저장 (전체본)
|
||||
try:
|
||||
minio_key = f"{request_id}/{input_file.filename.rsplit('.', 1)[0]}.json"
|
||||
presigned_url = save_result_to_minio(
|
||||
result_dict=results_minio,
|
||||
object_name=minio_key,
|
||||
)
|
||||
logger.info(f"[MinIO] 결과 MinIO 저장 완료: {presigned_url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MinIO] 결과 저장 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="결과 파일 저장 중 오류가 발생했습니다.",
|
||||
)
|
||||
69
workspace/services/model_service.py
Normal file
69
workspace/services/model_service.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
import httpx
|
||||
from config.setting import OLLAMA_URL
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelInfoService:
|
||||
OUTER_MODELS = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_ollama_model_map() -> Dict[str, str]:
|
||||
model_url_map = {}
|
||||
for url in OLLAMA_URL:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
tags_url = url.replace("/api/generate", "/api/tags")
|
||||
res = await client.get(tags_url)
|
||||
res.raise_for_status()
|
||||
models = res.json().get("models", [])
|
||||
for m in models:
|
||||
model_url_map[m["name"]] = url
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] {url} 모델 조회 실패: {e}")
|
||||
return model_url_map
|
||||
|
||||
@staticmethod
|
||||
async def get_model_info() -> JSONResponse:
|
||||
inner_models = []
|
||||
|
||||
for url in OLLAMA_URL:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
tags_url = url.replace("/generate", "/tags")
|
||||
res = await client.get(tags_url)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
models = [m["name"] for m in data.get("models", [])]
|
||||
inner_models.extend(models)
|
||||
except Exception as e:
|
||||
logger.error(f"[API-INFO-ERROR] Ollama 모델 조회 실패 ({url}): {e}")
|
||||
|
||||
inner_models = list(set(inner_models))
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"models": {
|
||||
"inner_model": {
|
||||
"default_model": "gpt-oss:20b", # gemma3:27b
|
||||
"model_list": inner_models,
|
||||
},
|
||||
"outer_model": {
|
||||
"default_model": "gpt-4.1",
|
||||
"model_list": ModelInfoService.OUTER_MODELS,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
292
workspace/services/pipeline_runner.py
Normal file
292
workspace/services/pipeline_runner.py
Normal file
@@ -0,0 +1,292 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
import httpx
|
||||
import redis
|
||||
from config.setting import OCR_API_URL, OCR_REDIS_DB, OCR_REDIS_HOST, OCR_REDIS_PORT
|
||||
from utils.checking_files import token_counter
|
||||
from utils.image_converter import prepare_images_from_file
|
||||
from utils.logging_utils import log_pipeline_status, log_user_request
|
||||
from utils.text_generator import (
|
||||
ClaudeGenerator,
|
||||
GeminiGenerator,
|
||||
GptGenerator,
|
||||
OllamaGenerator,
|
||||
)
|
||||
from utils.text_processor import post_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis 클라이언트 생성 (Celery 결과용 DB=1)
|
||||
redis_client = redis.Redis(
|
||||
host=OCR_REDIS_HOST,
|
||||
port=OCR_REDIS_PORT,
|
||||
db=OCR_REDIS_DB,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
|
||||
class PipelineRunner:
|
||||
@staticmethod
|
||||
async def run_pipeline(
|
||||
request_info: str, # ✅ 추가
|
||||
request_id: str,
|
||||
file_path: str,
|
||||
filename: str,
|
||||
prompt: str,
|
||||
prompt_filename: str, # ✅ 추가
|
||||
custom_mode: bool,
|
||||
mode: str,
|
||||
model: str,
|
||||
inner_models: List[str],
|
||||
outer_models: List[str],
|
||||
model_url_map: Dict[str, str],
|
||||
api_key: str,
|
||||
schema_override: Optional[dict] = None,
|
||||
prompt_mode: Literal["general", "extract"] = "extract",
|
||||
):
|
||||
start_time = time.time()
|
||||
|
||||
if mode == "multimodal":
|
||||
# 모델 유효성
|
||||
if model not in outer_models:
|
||||
raise ValueError(
|
||||
f"외부 모델 리스트에 '{model}'이 포함되어 있지 않습니다. outer_models: {outer_models}"
|
||||
)
|
||||
if not (("gpt" in model) or ("gemini" in model)):
|
||||
raise ValueError("멀티모달 E2E는 gpt 계열만 지원합니다.")
|
||||
|
||||
# 입력 파일 → 이미지 바이트 리스트 준비
|
||||
images = await prepare_images_from_file(file_path, filename)
|
||||
|
||||
# 요청 로깅(텍스트가 없으므로 prompt 길이만)
|
||||
context_length = len(prompt)
|
||||
try:
|
||||
log_user_request(
|
||||
request_info=request_info,
|
||||
endpoint=f"/{prompt_mode}/{mode}",
|
||||
input_filename=filename,
|
||||
model=model,
|
||||
prompt_filename=prompt_filename,
|
||||
context_length=context_length,
|
||||
api_key=api_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to log '/{prompt_mode}/{mode}' request: {e}")
|
||||
|
||||
# 멀티모달 LLM 호출
|
||||
log_pipeline_status(request_id, "멀티모달 LLM 추론 시작")
|
||||
if "gpt" in model:
|
||||
generator = GptGenerator(model=model)
|
||||
generated_text, llm_model, llm_url = await asyncio.to_thread(
|
||||
generator.generate_multimodal, images, prompt, schema_override
|
||||
)
|
||||
elif "gemini" in model:
|
||||
generator = GeminiGenerator(model=model)
|
||||
generated_text, llm_model, llm_url = await asyncio.to_thread(
|
||||
generator.generate_multimodal, images, prompt, schema_override
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
log_pipeline_status(request_id, "LLM 추론 완료 및 후처리 시작")
|
||||
|
||||
# 멀티모달은 OCR 텍스트/좌표 없음
|
||||
text = ""
|
||||
coord = None
|
||||
ocr_model = "bypass(multimodal)"
|
||||
|
||||
json_data = post_process(
|
||||
filename,
|
||||
text,
|
||||
generated_text,
|
||||
coord,
|
||||
ocr_model,
|
||||
llm_model,
|
||||
llm_url,
|
||||
mode,
|
||||
start_time,
|
||||
end_time,
|
||||
prompt_mode,
|
||||
)
|
||||
log_pipeline_status(request_id, "후처리 완료 및 결과 반환")
|
||||
return json_data
|
||||
|
||||
try:
|
||||
# OCR API 요청
|
||||
log_pipeline_status(request_id, "OCR API 호출 시작")
|
||||
async with httpx.AsyncClient() as client:
|
||||
# ✅ presigned URL을 OCR API로 전달
|
||||
ocr_resp = await client.post(
|
||||
OCR_API_URL,
|
||||
json=[
|
||||
{
|
||||
"file_url": file_path, # presigned URL
|
||||
"filename": filename,
|
||||
}
|
||||
],
|
||||
timeout=None,
|
||||
)
|
||||
ocr_resp.raise_for_status()
|
||||
|
||||
# OCR API 응답에서 task_id 추출
|
||||
task_ids_json = ocr_resp.json()
|
||||
print(f"[DEBUG] OCR API 응답: {task_ids_json}")
|
||||
task_ids = [
|
||||
item.get("task_id") for item in task_ids_json.get("results", [])
|
||||
]
|
||||
if not task_ids:
|
||||
raise ValueError("❌ OCR API에서 유효한 task_id를 받지 못했습니다.")
|
||||
task_id = task_ids[0]
|
||||
|
||||
# Redis에서 결과를 5초 간격으로 최대 10회 폴링
|
||||
raw_result = None
|
||||
for attempt in range(10): # 최대 10회 시도
|
||||
redis_key = f"ocr_result:{task_id}"
|
||||
raw_result = redis_client.get(redis_key)
|
||||
if raw_result:
|
||||
logger.info(
|
||||
f"✅ Redis에서 task_id '{task_id}'에 대한 OCR 결과를 찾았습니다."
|
||||
)
|
||||
break
|
||||
await asyncio.sleep(5)
|
||||
|
||||
if not raw_result: # 결과가 없으면 예외 발생
|
||||
error_message = (
|
||||
"❌ OCR API에서 작업을 완료하지 못했습니다. 페이지 수를 줄여주세요."
|
||||
)
|
||||
logger.error(error_message)
|
||||
raise ValueError(error_message)
|
||||
|
||||
result_data = json.loads(raw_result)
|
||||
text = result_data["parsed"]
|
||||
coord = result_data.get("fields")
|
||||
ocr_model = result_data.get("ocr_model", "OCR API(pytesseract)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ OCR 처리 중 예외 발생: {e}")
|
||||
raise
|
||||
|
||||
# ✅ 입력 길이 검사
|
||||
log_pipeline_status(request_id, "모델 입력 텍스트 길이 검사 시작")
|
||||
token_count = token_counter(prompt, text)
|
||||
context_length = len(prompt + text)
|
||||
|
||||
# 🔽 로그 기록
|
||||
try:
|
||||
log_user_request(
|
||||
request_info=request_info,
|
||||
endpoint=f"/{prompt_mode}/{mode}",
|
||||
input_filename=filename,
|
||||
model=model,
|
||||
prompt_filename=prompt_filename,
|
||||
context_length=context_length,
|
||||
api_key=api_key,
|
||||
# token_count=token_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to log '/{prompt_mode}/{mode}' request: {e}")
|
||||
|
||||
# ✅ 120K 토큰 초과 검사
|
||||
if token_count > 120000:
|
||||
return post_process(
|
||||
filename,
|
||||
text,
|
||||
f"⚠️ 입력 텍스트가 {token_count} 토큰으로 입력 길이를 초과했습니다. 모델 호출 생략합니다.",
|
||||
coord,
|
||||
ocr_model,
|
||||
"N/A",
|
||||
"N/A",
|
||||
mode,
|
||||
start_time,
|
||||
time.time(),
|
||||
prompt_mode,
|
||||
)
|
||||
|
||||
# 2. 내부 모델 처리 (Ollama)
|
||||
if mode in ("inner", "all", "structured"):
|
||||
if model in inner_models:
|
||||
log_pipeline_status(request_id, "내부 LLM 추론 시작")
|
||||
api_url = model_url_map.get(model)
|
||||
if not api_url:
|
||||
raise ValueError(
|
||||
f"❌ 모델 '{model}'이 로드된 Ollama 서버를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
generator = OllamaGenerator(model=model, api_url=api_url)
|
||||
|
||||
if mode == "structured":
|
||||
generated_text, llm_model, llm_url = await asyncio.to_thread(
|
||||
generator.structured_generate,
|
||||
text,
|
||||
prompt,
|
||||
custom_mode,
|
||||
schema_override,
|
||||
)
|
||||
else:
|
||||
generated_text, llm_model, llm_url = await asyncio.to_thread(
|
||||
generator.generate, text, prompt, custom_mode, prompt_mode
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"내부 모델 리스트에 '{model}'이 포함되어 있지 않습니다. inner_models: {inner_models}"
|
||||
)
|
||||
|
||||
# 3. 외부 모델 처리
|
||||
elif mode in ("outer", "all", "structured"):
|
||||
if model in outer_models:
|
||||
log_pipeline_status(request_id, "외부 LLM 추론 시작")
|
||||
if "claude" in model:
|
||||
generator = ClaudeGenerator(model=model)
|
||||
elif "gemini" in model:
|
||||
generator = GeminiGenerator(model=model)
|
||||
elif "gpt" in model:
|
||||
generator = GptGenerator(model=model)
|
||||
else:
|
||||
raise ValueError(
|
||||
"지원되지 않는 외부 모델입니다. ['gemini', 'claude', 'gpt'] 중 선택하세요."
|
||||
)
|
||||
|
||||
if mode == "structured":
|
||||
generated_text, llm_model, llm_url = await asyncio.to_thread(
|
||||
generator.structured_generate,
|
||||
text,
|
||||
prompt,
|
||||
custom_mode,
|
||||
schema_override,
|
||||
)
|
||||
else:
|
||||
generated_text, llm_model, llm_url = await asyncio.to_thread(
|
||||
generator.generate, text, prompt, custom_mode, prompt_mode
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"외부 모델 리스트에 '{model}'이 포함되어 있지 않습니다. outer_models: {outer_models}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"❌ 지원되지 않는 모드입니다. 'inner', 'outer', 'all', 'structured' 중에서 선택하세요. (입력: {mode})"
|
||||
)
|
||||
|
||||
log_pipeline_status(request_id, "LLM 추론 완료 및 후처리 시작")
|
||||
end_time = time.time()
|
||||
|
||||
# 4. 후처리
|
||||
json_data = post_process(
|
||||
filename,
|
||||
text,
|
||||
generated_text,
|
||||
coord,
|
||||
ocr_model,
|
||||
llm_model,
|
||||
llm_url,
|
||||
mode,
|
||||
start_time,
|
||||
end_time,
|
||||
prompt_mode,
|
||||
)
|
||||
|
||||
log_pipeline_status(request_id, "후처리 완료 및 결과 반환")
|
||||
return json_data
|
||||
36
workspace/services/prompt.py
Normal file
36
workspace/services/prompt.py
Normal file
@@ -0,0 +1,36 @@
|
||||
SUMMARY_PROMPT_TEMPLATE = """
|
||||
/no_think
|
||||
너는 방금 끝난 회의의 내용을 정리해서 팀원들에게 공유해야 하는 프로젝트 매니저야.
|
||||
아래의 STT 회의록 초안은 오타나 문맥 오류가 있을 수 있어. 무리하게 해석하기보다는 문맥상 가장 자연스럽고 합리적인 내용으로 정리하고, 애매한 부분은 그 불확실성을 그대로 언급해도 괜찮아.
|
||||
아래 양식 외의 내용은 절대 포함하지 마. 각 항목의 제목과 번호는 반드시 그대로 유지해.
|
||||
출력은 json으로 해
|
||||
|
||||
# 양식
|
||||
1. 회의 주요 키워드 (5개 내외로 작성)
|
||||
2. 논의된 주요 안건 목록(Action Items)
|
||||
3. 각 안건별 핵심 논의 내용 요약
|
||||
4. 최종적으로 합의된 결정 사항들
|
||||
5. 다음 회의에서 논의할 내용이나 미결 사항 (있다면 작성)
|
||||
|
||||
# 내용
|
||||
{context}
|
||||
"""
|
||||
|
||||
ONLY_GEMINI_PROMPT_TEMPLATE = """
|
||||
다음은 여러 명이 참여한 회의의 전사 기록이다. 각 발화자는 "SPEAKER_01", "SPEAKER_02" 와 같은 형식으로 구분되어 있다.
|
||||
같은 내용이지만 SPEAKER의 순서가 다를 수 도 있다.
|
||||
아래의 STT 회의록 초안은 오타나 문맥 오류가 있을 수 있어. 무리하게 해석하기보다는 문맥상 가장 자연스럽고 합리적인 내용으로 정리하고, 애매한 부분은 그 불확실성을 그대로 언급해도 괜찮아.
|
||||
각 화자의 발언을 고려하여
|
||||
아래 양식 외의 내용은 절대 포함하지 마. 각 항목의 제목과 번호는 반드시 그대로 유지해.
|
||||
출력은 json으로 해
|
||||
|
||||
# 양식
|
||||
1. 회의 주요 키워드 (5개 내외로 작성)
|
||||
2. 논의된 주요 안건 목록(Action Items)
|
||||
3. 각 안건별 핵심 논의 내용 요약
|
||||
4. 최종적으로 합의된 결정 사항들
|
||||
5. 다음 회의에서 논의할 내용이나 미결 사항 (있다면 작성)
|
||||
|
||||
# 내용
|
||||
{context}
|
||||
"""
|
||||
198
workspace/services/report.py
Normal file
198
workspace/services/report.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from anthropic import AsyncAnthropic
|
||||
from dotenv import load_dotenv
|
||||
from google.generativeai import GenerativeModel # gemini
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from services.prompt import ONLY_GEMINI_PROMPT_TEMPLATE, SUMMARY_PROMPT_TEMPLATE
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
tasks_store = {}
|
||||
ask_gpt_name = "gpt-4.1-mini"
|
||||
ask_ollama_qwen_name = "qwen3:custom"
|
||||
ask_gemini_name = "gemini-2.5-flash"
|
||||
ask_claude_name = "claude-3-7-sonnet-latest"
|
||||
|
||||
|
||||
def parse_json_safe(text: str):
|
||||
"""응답 텍스트가 JSON 포맷이 아닐 수도 있으니 안전하게 파싱 시도"""
|
||||
try:
|
||||
# 혹시 ```json ... ``` 형식 포함 시 제거
|
||||
if text.startswith("```json"):
|
||||
text = text.strip("```json").strip("```").strip()
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return {"raw_text": text}
|
||||
|
||||
|
||||
async def ask_gpt4(text: str):
|
||||
try:
|
||||
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
response = await client.chat.completions.create(
|
||||
model=ask_gpt_name,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": SUMMARY_PROMPT_TEMPLATE.format(context=text),
|
||||
}
|
||||
],
|
||||
temperature=0,
|
||||
)
|
||||
return ask_gpt_name, parse_json_safe(response.choices[0].message.content)
|
||||
except Exception as e:
|
||||
logger.error(f"ask_gpt4 error: {e}")
|
||||
return ask_gpt_name, {"error": str(e)}
|
||||
|
||||
|
||||
def fix_incomplete_json(text: str) -> str:
|
||||
open_braces = text.count("{")
|
||||
close_braces = text.count("}")
|
||||
if open_braces > close_braces:
|
||||
text += "}" * (open_braces - close_braces)
|
||||
return text
|
||||
|
||||
|
||||
async def ask_ollama_qwen(text: str):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
res = await client.post(
|
||||
"http://172.16.10.176:11434/api/generate",
|
||||
json={
|
||||
"model": "qwen3:custom",
|
||||
"prompt": SUMMARY_PROMPT_TEMPLATE.format(context=text),
|
||||
},
|
||||
timeout=300,
|
||||
)
|
||||
raw_text = res.text
|
||||
|
||||
# 1. <think> 태그 제거
|
||||
raw_text = re.sub(r"</?think>", "", raw_text)
|
||||
|
||||
# 2. 각 줄별 JSON 파싱 시도 (스트림 JSON 형식)
|
||||
json_objects = []
|
||||
for line in raw_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
json_objects.append(obj)
|
||||
except json.JSONDecodeError:
|
||||
# 무시하거나 로그 남기기
|
||||
pass
|
||||
|
||||
# 3. 여러 JSON 조각 중 'response' 필드 내용만 합치기 (필요시)
|
||||
full_response = "".join(obj.get("response", "") for obj in json_objects)
|
||||
|
||||
# 4. 합쳐진 response에서 JSON 부분만 추출
|
||||
json_match = re.search(r"\{.*\}", full_response, re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(0)
|
||||
try:
|
||||
parsed_json = json.loads(json_str)
|
||||
return "qwen3:custom", parsed_json
|
||||
except json.JSONDecodeError:
|
||||
return "qwen3:custom", {
|
||||
"error": "Invalid JSON in response",
|
||||
"raw_text": full_response,
|
||||
}
|
||||
else:
|
||||
return "qwen3:custom", {
|
||||
"error": "No JSON found in response",
|
||||
"raw_text": full_response,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return "qwen3:custom", {"error": str(e)}
|
||||
|
||||
|
||||
async def ask_gemini(text: str):
|
||||
try:
|
||||
model = GenerativeModel(model_name=ask_gemini_name)
|
||||
response = model.generate_content(SUMMARY_PROMPT_TEMPLATE.format(context=text))
|
||||
return ask_gemini_name, parse_json_safe(response.text)
|
||||
except Exception as e:
|
||||
logger.error(f"ask_gemini error: {e}")
|
||||
return ask_gemini_name, {"error": str(e)}
|
||||
|
||||
|
||||
async def dialog_ask_gemini(text: str):
|
||||
try:
|
||||
model = GenerativeModel(model_name=ask_gemini_name)
|
||||
response = model.generate_content(
|
||||
ONLY_GEMINI_PROMPT_TEMPLATE.format(context=text)
|
||||
)
|
||||
return ask_gemini_name, parse_json_safe(response.text)
|
||||
except Exception as e:
|
||||
logger.error(f"ask_gemini error: {e}")
|
||||
return ask_gemini_name, {"error": str(e)}
|
||||
|
||||
|
||||
async def ask_claude(text: str):
|
||||
try:
|
||||
client = AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
response = await client.messages.create(
|
||||
model=ask_claude_name,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": SUMMARY_PROMPT_TEMPLATE.format(context=text),
|
||||
}
|
||||
],
|
||||
max_tokens=12800,
|
||||
stream=False,
|
||||
)
|
||||
raw = response.content[0].text
|
||||
return ask_claude_name, parse_json_safe(raw)
|
||||
except Exception as e:
|
||||
logger.error(f"ask_claude error: {e}")
|
||||
return ask_claude_name, {"error": str(e)}
|
||||
|
||||
|
||||
async def total_summation(text: str) -> dict:
|
||||
tasks = [ask_gpt4(text), ask_ollama_qwen(text), ask_gemini(text), ask_claude(text)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return dict(results)
|
||||
|
||||
|
||||
async def run_model_task(model_func, text, key, task_id):
|
||||
try:
|
||||
model_name, result = await model_func(text)
|
||||
tasks_store[task_id][key] = {
|
||||
"status": "completed",
|
||||
"model_name": model_name,
|
||||
"result": result,
|
||||
}
|
||||
except Exception as e:
|
||||
tasks_store[task_id][key] = {
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
async def run_all_models(text: str, task_id: str):
|
||||
# 초기 상태 세팅
|
||||
tasks_store[task_id] = {
|
||||
"gpt4": {"status": "pending", "result": None},
|
||||
"qwen3": {"status": "pending", "result": None},
|
||||
"gemini": {"status": "pending", "result": None},
|
||||
"claude": {"status": "pending", "result": None},
|
||||
"finished": False,
|
||||
}
|
||||
|
||||
await asyncio.gather(
|
||||
run_model_task(ask_gpt4, text, "gpt4", task_id),
|
||||
run_model_task(ask_ollama_qwen, text, "qwen3", task_id),
|
||||
run_model_task(ask_gemini, text, "gemini", task_id),
|
||||
run_model_task(ask_claude, text, "claude", task_id),
|
||||
)
|
||||
|
||||
tasks_store[task_id]["finished"] = True
|
||||
Reference in New Issue
Block a user