"""Free-text message routing for the Builder conversation.""" from __future__ import annotations import logging from copy import deepcopy from typing import Any from ..chat_intent_classifier import build_chat_state_summary from ..chat_intent_shadow import build_chat_intent_shadow from ..fsm import FIELD_LABELS, FSMError, Transition, assistant_turn, component from ..models import ComposerMode, Stage from ..settings import load_settings from .candidate import _candidate_rewrite from .constants import SECTION_HEADINGS from .followups import _continue_recent_entry, _redisplay_revision_candidate from .rescue import llm_detail_route, llm_intent_rescue from .summary_regen import requests_summary_regen, summary_regen_turn from .predicates import ( _gap_prompt, _is_no_information_reply, _is_revision_instruction, _looks_like_recent_continuation, _matching_entries, _next_gap_dimensions, _requested_section, _requests_identity_change, _requests_new_entry, ) from .state import ( _dedupe_strings, _gap_state, _highlights, _merge_fact_text, _public_entry, _reset_gap_state, _set_stream_phases, ensure_builder_state, ) from .turns import _entry_choice_card, _fact_prompt, _next_step_turn, _record_card, recommended_section _SHADOW_UNSET = object() def _observe_chat_intent_shadow(agent: Any, profile: dict[str, Any], state: dict[str, Any], content: str) -> None: """P0 observe-only hook: shadow observation must never affect routing.""" try: shadow = getattr(agent, "_chat_intent_shadow", _SHADOW_UNSET) if shadow is _SHADOW_UNSET: shadow = build_chat_intent_shadow(load_settings()) agent._chat_intent_shadow = shadow if shadow is not None: shadow.observe(content, state_summary=build_chat_state_summary(profile, state)) except Exception: logging.getLogger(__name__).warning("chat_intent_shadow_observe_failed", exc_info=True) def process_message( agent: Any, profile: dict[str, Any], content: str, resume_content: dict[str, Any] ) -> Transition: updated = deepcopy(profile) state = ensure_builder_state(updated) _observe_chat_intent_shadow(agent, updated, state, content) if requests_summary_regen(content): return summary_regen_turn(agent, updated, resume_content) identity = state.get("identity_draft") section = str(state.get("active_section") or "") if isinstance(identity, dict) and identity and section: if state.get("editing_entry_id") and _requests_identity_change(content): _set_stream_phases(updated, "structuring") return Transition( Stage.BUILDER_CONVERSATION, updated, assistant_turn( "这次涉及基础信息变更,请在卡片中确认后继续补充具体事实。", [_record_card(section, title="修改经历基础信息", value=identity, entry_id=state["editing_entry_id"])], mode=ComposerMode.CHAT, ), ) if state.get("revision_mode") and _is_revision_instruction(content): return _redisplay_revision_candidate(agent, updated, content) routed = llm_detail_route(agent, updated, content) if routed is not None: return routed return _process_detail_message(agent, updated, content) requested_section = _requested_section(content) wants_new_entry = _requests_new_entry(content) if requested_section and (wants_new_entry or not _matching_entries(resume_content, content)): state["active_section"] = requested_section _reset_gap_state(state) _set_stream_phases(updated, "suggesting_next", "structuring") return Transition( Stage.BUILDER_CONVERSATION, updated, assistant_turn( f"好的,先补充{SECTION_HEADINGS[requested_section]}的关键信息。", [_record_card(requested_section, title=f"补充{SECTION_HEADINGS[requested_section]}", skippable=True)], mode=ComposerMode.CHAT, ), ) matches = _matching_entries(resume_content, content) if not wants_new_entry and len(matches) == 1: section_data, entry = matches[0] return _begin_edit(updated, section_data, entry) if not wants_new_entry and len(matches) > 1: state["selection_candidates"] = [entry.get("id") for _, entry in matches] _set_stream_phases(updated, "suggesting_next") return Transition( Stage.BUILDER_CONVERSATION, updated, assistant_turn("找到了多段可能的经历,请选择要修改的那一段。", [_entry_choice_card(matches)], mode=ComposerMode.CHAT), ) if _looks_like_recent_continuation(state, content): continued = _continue_recent_entry(agent, updated, content, resume_content) if continued is not None: return continued rescued = llm_intent_rescue(agent, updated, content, resume_content) if rescued is not None: return rescued _set_stream_phases(updated, "suggesting_next") return Transition(Stage.BUILDER_CONVERSATION, updated, _next_step_turn(recommended_section(updated, resume_content), prefix="可以。")) def _begin_edit(profile: dict[str, Any], section_data: dict[str, Any], entry: dict[str, Any]) -> Transition: state = ensure_builder_state(profile) section = str(section_data.get("kind") or "") if section not in SECTION_HEADINGS: raise FSMError("invalid_builder_section", "Unsupported resume section", status_code=422) draft = _public_entry(entry) state["active_section"] = section state["identity_draft"] = draft state["editing_base_entry"] = deepcopy(draft) state["editing_entry_id"] = entry.get("id") state["selection_candidates"] = [] state["revision_mode"] = False _reset_gap_state(state) _set_stream_phases(profile, "structuring") return Transition( Stage.BUILDER_CONVERSATION, profile, assistant_turn( f"我找到了这段{SECTION_HEADINGS[section]}。请直接补充或修改具体事实;基础信息不变时无需重填。", [], mode=ComposerMode.CHAT, ), ) def _process_detail_message(agent: Any, profile: dict[str, Any], content: str) -> Transition: state = ensure_builder_state(profile) section = str(state.get("active_section") or "education") base = dict(state.get("identity_draft") or {}) original = str(base.get("description") or "").strip() gap_state = _gap_state(state) skipping_asked_gap = bool(gap_state["asked"]) and _is_no_information_reply(content) if skipping_asked_gap: gap_state["skipped"] = _dedupe_strings([*gap_state["skipped"], *gap_state["asked"]]) merged_description = _merge_fact_text(original, content.strip(), skip_no_information=skipping_asked_gap) entry = {**base, "description": merged_description, "highlights": _highlights(merged_description)} state["identity_draft"] = entry state["revision_mode"] = False gaps = _next_gap_dimensions(section, entry, gap_state) if gaps: gap_state["asked"] = _dedupe_strings([*gap_state["asked"], *gaps]) gap_state["rounds"] += 1 _set_stream_phases(profile, "structuring", "checking_gaps") return Transition(Stage.BUILDER_CONVERSATION, profile, assistant_turn(_gap_prompt(gaps), [], mode=ComposerMode.CHAT)) proposal = _candidate_rewrite(agent, profile, entry, section) entry["_proposal"] = proposal state["pending_entry"] = entry _set_stream_phases(profile, "structuring", "checking_gaps", "rewriting") return Transition( Stage.BUILDER_CONVERSATION, profile, assistant_turn( "已整理已知事实并生成候选改写,尚未写入简历。请在原始内容与候选稿之间选择,或继续调整。", [component("ExperienceConfirmCard", title="确认写入简历", value=entry, labels=FIELD_LABELS, ai_proposal=proposal)], mode=ComposerMode.CHAT, ), )