generated from kgod/ai-review-template
200 lines
8.5 KiB
Python
200 lines
8.5 KiB
Python
"""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
|