Files
resume-agent/backend/app/settings.py
T
hypandClaude ae2d9b128d feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本:
- Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强)
- 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护
- 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏
- PostgreSQL 运行时 + Alembic 迁移链

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 11:08:30 +08:00

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