Files

200 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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