generated from kgod/ai-review-template
feat: initialize resume agent with OfferPai sync
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user