generated from kgod/ai-review-template
feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""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,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user