"""LLM rescue for Builder messages the keyword routing drops to the generic fallback. Active only when RESUME_AGENT_INTENT_ROUTER_MODE=on and an LLM provider is configured. The rescue never *replaces* keyword routing — it only handles messages that already fell through every keyword rule (the path that used to answer "可以。接下来建议…"). Classification failures and low confidence decline to the legacy fallback turn. """ from __future__ import annotations import re from typing import Any from ..chat_intent_classifier import build_chat_intent_classifier, build_chat_state_summary from ..chat_intents import ChatIntent from ..fsm import Transition, assistant_turn from ..llm_services import log_ai_event from ..models import ComposerMode, Stage from ..settings import load_settings from .constants import SECTION_HEADINGS, SECTION_KEYWORDS from .followups import _redisplay_revision_candidate, _regenerate_entry_candidate from .predicates import _entry_by_id from .state import _dedupe_strings, _gap_state, _reset_gap_state, _set_stream_phases, ensure_builder_state from .turns import _record_card _CLASSIFIER_UNSET = object() _MIN_RESCUE_CONFIDENCE = 0.5 _ENTRY_LABEL_KEYS = ("company", "project_name", "school", "organization", "title", "name", "position", "role") _DETAIL_ACK = "收到。这段经历还没整理完:请继续补充具体事实,或回复「没有」/「跳过」略过当前问题。" def _cached_classifier(agent: Any) -> Any: classifier = getattr(agent, "_chat_intent_classifier", _CLASSIFIER_UNSET) if classifier is _CLASSIFIER_UNSET: settings = load_settings() classifier = ( build_chat_intent_classifier(settings) if settings.intent_router_mode == "on" and settings.use_openai else None ) agent._chat_intent_classifier = classifier return classifier def _squash(value: str) -> str: return re.sub(r"[\s,,。;;!!??]", "", value.casefold()) def _find_entry_by_hint(resume_content: dict[str, Any], hint: str | None) -> tuple[dict[str, Any], dict[str, Any]] | None: needle = _squash(hint or "") if not needle: return None for section in resume_content.get("sections") or []: if not isinstance(section, dict): continue for entry in section.get("items") or []: if not isinstance(entry, dict): continue for key in _ENTRY_LABEL_KEYS: label = _squash(str(entry.get(key) or "")) if label and (label in needle or needle in label): return section, entry return None def _section_hint(content: str, raw: str | None) -> str | None: """Section the user named, derived deterministically from the message. The LLM classifier is not prompted to fill target_section for edit intents and may emit a Chinese heading when it does — normalize that, then fall back to matching the message itself (full "项目经历" outranks bare tokens like "项目"). Never trust the classifier alone: a null/wrong section used to drop the routing to the most-recent entry. """ value = (raw or "").strip().casefold() if len(value) >= 2: for kind, heading in SECTION_HEADINGS.items(): if value == kind or heading.casefold().startswith(value): return kind normalized = content.casefold() for kind, heading in SECTION_HEADINGS.items(): if heading in normalized: return kind return next((kind for kind, tokens in SECTION_KEYWORDS.items() if any(token in normalized for token in tokens)), None) def _rescue_target( profile: dict[str, Any], resume_content: dict[str, Any], hint: str | None, target_section: str | None = None, ) -> tuple[dict[str, Any], dict[str, Any]] | None: target = _find_entry_by_hint(resume_content, hint) if target is not None: return target if target_section: # A section the user named explicitly outranks the most-recent-entry # fallback; without this, "优化教育经历" lands on whatever was confirmed # last (e.g. a campus entry). section_entries = [ (section, entry) for section in resume_content.get("sections") or [] if isinstance(section, dict) and str(section.get("kind") or "") == target_section for entry in section.get("items") or [] if isinstance(entry, dict) ] if len(section_entries) == 1: return section_entries[0] reference = ensure_builder_state(profile).get("last_confirmed_entry") if isinstance(reference, dict): return _entry_by_id(resume_content, str(reference.get("entry_id") or "")) return None def llm_detail_route(agent: Any, profile: dict[str, Any], content: str) -> Transition | None: """LLM gate before free text is merged into the active draft as facts. Only intents that must NOT be merged are intercepted; provide_facts and anything uncertain return None so the legacy merge path continues. """ try: classifier = _cached_classifier(agent) if classifier is None: return None result = classifier.classify(content, state_summary=build_chat_state_summary(profile, ensure_builder_state(profile))) except Exception: return None log_ai_event("chat_intent_detail_route", intent=result.intent.value, confidence=result.confidence) if result.confidence < _MIN_RESCUE_CONFIDENCE: return None if result.intent is ChatIntent.NO_INFO: state = ensure_builder_state(profile) gap_state = _gap_state(state) gap_state["skipped"] = _dedupe_strings([*gap_state["skipped"], *gap_state["asked"]]) from .flow import _process_detail_message # late import: flow imports this module return _process_detail_message(agent, profile, "") if result.intent is ChatIntent.REVISE_PROPOSAL: return _redisplay_revision_candidate(agent, profile, result.revision_instruction or content) if result.intent in {ChatIntent.CHITCHAT, ChatIntent.ASK_QUESTION}: _set_stream_phases(profile, "structuring") return Transition( Stage.BUILDER_CONVERSATION, profile, assistant_turn(_DETAIL_ACK, [], mode=ComposerMode.CHAT), ) return None def llm_intent_rescue( agent: Any, profile: dict[str, Any], content: str, resume_content: dict[str, Any] ) -> Transition | None: """Classify a fell-through message and route it, or None to keep the legacy turn.""" try: classifier = _cached_classifier(agent) if classifier is None: return None result = classifier.classify(content, state_summary=build_chat_state_summary(profile, ensure_builder_state(profile))) except Exception: return None log_ai_event( "chat_intent_rescue", intent=result.intent.value, confidence=result.confidence, rescued=result.confidence >= _MIN_RESCUE_CONFIDENCE and result.intent in {ChatIntent.EDIT_ENTRY, ChatIntent.REVISE_PROPOSAL, ChatIntent.NEW_ENTRY}, ) if result.confidence < _MIN_RESCUE_CONFIDENCE: return None state = ensure_builder_state(profile) if result.intent in {ChatIntent.EDIT_ENTRY, ChatIntent.REVISE_PROPOSAL}: target = _rescue_target( profile, resume_content, result.target_entry_hint, _section_hint(content, result.target_section) ) if target is None: return None section_data, entry = target if str(section_data.get("kind") or "") not in SECTION_HEADINGS: return None if result.intent is ChatIntent.REVISE_PROPOSAL: return _regenerate_entry_candidate(agent, profile, section_data, entry, result.revision_instruction or content) from .flow import _begin_edit # late import: flow imports this module return _begin_edit(profile, section_data, entry) if result.intent is ChatIntent.NEW_ENTRY and result.target_section in SECTION_HEADINGS: section = str(result.target_section) state["active_section"] = section _reset_gap_state(state) _set_stream_phases(profile, "suggesting_next", "structuring") return Transition( Stage.BUILDER_CONVERSATION, profile, assistant_turn( f"好的,先补充{SECTION_HEADINGS[section]}的关键信息。", [_record_card(section, title=f"补充{SECTION_HEADINGS[section]}", skippable=True)], mode=ComposerMode.CHAT, ), ) return None