Files

184 lines
8.0 KiB
Python

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