from __future__ import annotations from copy import deepcopy from datetime import UTC, datetime from typing import Any, Protocol from pydantic import BaseModel, ConfigDict, Field from .llm_services import OpenAICompatibleStructuredClient from .settings import Settings class ProfileSummaryOutput(BaseModel): model_config = ConfigDict(extra="forbid") content: str = Field(min_length=20, max_length=600) class ProfileSummaryGenerator(Protocol): def generate(self, content: dict[str, Any]) -> str: ... class RuleBasedProfileSummaryGenerator: """Deterministic Chinese summary for tests and configured rule fallback.""" def generate(self, content: dict[str, Any]) -> str: basics = content.get("basics") if isinstance(content.get("basics"), dict) else {} target = content.get("target") if isinstance(content.get("target"), dict) else {} position = str(target.get("position") or target.get("target_position") or "目标岗位").strip() groups = content.get("skill_groups") if isinstance(content.get("skill_groups"), list) else [] skills = [ str(skill).strip() for group in groups if isinstance(group, dict) for skill in group.get("skills") or [] if str(skill).strip() ][:5] first_entry: dict[str, Any] = {} for section in content.get("sections") or []: if isinstance(section, dict) and section.get("items"): candidate = section["items"][0] if isinstance(candidate, dict): first_entry = candidate break major = str(first_entry.get("major") or basics.get("major") or "").strip() focus = str( first_entry.get("company") or first_entry.get("project_name") or first_entry.get("school") or "相关实践" ).strip() skill_text = "、".join(dict.fromkeys(skills)) or "相关技术与实践能力" major_text = f",具备{major}相关学习背景" if major else "" return f"面向{position}{major_text},具备{skill_text}等能力,拥有{focus}相关经历,能够结合已完成的项目与实践持续提升岗位匹配度。" class OpenAIProfileSummaryGenerator: def __init__(self, completion: OpenAICompatibleStructuredClient) -> None: self.completion = completion def generate(self, content: dict[str, Any]) -> str: output: ProfileSummaryOutput = self.completion.complete( schema=ProfileSummaryOutput, schema_name="profile_summary", system_prompt=( "你是中文简历个人总结撰写助手。仅返回 JSON。根据用户已确认的简历内容," "写一段 80 到 180 字、适合置于中文简历开头的个人总结。" "只概括目标岗位、教育/经历、项目和技能中的已有事实;不得包含手机、邮箱等隐私信息," "不得编造公司、学校、项目、学历、奖项、证书或量化数字。" "内容应自然连贯,不使用标题、列表、Markdown 或解释。" ), payload={"resume": _summary_source(content)}, ) return _validate_summary(output.content) class FallbackProfileSummaryGenerator: def __init__(self, primary: ProfileSummaryGenerator, fallback: ProfileSummaryGenerator) -> None: self.primary = primary self.fallback = fallback def generate(self, content: dict[str, Any]) -> str: try: return self.primary.generate(content) except Exception: return self.fallback.generate(content) def build_profile_summary_generator( settings: Settings, client: Any | None = None ) -> ProfileSummaryGenerator: rules = RuleBasedProfileSummaryGenerator() if not settings.use_openai: return rules primary = OpenAIProfileSummaryGenerator(OpenAICompatibleStructuredClient(settings, client)) return FallbackProfileSummaryGenerator(primary, rules) if settings.fallback_to_rules else primary def generated_summary(content: str) -> dict[str, Any]: return { "content": _validate_summary(content), "source": "ai_generated", "generated_at": datetime.now(UTC).isoformat(), "stale": False, } def _validate_summary(value: str) -> str: clean = " ".join(str(value or "").split()) if not 20 <= len(clean) <= 600: raise ValueError("profile_summary_invalid") return clean def _summary_source(content: dict[str, Any]) -> dict[str, Any]: result = deepcopy(content) basics = result.get("basics") if isinstance(basics, dict): for field in ("phone", "email", "masked_phone"): basics.pop(field, None) result.pop("profile_summary", None) return result