generated from kgod/ai-review-template
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
198 lines
7.7 KiB
Python
198 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
|
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_ENV_FILE = BACKEND_ROOT / ".env"
|
|
|
|
|
|
def _as_bool(value: str | None, default: bool) -> bool:
|
|
if value is None:
|
|
return default
|
|
normalized = value.strip().lower()
|
|
if normalized in {"1", "true", "yes", "on"}:
|
|
return True
|
|
if normalized in {"0", "false", "no", "off"}:
|
|
return False
|
|
raise ValueError(f"Invalid boolean configuration value: {value!r}")
|
|
|
|
|
|
def _as_int(name: str, value: str | None, default: int) -> int:
|
|
if value is None:
|
|
return default
|
|
parsed = int(value)
|
|
if parsed < 0:
|
|
raise ValueError(f"{name} must be non-negative")
|
|
return parsed
|
|
|
|
|
|
def _as_float(name: str, value: str | None, default: float) -> float:
|
|
if value is None:
|
|
return default
|
|
parsed = float(value)
|
|
if parsed <= 0:
|
|
raise ValueError(f"{name} must be positive")
|
|
return parsed
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Settings:
|
|
"""Runtime settings with the API key deliberately hidden from repr output."""
|
|
|
|
llm_provider: str = "auto"
|
|
openai_api_key: str | None = field(default=None, repr=False)
|
|
openai_base_url: str | None = None
|
|
openai_model: str = "gpt-4o-mini"
|
|
embedding_provider: str = "tei"
|
|
embedding_base_url: str = "http://127.0.0.1:8081"
|
|
embedding_model: str = "BAAI/bge-m3"
|
|
embedding_dimensions: int = 1024
|
|
embedding_timeout_seconds: float = 30.0
|
|
embedding_batch_size: int = 32
|
|
openai_timeout_seconds: float = 30.0
|
|
openai_max_retries: int = 2
|
|
light_opt_rate_limit: int = 20
|
|
light_opt_rate_window_seconds: float = 3600.0
|
|
structured_output_retries: int = 1
|
|
structured_output_mode: str = "json_schema"
|
|
fallback_to_rules: bool = True
|
|
intent_router_mode: str = "off"
|
|
intent_model: str | None = None
|
|
knowledge_admin_token: str | None = field(default=None, repr=False)
|
|
database_url: str | None = field(default=None, repr=False)
|
|
deep_max_questions: int = 6
|
|
deep_min_questions: int = 2
|
|
deep_gap_threshold: float = 5.0
|
|
|
|
@property
|
|
def use_openai(self) -> bool:
|
|
if self.llm_provider == "openai":
|
|
if not self.openai_api_key:
|
|
raise ValueError("OPENAI_API_KEY is required when LLM provider is openai")
|
|
return True
|
|
if self.llm_provider == "volcengine":
|
|
if not self.openai_api_key:
|
|
raise ValueError(
|
|
"VOLCENGINE_API_KEY is required when LLM provider is volcengine"
|
|
)
|
|
return True
|
|
if self.llm_provider == "rule":
|
|
return False
|
|
if self.llm_provider != "auto":
|
|
raise ValueError(
|
|
"RESUME_AGENT_LLM_PROVIDER must be auto, openai, volcengine, or rule"
|
|
)
|
|
return bool(self.openai_api_key)
|
|
|
|
|
|
def load_settings(env_file: str | Path | None = None) -> Settings:
|
|
selected_file = Path(
|
|
env_file or os.getenv("RESUME_AGENT_ENV_FILE", str(DEFAULT_ENV_FILE))
|
|
)
|
|
load_dotenv(selected_file, override=False)
|
|
mode = os.getenv("OPENAI_STRUCTURED_OUTPUT_MODE", "json_schema").strip().lower()
|
|
if mode not in {"json_schema", "json_object"}:
|
|
raise ValueError(
|
|
"OPENAI_STRUCTURED_OUTPUT_MODE must be json_schema or json_object"
|
|
)
|
|
provider = os.getenv("RESUME_AGENT_LLM_PROVIDER", "auto").strip().lower()
|
|
if provider == "volcengine":
|
|
llm_api_key = os.getenv("VOLCENGINE_API_KEY") or None
|
|
llm_base_url = os.getenv("VOLCENGINE_BASE_URL") or None
|
|
llm_model = os.getenv("VOLCENGINE_MODEL", "").strip()
|
|
else:
|
|
llm_api_key = os.getenv("OPENAI_API_KEY") or None
|
|
llm_base_url = os.getenv("OPENAI_BASE_URL") or None
|
|
llm_model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip()
|
|
intent_mode = os.getenv("RESUME_AGENT_INTENT_ROUTER_MODE", "off").strip().lower()
|
|
if intent_mode not in {"off", "shadow", "on"}:
|
|
raise ValueError("RESUME_AGENT_INTENT_ROUTER_MODE must be off, shadow, or on")
|
|
settings = Settings(
|
|
llm_provider=provider,
|
|
openai_api_key=llm_api_key,
|
|
openai_base_url=llm_base_url,
|
|
openai_model=llm_model,
|
|
embedding_provider=os.getenv("EMBEDDING_PROVIDER", "tei").strip().lower(),
|
|
embedding_base_url=os.getenv("EMBEDDING_BASE_URL", "http://127.0.0.1:8081").rstrip("/"),
|
|
embedding_model=os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3").strip(),
|
|
embedding_dimensions=_as_int(
|
|
"EMBEDDING_DIMENSIONS", os.getenv("EMBEDDING_DIMENSIONS"), 1024
|
|
),
|
|
embedding_timeout_seconds=_as_float(
|
|
"EMBEDDING_TIMEOUT_SECONDS", os.getenv("EMBEDDING_TIMEOUT_SECONDS"), 30.0
|
|
),
|
|
embedding_batch_size=_as_int(
|
|
"EMBEDDING_BATCH_SIZE", os.getenv("EMBEDDING_BATCH_SIZE"), 32
|
|
),
|
|
openai_timeout_seconds=_as_float(
|
|
"OPENAI_TIMEOUT_SECONDS", os.getenv("OPENAI_TIMEOUT_SECONDS"), 30.0
|
|
),
|
|
openai_max_retries=_as_int(
|
|
"OPENAI_MAX_RETRIES", os.getenv("OPENAI_MAX_RETRIES"), 2
|
|
),
|
|
light_opt_rate_limit=_as_int(
|
|
"RESUME_AGENT_LIGHT_OPT_RATE_LIMIT",
|
|
os.getenv("RESUME_AGENT_LIGHT_OPT_RATE_LIMIT"),
|
|
20,
|
|
),
|
|
light_opt_rate_window_seconds=_as_float(
|
|
"RESUME_AGENT_LIGHT_OPT_RATE_WINDOW_SECONDS",
|
|
os.getenv("RESUME_AGENT_LIGHT_OPT_RATE_WINDOW_SECONDS"),
|
|
3600.0,
|
|
),
|
|
structured_output_retries=_as_int(
|
|
"OPENAI_STRUCTURED_OUTPUT_RETRIES",
|
|
os.getenv("OPENAI_STRUCTURED_OUTPUT_RETRIES"),
|
|
1,
|
|
),
|
|
structured_output_mode=mode,
|
|
fallback_to_rules=_as_bool(
|
|
os.getenv("RESUME_AGENT_LLM_FALLBACK_TO_RULES"), True
|
|
),
|
|
intent_router_mode=intent_mode,
|
|
intent_model=os.getenv("RESUME_AGENT_INTENT_MODEL", "").strip() or None,
|
|
knowledge_admin_token=os.getenv("KNOWLEDGE_ADMIN_TOKEN") or None,
|
|
database_url=os.getenv("DATABASE_URL") or None,
|
|
deep_max_questions=_as_int(
|
|
"RESUME_AGENT_DEEP_MAX_QUESTIONS",
|
|
os.getenv("RESUME_AGENT_DEEP_MAX_QUESTIONS"),
|
|
6,
|
|
),
|
|
deep_min_questions=_as_int(
|
|
"RESUME_AGENT_DEEP_MIN_QUESTIONS",
|
|
os.getenv("RESUME_AGENT_DEEP_MIN_QUESTIONS"),
|
|
2,
|
|
),
|
|
deep_gap_threshold=_as_float(
|
|
"RESUME_AGENT_DEEP_GAP_THRESHOLD",
|
|
os.getenv("RESUME_AGENT_DEEP_GAP_THRESHOLD"),
|
|
5.0,
|
|
),
|
|
)
|
|
if settings.embedding_provider not in {"tei", "openai", "hash"}:
|
|
raise ValueError("EMBEDDING_PROVIDER must be tei, openai, or hash")
|
|
if not settings.embedding_model:
|
|
raise ValueError("EMBEDDING_MODEL cannot be blank")
|
|
if settings.embedding_provider == "tei" and not settings.embedding_base_url:
|
|
raise ValueError("EMBEDDING_BASE_URL cannot be blank when EMBEDDING_PROVIDER is tei")
|
|
if settings.embedding_dimensions < 1:
|
|
raise ValueError("EMBEDDING_DIMENSIONS must be positive")
|
|
if settings.embedding_batch_size < 1:
|
|
raise ValueError("EMBEDDING_BATCH_SIZE must be positive")
|
|
if settings.deep_max_questions < settings.deep_min_questions:
|
|
raise ValueError(
|
|
"RESUME_AGENT_DEEP_MAX_QUESTIONS must be at least "
|
|
"RESUME_AGENT_DEEP_MIN_QUESTIONS"
|
|
)
|
|
if settings.deep_max_questions < 1:
|
|
raise ValueError("RESUME_AGENT_DEEP_MAX_QUESTIONS must be positive")
|
|
if not settings.openai_model:
|
|
model_setting = "VOLCENGINE_MODEL" if provider == "volcengine" else "OPENAI_MODEL"
|
|
raise ValueError(f"{model_setting} cannot be blank")
|
|
return settings
|