generated from kgod/ai-review-template
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
184 lines
8.0 KiB
Python
184 lines
8.0 KiB
Python
"""Free-text message routing for the Builder conversation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from copy import deepcopy
|
|
from typing import Any
|
|
|
|
from ..chat_intent_classifier import build_chat_state_summary
|
|
from ..chat_intent_shadow import build_chat_intent_shadow
|
|
from ..fsm import FIELD_LABELS, FSMError, Transition, assistant_turn, component
|
|
from ..models import ComposerMode, Stage
|
|
from ..settings import load_settings
|
|
from .candidate import _candidate_rewrite
|
|
from .constants import SECTION_HEADINGS
|
|
from .followups import _continue_recent_entry, _redisplay_revision_candidate
|
|
from .rescue import llm_detail_route, llm_intent_rescue
|
|
from .summary_regen import requests_summary_regen, summary_regen_turn
|
|
from .predicates import (
|
|
_gap_prompt,
|
|
_is_no_information_reply,
|
|
_is_revision_instruction,
|
|
_looks_like_recent_continuation,
|
|
_matching_entries,
|
|
_next_gap_dimensions,
|
|
_requested_section,
|
|
_requests_identity_change,
|
|
_requests_new_entry,
|
|
)
|
|
from .state import (
|
|
_dedupe_strings,
|
|
_gap_state,
|
|
_highlights,
|
|
_merge_fact_text,
|
|
_public_entry,
|
|
_reset_gap_state,
|
|
_set_stream_phases,
|
|
ensure_builder_state,
|
|
)
|
|
from .turns import _entry_choice_card, _fact_prompt, _next_step_turn, _record_card, recommended_section
|
|
|
|
|
|
_SHADOW_UNSET = object()
|
|
|
|
|
|
def _observe_chat_intent_shadow(agent: Any, profile: dict[str, Any], state: dict[str, Any], content: str) -> None:
|
|
"""P0 observe-only hook: shadow observation must never affect routing."""
|
|
try:
|
|
shadow = getattr(agent, "_chat_intent_shadow", _SHADOW_UNSET)
|
|
if shadow is _SHADOW_UNSET:
|
|
shadow = build_chat_intent_shadow(load_settings())
|
|
agent._chat_intent_shadow = shadow
|
|
if shadow is not None:
|
|
shadow.observe(content, state_summary=build_chat_state_summary(profile, state))
|
|
except Exception:
|
|
logging.getLogger(__name__).warning("chat_intent_shadow_observe_failed", exc_info=True)
|
|
|
|
|
|
def process_message(
|
|
agent: Any, profile: dict[str, Any], content: str, resume_content: dict[str, Any]
|
|
) -> Transition:
|
|
updated = deepcopy(profile)
|
|
state = ensure_builder_state(updated)
|
|
_observe_chat_intent_shadow(agent, updated, state, content)
|
|
if requests_summary_regen(content):
|
|
return summary_regen_turn(agent, updated, resume_content)
|
|
identity = state.get("identity_draft")
|
|
section = str(state.get("active_section") or "")
|
|
if isinstance(identity, dict) and identity and section:
|
|
if state.get("editing_entry_id") and _requests_identity_change(content):
|
|
_set_stream_phases(updated, "structuring")
|
|
return Transition(
|
|
Stage.BUILDER_CONVERSATION,
|
|
updated,
|
|
assistant_turn(
|
|
"这次涉及基础信息变更,请在卡片中确认后继续补充具体事实。",
|
|
[_record_card(section, title="修改经历基础信息", value=identity, entry_id=state["editing_entry_id"])],
|
|
mode=ComposerMode.CHAT,
|
|
),
|
|
)
|
|
if state.get("revision_mode") and _is_revision_instruction(content):
|
|
return _redisplay_revision_candidate(agent, updated, content)
|
|
routed = llm_detail_route(agent, updated, content)
|
|
if routed is not None:
|
|
return routed
|
|
return _process_detail_message(agent, updated, content)
|
|
|
|
requested_section = _requested_section(content)
|
|
wants_new_entry = _requests_new_entry(content)
|
|
if requested_section and (wants_new_entry or not _matching_entries(resume_content, content)):
|
|
state["active_section"] = requested_section
|
|
_reset_gap_state(state)
|
|
_set_stream_phases(updated, "suggesting_next", "structuring")
|
|
return Transition(
|
|
Stage.BUILDER_CONVERSATION,
|
|
updated,
|
|
assistant_turn(
|
|
f"好的,先补充{SECTION_HEADINGS[requested_section]}的关键信息。",
|
|
[_record_card(requested_section, title=f"补充{SECTION_HEADINGS[requested_section]}", skippable=True)],
|
|
mode=ComposerMode.CHAT,
|
|
),
|
|
)
|
|
|
|
matches = _matching_entries(resume_content, content)
|
|
if not wants_new_entry and len(matches) == 1:
|
|
section_data, entry = matches[0]
|
|
return _begin_edit(updated, section_data, entry)
|
|
if not wants_new_entry and len(matches) > 1:
|
|
state["selection_candidates"] = [entry.get("id") for _, entry in matches]
|
|
_set_stream_phases(updated, "suggesting_next")
|
|
return Transition(
|
|
Stage.BUILDER_CONVERSATION,
|
|
updated,
|
|
assistant_turn("找到了多段可能的经历,请选择要修改的那一段。", [_entry_choice_card(matches)], mode=ComposerMode.CHAT),
|
|
)
|
|
if _looks_like_recent_continuation(state, content):
|
|
continued = _continue_recent_entry(agent, updated, content, resume_content)
|
|
if continued is not None:
|
|
return continued
|
|
rescued = llm_intent_rescue(agent, updated, content, resume_content)
|
|
if rescued is not None:
|
|
return rescued
|
|
_set_stream_phases(updated, "suggesting_next")
|
|
return Transition(Stage.BUILDER_CONVERSATION, updated, _next_step_turn(recommended_section(updated, resume_content), prefix="可以。"))
|
|
|
|
|
|
def _begin_edit(profile: dict[str, Any], section_data: dict[str, Any], entry: dict[str, Any]) -> Transition:
|
|
state = ensure_builder_state(profile)
|
|
section = str(section_data.get("kind") or "")
|
|
if section not in SECTION_HEADINGS:
|
|
raise FSMError("invalid_builder_section", "Unsupported resume section", status_code=422)
|
|
draft = _public_entry(entry)
|
|
state["active_section"] = section
|
|
state["identity_draft"] = draft
|
|
state["editing_base_entry"] = deepcopy(draft)
|
|
state["editing_entry_id"] = entry.get("id")
|
|
state["selection_candidates"] = []
|
|
state["revision_mode"] = False
|
|
_reset_gap_state(state)
|
|
_set_stream_phases(profile, "structuring")
|
|
return Transition(
|
|
Stage.BUILDER_CONVERSATION,
|
|
profile,
|
|
assistant_turn(
|
|
f"我找到了这段{SECTION_HEADINGS[section]}。请直接补充或修改具体事实;基础信息不变时无需重填。",
|
|
[],
|
|
mode=ComposerMode.CHAT,
|
|
),
|
|
)
|
|
|
|
|
|
def _process_detail_message(agent: Any, profile: dict[str, Any], content: str) -> Transition:
|
|
state = ensure_builder_state(profile)
|
|
section = str(state.get("active_section") or "education")
|
|
base = dict(state.get("identity_draft") or {})
|
|
original = str(base.get("description") or "").strip()
|
|
gap_state = _gap_state(state)
|
|
skipping_asked_gap = bool(gap_state["asked"]) and _is_no_information_reply(content)
|
|
if skipping_asked_gap:
|
|
gap_state["skipped"] = _dedupe_strings([*gap_state["skipped"], *gap_state["asked"]])
|
|
merged_description = _merge_fact_text(original, content.strip(), skip_no_information=skipping_asked_gap)
|
|
entry = {**base, "description": merged_description, "highlights": _highlights(merged_description)}
|
|
state["identity_draft"] = entry
|
|
state["revision_mode"] = False
|
|
gaps = _next_gap_dimensions(section, entry, gap_state)
|
|
if gaps:
|
|
gap_state["asked"] = _dedupe_strings([*gap_state["asked"], *gaps])
|
|
gap_state["rounds"] += 1
|
|
_set_stream_phases(profile, "structuring", "checking_gaps")
|
|
return Transition(Stage.BUILDER_CONVERSATION, profile, assistant_turn(_gap_prompt(gaps), [], mode=ComposerMode.CHAT))
|
|
proposal = _candidate_rewrite(agent, profile, entry, section)
|
|
entry["_proposal"] = proposal
|
|
state["pending_entry"] = entry
|
|
_set_stream_phases(profile, "structuring", "checking_gaps", "rewriting")
|
|
return Transition(
|
|
Stage.BUILDER_CONVERSATION,
|
|
profile,
|
|
assistant_turn(
|
|
"已整理已知事实并生成候选改写,尚未写入简历。请在原始内容与候选稿之间选择,或继续调整。",
|
|
[component("ExperienceConfirmCard", title="确认写入简历", value=entry, labels=FIELD_LABELS, ai_proposal=proposal)],
|
|
mode=ComposerMode.CHAT,
|
|
),
|
|
)
|