feat: builder 简历生成 + 轻度优化 + 简历导入交付副本

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hyp
2026-08-05 11:08:30 +08:00
co-authored by Claude
commit ae2d9b128d
191 changed files with 27719 additions and 0 deletions
+679
View File
@@ -0,0 +1,679 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from .models import AnchorType, ComposerMode, JobType, Stage
from .validators import anchor_missing_fields, can_create_resume, mask_phone, strict_phone
COMPONENT_SLUGS = {
"PrivacyConsentCard": "privacy_consent_card",
"ResumePhoneSelector": "resume_phone_selector",
"ResumePhoneInput": "resume_phone_input",
"ResumeNameInput": "resume_name_input",
"JobTypeCards": "job_type_cards",
"AnchorTypeCards": "anchor_type_cards",
"ShortTextInput": "short_text_input",
"DegreeSelector": "degree_selector",
"DateRangeSelector": "date_range_selector",
"ChoiceChips": "choice_chips",
"ExperienceConfirmCard": "experience_confirm_card",
"CreateResumeCard": "create_resume_card",
"CreatingStatusCard": "creating_status_card",
"ContentReadyCard": "content_ready_card",
"CreateRetryCard": "create_retry_card",
"TagsInput": "tags_input",
"CompetitionFields": "competition_fields",
"AddAnother": "add_another",
"ProgressCard": "progress_card",
"AnchorFields": "anchor_fields",
"RecordFields": "record_fields",
"CustomCardPicker": "custom_card_picker",
}
ANCHOR_FIELDS: dict[str, list[str]] = {
AnchorType.EDUCATION: [
"school",
"major",
"degree",
"start_date",
"end_date_or_present",
],
AnchorType.WORK_EXPERIENCE: [
"company",
"position",
"start_date",
"end_date_or_present",
],
AnchorType.INTERNSHIP_EXPERIENCE: [
"company",
"position",
"start_date",
"end_date_or_present",
],
AnchorType.PROJECT_EXPERIENCE: [
"project_name",
"project_role",
"start_date",
"end_date_or_present",
],
"campus_experience": [
"organization",
"role",
"start_date",
"end_date_or_present",
],
}
FIELD_LABELS = {
"school": "学校名称",
"major": "专业",
"degree": "学历",
"company": "公司名称",
"position": "职位",
"project_name": "项目名称",
"project_role": "项目角色",
"organization": "组织名称",
"role": "担任角色",
"start_date": "开始时间",
"end_date_or_present": "结束时间",
"description": "经历描述",
}
DEGREE_OPTIONS = ["博士", "硕士", "本科", "大专", "高中及以下"]
ANCHOR_CARD_TITLES = {
"education": "填写教育经历",
"work_experience": "填写工作经历",
"internship_experience": "填写实习经历",
"project_experience": "填写项目经历",
"campus_experience": "填写校园经历",
}
def anchor_field_specs(anchor_type: str | None) -> list[dict[str, Any]]:
"""按经历类型生成表单卡字段元数据(RecordFields/AnchorFields 共用契约)。"""
specs: list[dict[str, Any]] = []
for field in ANCHOR_FIELDS.get(anchor_type, []):
spec: dict[str, Any] = {
"key": field,
"label": FIELD_LABELS[field],
"kind": "text",
"required": True,
}
if field == "degree":
spec["kind"] = "degree"
spec["options"] = DEGREE_OPTIONS
elif field == "start_date":
spec["kind"] = "month"
elif field == "end_date_or_present":
spec["kind"] = "month_end"
specs.append(spec)
return specs
STAGE_COMPONENTS: dict[Stage, set[str]] = {
Stage.PRIVACY_CONSENT: {"PrivacyConsentCard"},
Stage.RESUME_SOURCE_SELECT: {"ChoiceChips"},
Stage.RESUME_IMPORT_UPLOAD: set(),
Stage.PHONE_SELECTION: {"ResumePhoneSelector"},
Stage.MANUAL_PHONE_INPUT: {"ResumePhoneInput"},
Stage.PERSONAL_INFO: {"RecordFields"},
Stage.NAME_CAPTURE: {"ResumeNameInput"},
Stage.JOB_TYPE_SELECT: {"JobTypeCards"},
Stage.TARGET_POSITION: {"ChoiceChips", "RecordFields"},
Stage.TARGET_POSITION_MAJOR: {"RecordFields"},
Stage.TARGET_POSITION_RECOMMENDATION: {"ChoiceChips"},
Stage.ANCHOR_TYPE_SELECT: {"AnchorTypeCards"},
Stage.ANCHOR_COLLECTING: {"AnchorFields"},
Stage.ANCHOR_CONFIRM: {"ExperienceConfirmCard"},
Stage.MINIMUM_READY: {"CreateResumeCard"},
Stage.CONTENT_READY: {"ContentReadyCard", "ExperienceConfirmCard"},
Stage.RESUME_ENRICHING: {
"ContentReadyCard",
"ChoiceChips",
"CompetitionFields",
"TagsInput",
"AddAnother",
"ProgressCard",
"ExperienceConfirmCard",
"RecordFields",
"CustomCardPicker",
},
Stage.CREATE_FAILED: {"CreateRetryCard"},
Stage.BUILDER_CONVERSATION: {"RecordFields", "ExperienceConfirmCard", "ChoiceChips"},
}
class FSMError(Exception):
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 409,
missing_fields: list[str] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.missing_fields = missing_fields or []
@dataclass(slots=True)
class Transition:
stage: Stage
profile: dict[str, Any]
turn: dict[str, Any]
lifecycle: str = "submitted"
block_data_updates: dict[str, Any] | None = None
create_draft: bool = False
resume_content: dict[str, Any] | None = None
refresh_resume: bool = False
polish_description: bool = False
propose_anchor_optimization: bool = False
suggest_skills: bool = False
suggest_target_positions: bool = False
generate_profile_summary: bool = False
def component(name: str, **props: Any) -> dict[str, Any]:
return {
"type": "component",
"lifecycle": "active",
"data": {
"component": COMPONENT_SLUGS[name],
"component_name": name,
**props,
},
}
def text_block(text: str, *, block_type: str = "text") -> dict[str, Any]:
return {"type": block_type, "lifecycle": "active", "data": {"text": text}}
def assistant_turn(
content: str,
blocks: list[dict[str, Any]],
*,
mode: ComposerMode = ComposerMode.UI_ONLY,
) -> dict[str, Any]:
return {
"role": "assistant",
"content": content,
"composer_mode": mode,
"blocks": [text_block(content), *blocks],
}
def initial_turn() -> dict[str, Any]:
return assistant_turn(
"在开始前,请阅读并同意隐私说明。",
[component("PrivacyConsentCard", required=True)],
)
def required_fields(profile: dict[str, Any]) -> list[str]:
"""The initial Builder resume only requires verified setup information."""
return []
def missing_fields(profile: dict[str, Any]) -> list[str]:
return anchor_missing_fields(profile, required_fields(profile))
def gate_allowed(profile: dict[str, Any]) -> bool:
return can_create_resume(profile, missing_fields(profile))
def _anchor_card(profile: dict[str, Any], value: dict[str, Any] | None = None) -> dict[str, Any]:
anchor_type = profile.get("anchor_type")
return component(
"AnchorFields",
anchor_type=anchor_type,
title=ANCHOR_CARD_TITLES.get(str(anchor_type), "填写核心经历"),
fields=anchor_field_specs(anchor_type),
show_description=True,
skippable=True,
skip_label="\u6682\u65e0\u6838\u5fc3\u7ecf\u5386\uff0c\u521b\u5efa\u57fa\u7840\u7b80\u5386",
value=value,
)
def process_component_event(
*,
stage: Stage,
profile: dict[str, Any],
component_data: dict[str, Any],
action: str,
payload: dict[str, Any],
) -> Transition:
name = component_data.get("component_name")
if name not in STAGE_COMPONENTS.get(stage, set()):
raise FSMError("stale_component", "This component is not active for the current stage")
action = _canonical_action(name, action, payload)
updated = deepcopy(profile)
if stage == Stage.PRIVACY_CONSENT:
if action == "decline_privacy":
return Transition(
stage=stage,
profile=updated,
lifecycle="dismissed",
turn=assistant_turn(
"\u9700\u8981\u540c\u610f\u9690\u79c1\u8bf4\u660e\u540e\u624d\u80fd\u7ee7\u7eed\u3002",
[component("PrivacyConsentCard", required=True)],
),
)
_expect(action, "accept_privacy")
updated["privacy_accepted"] = True
return Transition(
Stage.RESUME_SOURCE_SELECT,
updated,
assistant_turn(
"\u8bf7\u9009\u62e9\u5f00\u59cb\u65b9\u5f0f\u3002",
[
component(
"ChoiceChips",
eyebrow="\u5f00\u59cb\u521b\u5efa",
title="\u9009\u62e9\u521b\u5efa\u65b9\u5f0f",
description="\u5bfc\u5165\u4f1a\u5148\u63d0\u53d6\u6587\u6863\u5185\u5bb9\uff0c\u518d\u6620\u5c04\u4e3a\u53ef\u7f16\u8f91\u7684\u7b80\u5386\u7ed3\u6784\u3002",
options=[
{"value": "import", "label": "\u5bfc\u5165\u5df2\u6709\u7b80\u5386", "description": "\u652f\u6301 PDF \u6216 DOCX"},
{"value": "manual", "label": "\u521b\u5efa\u65b0\u7b80\u5386", "description": "\u4ece\u57fa\u7840\u4fe1\u606f\u548c\u7ecf\u5386\u5f00\u59cb\u586b\u5199"},
],
)
],
),
)
if stage == Stage.RESUME_SOURCE_SELECT:
_expect(action, "select_choice")
source = str(payload.get("value") or "").strip()
if source == "import":
updated["resume_source"] = "import"
return Transition(
Stage.RESUME_IMPORT_UPLOAD,
updated,
assistant_turn("\u8bf7\u9009\u62e9\u9700\u8981\u5bfc\u5165\u7684 PDF \u6216 DOCX \u7b80\u5386\u3002", []),
)
if source == "manual":
updated["resume_source"] = "manual"
return Transition(
Stage.PHONE_SELECTION,
updated,
assistant_turn(
"\u8bf7\u9009\u62e9\u624b\u673a\u53f7\u6765\u6e90\u3002",
[
component(
"ResumePhoneSelector",
has_account_phone=bool(updated.get("account_phone")),
masked_phone=mask_phone(updated.get("account_phone")),
)
],
),
)
raise FSMError("invalid_resume_source", "Select import or manual", status_code=422)
if stage == Stage.PHONE_SELECTION:
if action == "use_other_phone":
return Transition(
Stage.MANUAL_PHONE_INPUT,
updated,
assistant_turn("请输入手机号。", [component("ResumePhoneInput")]),
)
_expect(action, "use_account_phone")
phone = updated.get("account_phone") or payload.get("phone")
if not phone:
raise FSMError("account_phone_unavailable", "No account phone is available", status_code=422)
updated["phone"] = phone
updated["phone_source"] = "account"
from .fsm_basics import personal_info_transition
return personal_info_transition(updated)
if stage == Stage.MANUAL_PHONE_INPUT:
_expect(action, "submit_manual_phone")
phone = payload.get("phone")
if not isinstance(phone, str) or not strict_phone(phone):
raise FSMError(
"invalid_phone",
"phone must match ^1[3-9]\\d{9}$",
status_code=422,
)
updated["phone"] = phone
updated["phone_source"] = "manual"
from .fsm_basics import personal_info_transition
return personal_info_transition(updated)
if stage == Stage.PERSONAL_INFO:
_expect(action, "submit")
from .fsm_basics import validate_personal_info
updated.update(validate_personal_info(payload))
updated.pop("account_phone", None)
return Transition(
Stage.JOB_TYPE_SELECT,
updated,
assistant_turn(
"请选择求职类型。",
[component("JobTypeCards", options=["campus", "social", "internship"])],
),
)
if stage == Stage.NAME_CAPTURE:
_expect(action, "submit_name")
name_value = payload.get("name")
if not isinstance(name_value, str) or not name_value.strip() or len(name_value.strip()) > 64:
raise FSMError("invalid_name", "name must contain 1 to 64 characters", status_code=422)
updated["name"] = name_value.strip()
return Transition(
Stage.JOB_TYPE_SELECT,
updated,
assistant_turn(
"请选择求职类型。",
[component("JobTypeCards", options=["campus", "social", "internship"])],
),
)
if stage == Stage.JOB_TYPE_SELECT:
_expect(action, "select_job_type")
job_type = _job_type(payload.get("job_type"))
updated["job_type"] = job_type
if job_type in {JobType.CAMPUS, JobType.INTERNSHIP}:
updated["anchor_type"] = AnchorType.EDUCATION
elif job_type == JobType.SOCIAL:
updated["anchor_type"] = AnchorType.WORK_EXPERIENCE
from .fsm_basics import target_position_transition
return target_position_transition(updated)
if stage == Stage.TARGET_POSITION:
from .fsm_basics import target_position_input_transition, target_position_major_transition, validate_target_position
if action == "skip":
updated.pop("target_position", None)
return _minimum_ready_transition(updated)
if action == "select_choice":
selected = str(payload.get("value") or "").strip()
if selected == "known":
return target_position_input_transition(updated)
if selected == "explore":
return target_position_major_transition(updated)
raise FSMError("invalid_target_position_choice", "Select known or explore", status_code=422)
_expect(action, "submit")
updated["target_position"] = validate_target_position(payload)
return _minimum_ready_transition(updated)
if stage == Stage.TARGET_POSITION_MAJOR:
from .fsm_basics import target_position_recommendation_transition
_expect(action, "submit")
major = str(payload.get("major") or "").strip()
interests = str(payload.get("interests") or "").strip()
if not major or len(major) > 80 or len(interests) > 120:
raise FSMError(
"invalid_target_position_context",
"Major is required and the supplied text is too long",
status_code=422,
missing_fields=["major"] if not major else [],
)
updated["target_position_major"] = major
updated["target_position_interests"] = interests or None
return Transition(
Stage.TARGET_POSITION_RECOMMENDATION,
updated,
target_position_recommendation_transition(updated).turn,
suggest_target_positions=True,
)
if stage == Stage.TARGET_POSITION_RECOMMENDATION:
from .fsm_basics import target_position_input_transition
_expect(action, "select_choice")
selected = str(payload.get("value") or "").strip()
if selected == "manual":
return target_position_input_transition(updated)
suggestions = updated.get("target_position_suggestions") or []
titles = {str(item.get("title") or "") for item in suggestions if isinstance(item, dict)}
if selected not in titles:
raise FSMError("invalid_target_position_suggestion", "Select a recommended position or enter one manually", status_code=422)
updated["target_position"] = selected
return _minimum_ready_transition(updated)
if stage == Stage.ANCHOR_TYPE_SELECT:
_expect(action, "select_anchor_type")
updated["anchor_type"] = _anchor_type(payload.get("anchor_type"))
return _minimum_ready_transition(updated)
if stage == Stage.ANCHOR_COLLECTING:
return _collect_anchor(updated, component_data, action, payload)
if stage == Stage.ANCHOR_CONFIRM:
if action == "edit_anchor":
return Transition(
Stage.ANCHOR_COLLECTING,
updated,
assistant_turn(
"请直接在卡片中修改这段经历。",
[_anchor_card(updated, value=dict(updated.get("anchor") or {}))],
),
)
_expect(action, "confirm_anchor")
missing = missing_fields(updated)
if missing:
raise FSMError("anchor_incomplete", "The first anchor is incomplete", missing_fields=missing)
updated["anchor_confirmed"] = True
return Transition(
Stage.MINIMUM_READY,
updated,
assistant_turn(
"首段经历已确认,可以创建简历。",
[component("CreateResumeCard", primary_action="create")],
),
lifecycle="confirmed",
create_draft=True,
)
if stage == Stage.CONTENT_READY:
if action == "finish_enrichment":
updated["enrichment_finished"] = True
return Transition(
Stage.CONTENT_READY,
updated,
assistant_turn("简历内容已保存。", [], mode=ComposerMode.UI_ONLY),
lifecycle="confirmed",
generate_profile_summary=True,
)
_expect(action, "continue_enriching")
if updated.get("imported_resume"):
from .enrichment_custom import custom_card_picker_transition
return custom_card_picker_transition(updated)
from .fsm_enrichment import begin_enrichment
return begin_enrichment(updated)
if stage == Stage.RESUME_ENRICHING:
if action == "finish_enrichment":
updated["enrichment_finished"] = True
return Transition(
Stage.CONTENT_READY,
updated,
assistant_turn(
"补充完成,简历已更新。",
[component("ContentReadyCard", can_continue=True)],
),
lifecycle="confirmed",
generate_profile_summary=True,
)
if action == "continue_enriching":
if updated.get("imported_resume"):
from .enrichment_custom import custom_card_picker_transition
return custom_card_picker_transition(updated)
from .fsm_enrichment import begin_enrichment
return begin_enrichment(updated)
from .enrichment_collectors import process_module_event
return process_module_event(updated, component_data, action, payload)
raise FSMError("invalid_transition", f"No component event is allowed in {stage}")
def _collect_anchor(
profile: dict[str, Any],
component_data: dict[str, Any],
action: str,
payload: dict[str, Any],
) -> Transition:
profile.pop("anchor_confirmed", None)
profile.pop("anchor_proposal", None)
if action == "skip":
profile["core_experience_skipped"] = True
profile.pop("anchor_type", None)
profile.pop("anchor", None)
return Transition(
Stage.MINIMUM_READY,
profile,
assistant_turn(
"\u5df2\u8df3\u8fc7\u6838\u5fc3\u7ecf\u5386\uff0c\u53ef\u4ee5\u5148\u521b\u5efa\u57fa\u7840\u7b80\u5386\uff0c\u4e4b\u540e\u4ecd\u53ef\u5728\u9884\u89c8\u4e2d\u7ee7\u7eed\u8865\u5145\u3002",
[component("CreateResumeCard", primary_action="create")],
),
lifecycle="dismissed",
create_draft=True,
)
_expect(action, "submit")
profile.pop("core_experience_skipped", None)
required = required_fields(profile)
anchor = {field: str(payload.get(field) or "").strip() for field in required}
description = str(payload.get("description") or "").strip()
if description:
anchor["description"] = description
profile["anchor"] = anchor
missing = anchor_missing_fields(profile, required)
if missing:
raise FSMError(
"invalid_anchor",
"核心字段缺失或格式有误(时间需为 YYYY-MM,结束不早于开始)",
status_code=422,
missing_fields=missing,
)
return Transition(
Stage.ANCHOR_CONFIRM,
profile,
assistant_turn(
"请确认这段经历。",
[
component(
"ExperienceConfirmCard",
anchor_type=profile["anchor_type"],
value=anchor,
labels=FIELD_LABELS,
)
],
),
propose_anchor_optimization=True,
)
def _minimum_ready_transition(profile: dict[str, Any]) -> Transition:
profile.pop("anchor", None)
profile.pop("anchor_confirmed", None)
profile.pop("anchor_proposal", None)
profile.pop("core_experience_skipped", None)
return Transition(
Stage.MINIMUM_READY,
profile,
assistant_turn(
"基础信息已经准备好。先生成简历,随后我会按你的求职方向建议优先补充的经历。",
[component("CreateResumeCard", primary_action="create")],
),
create_draft=True,
)
def _begin_anchor(profile: dict[str, Any]) -> Transition:
profile["anchor"] = {}
prompt = {
AnchorType.EDUCATION: "请填写当前或最高的一段教育经历,包括学校、专业、学历和就读时间。",
AnchorType.WORK_EXPERIENCE: "请填写一段最近或最有代表性的工作,包括公司、职位和任职时间。",
AnchorType.INTERNSHIP_EXPERIENCE: "请填写一段实习经历,包括公司、职位和实习时间。",
AnchorType.PROJECT_EXPERIENCE: "请填写一个代表性项目,包括项目名、你的角色和项目时间。",
}.get(profile.get("anchor_type"), "请填写一段最能代表你的经历。")
return Transition(
Stage.ANCHOR_COLLECTING,
profile,
assistant_turn(prompt, [_anchor_card(profile)], mode=ComposerMode.UI_ONLY),
)
def _anchor_type_transition(profile: dict[str, Any]) -> Transition:
return Transition(
Stage.ANCHOR_TYPE_SELECT,
profile,
assistant_turn(
"请选择最能代表你的首段经历。",
[
component(
"AnchorTypeCards",
options=[item.value for item in AnchorType],
)
],
),
)
def _canonical_action(name: str, action: str, payload: dict[str, Any]) -> str:
action = action.lower().strip()
if action == "consent":
return "accept_privacy" if payload.get("accepted", True) else "decline_privacy"
if action == "accept":
return "accept_privacy" if payload.get("accepted", True) else "decline_privacy"
if action == "confirm":
return "confirm_anchor" if payload.get("confirmed", True) else "edit_anchor"
if action == "edit":
return "edit_anchor"
if action == "select":
if name == "ResumePhoneSelector":
source = payload.get("source") or payload.get("value")
return "use_other_phone" if source in {"other", "manual"} else "use_account_phone"
if name == "JobTypeCards":
return "select_job_type"
if name == "AnchorTypeCards":
return "select_anchor_type"
return "select_choice"
if action == "submit":
return {
"ResumePhoneInput": "submit_manual_phone",
"ResumeNameInput": "submit_name",
"ShortTextInput": "submit_field",
"DegreeSelector": "select_choice",
"DateRangeSelector": "submit_date_range",
}.get(name, action)
return action
def _expect(actual: str, expected: str) -> None:
if actual != expected:
raise FSMError("invalid_event", f"Expected event '{expected}', got '{actual}'", status_code=422)
def _job_type(value: Any) -> JobType:
aliases = {"experienced": "social", "professional": "social", "student": "campus"}
try:
return JobType(aliases.get(str(value), str(value)))
except ValueError as exc:
raise FSMError(
"invalid_job_type",
"job_type must be campus, social, or internship",
status_code=422,
) from exc
def _anchor_type(value: Any) -> AnchorType:
try:
return AnchorType(str(value))
except ValueError as exc:
choices = ", ".join(item.value for item in AnchorType)
raise FSMError("invalid_anchor_type", f"anchor_type must be one of: {choices}", status_code=422) from exc