Files
resume-agent/backend/app/profile_summary.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

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