"""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)