원 레포랑 완전 분리

This commit is contained in:
ai-cell-a100-1
2025-08-11 18:56:38 +09:00
commit 7217d3cbaa
86 changed files with 6631 additions and 0 deletions

View File

View 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

View 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"
}

View 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}"}
)

View 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="결과 파일 저장 중 오류가 발생했습니다.",
)

View 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,
},
}
}
)

View 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

View 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}
"""

View 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