09883d0358
- migrations 138~142: source TEXT DEFAULT 'document_server' + eval_case_id TEXT
추가, 인덱스 2개, backfill, 1주 관찰 후 NOT NULL (140 적용 분리)
- app/models/ask_event.py: source / eval_case_id ORM 필드 (138~141 단계 nullable)
- app/services/search_telemetry.py: record_ask_event 시그니처에 source / eval_case_id
- app/core/config.py: settings.eval_runner_token + EVAL_RUNNER_TOKEN env 로드
- app/api/search.py:
- X-Source / X-Eval-Case-Id / X-Eval-Token 헤더 수신
- _resolve_eval_identity(): hmac.compare_digest 로 token 검증, 실패 시 source
'document_server' 강등 + warning log + eval_case_id=None
- 두 record_ask_event 호출에 검증된 source/eval_case_id 전달
- credentials.env.example: EVAL_RUNNER_TOKEN= (empty default = 모든 eval claim 거부)
- tests/test_ask_eval_auth.py: 9 케이스 — token 없음/틀림/일치, env 미설정,
case_id only, non-eval source forces case_id None
trust boundary: 일반 client 의 X-Source=eval / X-Eval-Case-Id 시도는 무시되어
calibration telemetry 오염 불가. eval runner 만 EVAL_RUNNER_TOKEN 으로 인증.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
4.5 KiB
Python
145 lines
4.5 KiB
Python
"""설정 로딩 — config.yaml + credentials.env"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class UploadConfig(BaseModel):
|
|
max_bytes: int = 100_000_000
|
|
content_length_slack_ratio: float = 1.05
|
|
stream_chunk_bytes: int = 1_048_576
|
|
|
|
|
|
class AIModelConfig(BaseModel):
|
|
endpoint: str
|
|
model: str
|
|
max_tokens: int = 4096
|
|
timeout: int = 60
|
|
daily_budget_usd: float | None = None
|
|
require_explicit_trigger: bool = False
|
|
|
|
|
|
class AIConfig(BaseModel):
|
|
gateway_endpoint: str
|
|
primary: AIModelConfig
|
|
fallback: AIModelConfig
|
|
premium: AIModelConfig
|
|
embedding: AIModelConfig
|
|
vision: AIModelConfig
|
|
rerank: AIModelConfig
|
|
# Phase 3.5a: exaone classifier (optional — 없으면 score-only gate)
|
|
classifier: AIModelConfig | None = None
|
|
# Phase 3.5b: exaone verifier (optional — 없으면 grounding-only)
|
|
verifier: AIModelConfig | None = None
|
|
|
|
|
|
class Settings(BaseModel):
|
|
# DB
|
|
database_url: str = ""
|
|
|
|
# AI
|
|
ai: AIConfig | None = None
|
|
|
|
# NAS
|
|
nas_mount_path: str = "/documents"
|
|
nas_pkm_root: str = "/documents/PKM"
|
|
|
|
# 인증
|
|
jwt_secret: str = ""
|
|
totp_secret: str = ""
|
|
|
|
# Phase 3.5: eval runner shared secret — X-Source=eval / X-Eval-Case-Id 헤더 신뢰 검증.
|
|
# 비어있으면 모든 eval 헤더 거부 (부재 = 비활성).
|
|
eval_runner_token: str = ""
|
|
|
|
# kordoc
|
|
kordoc_endpoint: str = "http://kordoc-service:3100"
|
|
|
|
# OCR (Surya)
|
|
ocr_endpoint: str = "http://ocr-service:3200"
|
|
|
|
# 분류 체계
|
|
taxonomy: dict = {}
|
|
document_types: list[str] = []
|
|
|
|
# 업로드 한도 (authoritative policy)
|
|
upload: UploadConfig = UploadConfig()
|
|
|
|
|
|
def load_settings() -> Settings:
|
|
"""config.yaml + 환경변수에서 설정 로딩"""
|
|
# 환경변수 (docker-compose에서 주입)
|
|
database_url = os.getenv("DATABASE_URL", "")
|
|
jwt_secret = os.getenv("JWT_SECRET", "")
|
|
totp_secret = os.getenv("TOTP_SECRET", "")
|
|
eval_runner_token = os.getenv("EVAL_RUNNER_TOKEN", "")
|
|
kordoc_endpoint = os.getenv("KORDOC_ENDPOINT", "http://kordoc-service:3100")
|
|
ocr_endpoint = os.getenv("OCR_ENDPOINT", "http://ocr-service:3200")
|
|
|
|
# config.yaml — Docker 컨테이너 내부(/app/config.yaml) 또는 프로젝트 루트
|
|
config_path = Path("/app/config.yaml")
|
|
if not config_path.exists():
|
|
config_path = Path(__file__).parent.parent.parent / "config.yaml"
|
|
ai_config = None
|
|
nas_mount = "/documents"
|
|
nas_pkm = "/documents/PKM"
|
|
|
|
if config_path.exists():
|
|
with open(config_path) as f:
|
|
raw = yaml.safe_load(f)
|
|
|
|
if "ai" in raw:
|
|
ai_raw = raw["ai"]
|
|
ai_config = AIConfig(
|
|
gateway_endpoint=ai_raw.get("gateway", {}).get("endpoint", ""),
|
|
primary=AIModelConfig(**ai_raw["models"]["primary"]),
|
|
fallback=AIModelConfig(**ai_raw["models"]["fallback"]),
|
|
premium=AIModelConfig(**ai_raw["models"]["premium"]),
|
|
embedding=AIModelConfig(**ai_raw["models"]["embedding"]),
|
|
vision=AIModelConfig(**ai_raw["models"]["vision"]),
|
|
rerank=AIModelConfig(**ai_raw["models"]["rerank"]),
|
|
classifier=(
|
|
AIModelConfig(**ai_raw["models"]["classifier"])
|
|
if "classifier" in ai_raw.get("models", {})
|
|
else None
|
|
),
|
|
verifier=(
|
|
AIModelConfig(**ai_raw["models"]["verifier"])
|
|
if "verifier" in ai_raw.get("models", {})
|
|
else None
|
|
),
|
|
)
|
|
|
|
if "nas" in raw:
|
|
nas_mount = raw["nas"].get("mount_path", nas_mount)
|
|
nas_pkm = raw["nas"].get("pkm_root", nas_pkm)
|
|
|
|
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
|
|
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
|
|
upload_cfg = (
|
|
UploadConfig(**raw["upload"])
|
|
if config_path.exists() and raw and "upload" in raw
|
|
else UploadConfig()
|
|
)
|
|
|
|
return Settings(
|
|
database_url=database_url,
|
|
ai=ai_config,
|
|
nas_mount_path=nas_mount,
|
|
nas_pkm_root=nas_pkm,
|
|
jwt_secret=jwt_secret,
|
|
totp_secret=totp_secret,
|
|
eval_runner_token=eval_runner_token,
|
|
kordoc_endpoint=kordoc_endpoint,
|
|
ocr_endpoint=ocr_endpoint,
|
|
taxonomy=taxonomy,
|
|
document_types=document_types,
|
|
upload=upload_cfg,
|
|
)
|
|
|
|
|
|
settings = load_settings()
|