generated from kgod/ai-review-template
123 lines
4.8 KiB
Python
123 lines
4.8 KiB
Python
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
|