feat: initialize resume agent with OfferPai sync

This commit is contained in:
Codex
2026-08-05 20:20:18 +08:00
commit 61ec750031
197 changed files with 33291 additions and 0 deletions
+199
View File
@@ -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