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

192 lines
9.4 KiB
Python

"""Component-event handling for Builder cards (RecordFields, ChoiceChips, confirms)."""
from __future__ import annotations
from copy import deepcopy
from types import SimpleNamespace
from typing import Any
from ..fsm import FSMError, Transition, anchor_field_specs, assistant_turn
from ..models import ComposerMode, Stage
from ..validators import record_entry_errors
from .constants import SECTION_HEADINGS, u
from .flow import _begin_edit
from .followups import _redisplay_revision_candidate, _revise_pending_candidate
from .predicates import _entry_by_id
from .save import save_entry
from .skills import _builder_skill_candidates, _process_skill_selection, _skill_choice_card
from .summary_regen import SUMMARY_APPLY_MODULE, apply_summary_proposal, finish_transition
from .state import (
_clear_draft,
_public_entry,
_reset_gap_state,
_set_stream_phases,
ensure_builder_state,
)
from .turns import _fact_prompt, _next_step_turn, _record_card, recommended_section
def process_component_event(
profile: dict[str, Any],
component_data: dict[str, Any],
action: str,
payload: dict[str, Any],
resume_content: dict[str, Any],
skill_suggester: Any | None = None,
) -> Transition:
updated = deepcopy(profile)
state = ensure_builder_state(updated)
name = str(component_data.get("component_name") or "")
if name == "ChoiceChips":
module = str(component_data.get("module") or "")
if module == "builder_entry_select":
if action != "select":
raise FSMError("invalid_builder_choice", "Choose an experience type", status_code=422)
entry_id = str(payload.get("value") or "").strip()
target = _entry_by_id(resume_content, entry_id)
if target is None:
raise FSMError("builder_entry_not_found", "The selected experience no longer exists", status_code=409)
section, entry = target
return _begin_edit(updated, section, entry)
if module == "builder_skill_select":
return _process_skill_selection(updated, state, action, payload, resume_content)
if module == SUMMARY_APPLY_MODULE:
return apply_summary_proposal(updated, action, payload, resume_content)
if module == "builder_next_section":
if action != "select":
raise FSMError("invalid_builder_choice", "Choose the next Builder action", status_code=422)
action_value = str(payload.get("value") or "").strip()
if action_value == "builder_recommend_skills":
candidates = _builder_skill_candidates(updated, resume_content, skill_suggester)
state["pending_skill_candidates"] = candidates
_set_stream_phases(updated, "suggesting_next", "structuring")
if not candidates:
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
_next_step_turn(
recommended_section(updated, resume_content),
prefix=u("暂时没有新的岗位技能建议。"),
),
)
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
assistant_turn(
u("结合你选择的目标岗位,整理出以下待确认技能。只有你勾选并确认后,才会写入简历。"),
[_skill_choice_card(candidates)],
mode=ComposerMode.CHAT,
),
)
if action_value == "builder_finish":
return finish_transition(updated, resume_content)
section = action_value
if section not in SECTION_HEADINGS:
raise FSMError("invalid_builder_section", "Unsupported resume section", status_code=422)
state["active_section"] = section
_reset_gap_state(state)
_set_stream_phases(updated, "suggesting_next", "structuring")
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
assistant_turn(
f"请先填写这段{SECTION_HEADINGS[section]}的基础信息。",
[_record_card(section, title=f"填写{SECTION_HEADINGS[section]}", skippable=True)],
mode=ComposerMode.CHAT,
),
)
raise FSMError("invalid_builder_choice", "Choose an experience type", status_code=422)
if name == "RecordFields":
section = str(component_data.get("record_type") or state.get("active_section") or "education")
if section not in SECTION_HEADINGS:
raise FSMError("invalid_builder_section", "Unsupported resume section", status_code=422)
if action == "skip":
_clear_draft(state)
_set_stream_phases(updated, "suggesting_next")
return Transition(Stage.BUILDER_CONVERSATION, updated, _next_step_turn(recommended_section(updated, resume_content)), lifecycle="dismissed")
if action != "submit":
raise FSMError("invalid_builder_identity", "Submit or skip the experience card", status_code=422)
fields = anchor_field_specs(section)
required = [field["key"] for field in fields]
submitted = {field: str(payload.get(field) or "").strip() for field in required}
errors = record_entry_errors(submitted, required)
if errors:
raise FSMError("invalid_builder_identity", "Please complete the required experience fields", status_code=422, missing_fields=errors)
base = state.get("editing_base_entry")
entry = {**dict(base or {}), **submitted} if isinstance(base, dict) else submitted
state["active_section"] = section
state["identity_draft"] = entry
state["editing_entry_id"] = component_data.get("entry_id") or state.get("editing_entry_id") or None
_reset_gap_state(state)
_set_stream_phases(updated, "structuring")
return Transition(Stage.BUILDER_CONVERSATION, updated, assistant_turn(_fact_prompt(section), [], mode=ComposerMode.CHAT))
if name == "ExperienceConfirmCard":
pending = state.get("pending_entry")
if not isinstance(pending, dict):
raise FSMError("builder_proposal_missing", "The experience proposal is no longer available")
if action == "edit":
state["identity_draft"] = _public_entry(pending)
state["pending_entry"] = None
state["revision_mode"] = True
_reset_gap_state(state)
_set_stream_phases(updated, "structuring")
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
assistant_turn("好的,请直接补充或指出要调整的事实;我会基于原内容重新生成候选改写。", [], mode=ComposerMode.CHAT),
)
if action == "revise":
instruction = str(payload.get("instruction") or "").strip()
if not instruction:
raise FSMError("invalid_builder_confirmation", "Provide revision guidance", status_code=422)
return _revise_pending_candidate(updated, pending, instruction)
if action != "confirm":
raise FSMError("invalid_builder_confirmation", "Confirm or revise the proposed experience", status_code=422)
entry = _public_entry(pending)
fact_description = str(entry.get("description") or "").strip()
proposal = pending.get("_proposal")
if payload.get("use_optimized") and isinstance(proposal, dict):
optimized = str(proposal.get("optimized_description") or "").strip()
if optimized:
entry["description"] = optimized
entry["provenance"] = proposal.get("source") or "ai_expanded"
entry.setdefault("provenance", "user_provided")
content = save_entry(
resume_content,
str(state.get("active_section") or "education"),
entry,
entry_id=str(state.get("editing_entry_id") or "") or None,
)
section = str(state.get("active_section") or "education")
section_items = next(
(item.get("items") for item in content.get("sections") or [] if item.get("kind") == section),
[],
)
if not isinstance(section_items, list) or not section_items:
raise FSMError("builder_entry_not_found", "The confirmed experience could not be saved", status_code=409)
saved_entry = next(
(item for item in section_items if isinstance(item, dict) and item.get("id") == entry.get("id")),
section_items[-1],
)
# Keep the initial ID as a temporary lookup anchor. The persistence layer
# reconciles it to the final ID after the transition is returned.
state["last_confirmed_entry"] = {
"entry_id": str(saved_entry.get("id") or entry.get("id") or ""),
"section": section,
"fact_description": fact_description,
}
_clear_draft(state)
_set_stream_phases(updated, "saving", "suggesting_next")
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
_next_step_turn(recommended_section(updated, content), prefix="已写入简历。"),
lifecycle="confirmed",
resume_content=content,
)
raise FSMError("invalid_builder_component", "This card is no longer active", status_code=422)