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
@@ -0,0 +1,79 @@
"""Focused Builder conversation policy for lightweight resume completion.
Builder collects confirmed resume facts only. It never calls the deep-optimization
graph, job rubrics, Office data, or JD analysis. Candidate rewrites remain optional
until the user explicitly chooses one in the confirmation card.
This package was split from the original single module to keep every code file
within the 200-line harness limit. The public surface is re-exported here so
existing `from . import builder_conversation` / `from app.builder_conversation
import ...` consumers keep working unchanged.
"""
from __future__ import annotations
from .candidate import (
_candidate_rewrite,
_fact_is_preserved,
_material_fact_fragments,
_normalize_material_fact,
_uncovered_material_facts,
)
from .component_events import process_component_event
from .constants import (
GAP_PROMPTS,
IDENTITY_CHANGE_TERMS,
MAX_GAP_DIMENSIONS,
MAX_GAPS_PER_TURN,
NO_INFORMATION_PATTERNS,
SECTION_GAP_DIMENSIONS,
SECTION_HEADINGS,
SECTION_KEYWORDS,
SECTION_PRIORITY,
u,
)
from .flow import _begin_edit, _process_detail_message, process_message
from .followups import (
_continue_recent_entry,
_redisplay_revision_candidate,
reconcile_last_confirmed_entry,
)
from .predicates import (
_dimension_present,
_entry_by_id,
_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 .save import save_entry
from .skills import _builder_skill_candidates, _process_skill_selection, _skill_choice_card
from .state import (
_clear_draft,
_completed_sections,
_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,
_entry_label,
_fact_prompt,
_next_step_turn,
_record_card,
_section_choice_card,
recommended_section,
welcome_turn,
)
__all__ = [name for name in dir() if not name.startswith("__")]
@@ -0,0 +1,96 @@
"""Candidate rewrite for Builder entries (light STAR optimization)."""
from __future__ import annotations
from copy import deepcopy
import re
from typing import Any
from .state import _dedupe_strings
from ..experience_optimizer import _fact_text_is_preserved, split_description_parts
def _candidate_rewrite(
agent: Any, profile: dict[str, Any], entry: dict[str, Any], section: str, *, instruction: str | None = None,
ensure_facts: bool = False,
) -> dict[str, Any]:
try:
proposal = agent.expander.expand(
deepcopy(entry),
context={
"job_type": profile.get("job_type"),
"target_position": profile.get("target_position"),
"entry_type": section,
"instruction": instruction,
},
)
except Exception:
proposal = {}
original = str(entry.get("description") or "").strip()
optimized = str(proposal.get("optimized_description") or "").strip() or original
if ensure_facts:
# Explicit user-requested revision: still-missing material facts are folded
# back in (the user asked for them; this is not a silent auto-append).
missing = _uncovered_material_facts(optimized, original)
if missing:
if "" in optimized:
optimized = optimized + "".join(f"\n{fact}" for fact in missing)
else:
optimized = f"{optimized.rstrip('')}{''.join(missing)}"
return {
"optimized_description": optimized,
"changes": proposal.get("changes") or [],
"source": proposal.get("source") or "ai_expanded",
"uncovered_facts": _uncovered_material_facts(optimized, original),
**({"generation_source": proposal["generation_source"]} if proposal.get("generation_source") else {}),
}
def _uncovered_material_facts(candidate: str, original: str) -> list[str]:
"""Material user facts the candidate dropped. Reported, never auto-appended."""
uncovered = [fact for fact in _material_fact_fragments(original) if not _fact_is_preserved(fact, candidate)]
fragments = split_description_parts(original)
if len(fragments) >= 2:
# Structured descriptions (feature lists, tech stack, outcomes) are checked
# fragment by fragment, so a dropped feature module is reported even when the
# tech stack survived. Single-sentence descriptions keep the regex-only path.
ledger = [
{"id": f"fragment_{index}", "source": "user_form", "field": "description_part", "text": fragment}
for index, fragment in enumerate(fragments, start=1)
]
uncovered.extend(
fragment
for index, fragment in enumerate(fragments, start=1)
if not _fact_text_is_preserved(f"fragment_{index}", ledger, candidate)
)
return _dedupe_strings(uncovered)
def _material_fact_fragments(text: str) -> list[str]:
facts: list[str] = []
patterns = (
r"gpa\s*[:]?\s*\d+(?:\.\d+)?\s*/\s*\d+(?:\.\d+)?",
r"(?:排名\s*)?(?:前\s*百分之\s*\d+(?:\.\d+)?|前\s*\d+(?:\.\d+)?\s*%|top\s*\d+(?:\.\d+)?\s*%)",
r"(?:专业|年级)?(?:排名)?前(?:十|二十|三十|五十)",
r"(?:获得|荣获|获评|获奖|取得)[^。;;\n]{0,30}(?:奖学金|奖项|荣誉|一等奖|二等奖|三等奖|优秀[^。;;\n]{0,12})",
r"(?:完成|参与|负责|主导|开发|设计|实现|搭建|推进|开展)[^。;;\n]{0,40}(?:课程项目|课程设计|项目|竞赛|实验室|实践|实训|研究|论文)",
r"(?:服务|覆盖|面向|参与|支持|管理|处理|完成|交付|提升|降低|增长)[^。;;\n]{0,20}?\d+(?:\.\d+)?\s*(?:%|人|名(?:学生|用户|客户|参与者)?|次|天|周|月|小时|万元|万|千|个|项|篇|场)",
)
for pattern in patterns:
facts.extend(match.group(0).strip(" \t,") for match in re.finditer(pattern, text, flags=re.IGNORECASE))
tool_pattern = r"\b(?:python|sql|java|javascript|typescript|vue|react|excel|power\s*bi|tableau|pandas|tensorflow|pytorch|docker|git|linux)\b"
facts.extend(match.group(0).strip() for match in re.finditer(tool_pattern, text, flags=re.IGNORECASE))
return _dedupe_strings([fact for fact in facts if fact])
def _fact_is_preserved(fact: str, candidate: str) -> bool:
normalized_fact = _normalize_material_fact(fact)
normalized_candidate = _normalize_material_fact(candidate)
return bool(normalized_fact) and normalized_fact in normalized_candidate
def _normalize_material_fact(value: str) -> str:
normalized = value.casefold().replace("百分之", "%")
normalized = re.sub(r"(?:排名|专业排名|年级排名)?前\s*(\d+(?:\.\d+)?)\s*%?", r"top\1", normalized)
normalized = re.sub(r"top\s*(\d+(?:\.\d+)?)\s*%?", r"top\1", normalized)
return re.sub(r"[\s,,。;;:]", "", normalized)
@@ -0,0 +1,191 @@
"""Component-event handling for Builder cards (RecordFields, ChoiceChips, confirms)."""
from __future__ import annotations
from copy import deepcopy
from types import SimpleNamespace
from typing import Any
from ..fsm import FSMError, Transition, anchor_field_specs, assistant_turn
from ..models import ComposerMode, Stage
from ..validators import record_entry_errors
from .constants import SECTION_HEADINGS, u
from .flow import _begin_edit
from .followups import _redisplay_revision_candidate, _revise_pending_candidate
from .predicates import _entry_by_id
from .save import save_entry
from .skills import _builder_skill_candidates, _process_skill_selection, _skill_choice_card
from .summary_regen import SUMMARY_APPLY_MODULE, apply_summary_proposal, finish_transition
from .state import (
_clear_draft,
_public_entry,
_reset_gap_state,
_set_stream_phases,
ensure_builder_state,
)
from .turns import _fact_prompt, _next_step_turn, _record_card, recommended_section
def process_component_event(
profile: dict[str, Any],
component_data: dict[str, Any],
action: str,
payload: dict[str, Any],
resume_content: dict[str, Any],
skill_suggester: Any | None = None,
) -> Transition:
updated = deepcopy(profile)
state = ensure_builder_state(updated)
name = str(component_data.get("component_name") or "")
if name == "ChoiceChips":
module = str(component_data.get("module") or "")
if module == "builder_entry_select":
if action != "select":
raise FSMError("invalid_builder_choice", "Choose an experience type", status_code=422)
entry_id = str(payload.get("value") or "").strip()
target = _entry_by_id(resume_content, entry_id)
if target is None:
raise FSMError("builder_entry_not_found", "The selected experience no longer exists", status_code=409)
section, entry = target
return _begin_edit(updated, section, entry)
if module == "builder_skill_select":
return _process_skill_selection(updated, state, action, payload, resume_content)
if module == SUMMARY_APPLY_MODULE:
return apply_summary_proposal(updated, action, payload, resume_content)
if module == "builder_next_section":
if action != "select":
raise FSMError("invalid_builder_choice", "Choose the next Builder action", status_code=422)
action_value = str(payload.get("value") or "").strip()
if action_value == "builder_recommend_skills":
candidates = _builder_skill_candidates(updated, resume_content, skill_suggester)
state["pending_skill_candidates"] = candidates
_set_stream_phases(updated, "suggesting_next", "structuring")
if not candidates:
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
_next_step_turn(
recommended_section(updated, resume_content),
prefix=u("暂时没有新的岗位技能建议。"),
),
)
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
assistant_turn(
u("结合你选择的目标岗位,整理出以下待确认技能。只有你勾选并确认后,才会写入简历。"),
[_skill_choice_card(candidates)],
mode=ComposerMode.CHAT,
),
)
if action_value == "builder_finish":
return finish_transition(updated, resume_content)
section = action_value
if section not in SECTION_HEADINGS:
raise FSMError("invalid_builder_section", "Unsupported resume section", status_code=422)
state["active_section"] = section
_reset_gap_state(state)
_set_stream_phases(updated, "suggesting_next", "structuring")
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
assistant_turn(
f"请先填写这段{SECTION_HEADINGS[section]}的基础信息。",
[_record_card(section, title=f"填写{SECTION_HEADINGS[section]}", skippable=True)],
mode=ComposerMode.CHAT,
),
)
raise FSMError("invalid_builder_choice", "Choose an experience type", status_code=422)
if name == "RecordFields":
section = str(component_data.get("record_type") or state.get("active_section") or "education")
if section not in SECTION_HEADINGS:
raise FSMError("invalid_builder_section", "Unsupported resume section", status_code=422)
if action == "skip":
_clear_draft(state)
_set_stream_phases(updated, "suggesting_next")
return Transition(Stage.BUILDER_CONVERSATION, updated, _next_step_turn(recommended_section(updated, resume_content)), lifecycle="dismissed")
if action != "submit":
raise FSMError("invalid_builder_identity", "Submit or skip the experience card", status_code=422)
fields = anchor_field_specs(section)
required = [field["key"] for field in fields]
submitted = {field: str(payload.get(field) or "").strip() for field in required}
errors = record_entry_errors(submitted, required)
if errors:
raise FSMError("invalid_builder_identity", "Please complete the required experience fields", status_code=422, missing_fields=errors)
base = state.get("editing_base_entry")
entry = {**dict(base or {}), **submitted} if isinstance(base, dict) else submitted
state["active_section"] = section
state["identity_draft"] = entry
state["editing_entry_id"] = component_data.get("entry_id") or state.get("editing_entry_id") or None
_reset_gap_state(state)
_set_stream_phases(updated, "structuring")
return Transition(Stage.BUILDER_CONVERSATION, updated, assistant_turn(_fact_prompt(section), [], mode=ComposerMode.CHAT))
if name == "ExperienceConfirmCard":
pending = state.get("pending_entry")
if not isinstance(pending, dict):
raise FSMError("builder_proposal_missing", "The experience proposal is no longer available")
if action == "edit":
state["identity_draft"] = _public_entry(pending)
state["pending_entry"] = None
state["revision_mode"] = True
_reset_gap_state(state)
_set_stream_phases(updated, "structuring")
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
assistant_turn("好的,请直接补充或指出要调整的事实;我会基于原内容重新生成候选改写。", [], mode=ComposerMode.CHAT),
)
if action == "revise":
instruction = str(payload.get("instruction") or "").strip()
if not instruction:
raise FSMError("invalid_builder_confirmation", "Provide revision guidance", status_code=422)
return _revise_pending_candidate(updated, pending, instruction)
if action != "confirm":
raise FSMError("invalid_builder_confirmation", "Confirm or revise the proposed experience", status_code=422)
entry = _public_entry(pending)
fact_description = str(entry.get("description") or "").strip()
proposal = pending.get("_proposal")
if payload.get("use_optimized") and isinstance(proposal, dict):
optimized = str(proposal.get("optimized_description") or "").strip()
if optimized:
entry["description"] = optimized
entry["provenance"] = proposal.get("source") or "ai_expanded"
entry.setdefault("provenance", "user_provided")
content = save_entry(
resume_content,
str(state.get("active_section") or "education"),
entry,
entry_id=str(state.get("editing_entry_id") or "") or None,
)
section = str(state.get("active_section") or "education")
section_items = next(
(item.get("items") for item in content.get("sections") or [] if item.get("kind") == section),
[],
)
if not isinstance(section_items, list) or not section_items:
raise FSMError("builder_entry_not_found", "The confirmed experience could not be saved", status_code=409)
saved_entry = next(
(item for item in section_items if isinstance(item, dict) and item.get("id") == entry.get("id")),
section_items[-1],
)
# Keep the initial ID as a temporary lookup anchor. The persistence layer
# reconciles it to the final ID after the transition is returned.
state["last_confirmed_entry"] = {
"entry_id": str(saved_entry.get("id") or entry.get("id") or ""),
"section": section,
"fact_description": fact_description,
}
_clear_draft(state)
_set_stream_phases(updated, "saving", "suggesting_next")
return Transition(
Stage.BUILDER_CONVERSATION,
updated,
_next_step_turn(recommended_section(updated, content), prefix="已写入简历。"),
lifecycle="confirmed",
resume_content=content,
)
raise FSMError("invalid_builder_component", "This card is no longer active", status_code=422)
@@ -0,0 +1,48 @@
"""Builder conversation constants: sections, gap prompts, and priorities."""
SECTION_HEADINGS = {
"education": "教育经历",
"work_experience": "工作经历",
"internship_experience": "实习经历",
"project_experience": "项目经历",
"campus_experience": "校园经历",
}
SECTION_KEYWORDS = {
"education": ("教育", "学校", "学历"),
"work_experience": ("工作", "职场", "任职"),
"internship_experience": ("实习",),
"project_experience": ("项目",),
"campus_experience": ("校园", "社团", "学生会"),
}
IDENTITY_CHANGE_TERMS = ("学校", "公司", "单位", "职位", "岗位", "时间", "入职", "毕业", "就读")
SECTION_PRIORITY = {
"campus": ("education", "project_experience", "internship_experience", "campus_experience", "work_experience"),
"internship": ("education", "internship_experience", "project_experience", "campus_experience", "work_experience"),
"social": ("work_experience", "project_experience", "internship_experience", "campus_experience", "education"),
}
MAX_GAP_DIMENSIONS = 3
MAX_GAPS_PER_TURN = 2
NO_INFORMATION_PATTERNS = ("没有", "", "", "暂无", "没有了", "没了", "不清楚", "不确定")
GAP_PROMPTS = {
"academic_result": "这段教育经历还缺少一项能体现学习成果的事实:GPA/均分、排名、奖学金或荣誉中有可写的吗?没有也可以直接说没有。",
"practice_evidence": "还可以补一项课程项目、竞赛、实验室或实践经历;有相关事实吗?没有也可以直接说没有。",
"contribution_method": "你在其中具体负责了什么,使用了哪些方法或工具?没有也可以直接说没有。",
"delivery_or_outcome": "是否有可确认的交付物、结果或验收成果?没有也可以直接说没有。",
"scale_or_metric": "是否有覆盖规模、数量、耗时、效率或质量等量化信息?没有也可以直接说没有。",
"responsibility_execution": "你具体负责和执行了哪些环节?没有也可以直接说没有。",
"scale_or_result": "活动规模或可确认结果是什么?没有也可以直接说没有。",
}
def u(value: str) -> str:
"""Return localized Builder text without a second encoding pass."""
return value
SECTION_GAP_DIMENSIONS = {
"education": ("academic_result", "practice_evidence"),
"project_experience": ("contribution_method", "delivery_or_outcome", "scale_or_metric"),
"work_experience": ("contribution_method", "delivery_or_outcome", "scale_or_metric"),
"internship_experience": ("contribution_method", "delivery_or_outcome", "scale_or_metric"),
"campus_experience": ("responsibility_execution", "scale_or_result"),
}
@@ -0,0 +1,22 @@
"""Lazy shared expander for card-driven rewrites that lack an agent reference.
process_component_event is invoked by agent.py (over the 200-line edit limit, so its
call signature is fixed) with no agent handle. Card actions that need a rewrite
therefore share one process-wide expander built with the same factory as main.py.
"""
from __future__ import annotations
from typing import Any
_EXPANDER: Any = None
def shared_expander() -> Any:
global _EXPANDER
if _EXPANDER is None:
from ..resume_expansion import build_expander
from ..settings import load_settings
_EXPANDER = build_expander(load_settings())
return _EXPANDER
+183
View File
@@ -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,
),
)
@@ -0,0 +1,160 @@
"""Continuation and revision turns for already-confirmed Builder entries."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from ..fsm import FIELD_LABELS, Transition, assistant_turn, component
from ..models import ComposerMode, Stage
from .candidate import _candidate_rewrite
from .constants import SECTION_HEADINGS, u
from .expander_provider import shared_expander
from .predicates import _entry_by_id
from .state import (
_highlights,
_merge_fact_text,
_public_entry,
_reset_gap_state,
_set_stream_phases,
ensure_builder_state,
)
def reconcile_last_confirmed_entry(profile: dict[str, Any], resume_content: dict[str, Any]) -> None:
"""Keep Builder's continuation pointer aligned after document ID reconciliation."""
state = ensure_builder_state(profile)
reference = state.get("last_confirmed_entry")
if not isinstance(reference, dict):
return
entry_id = str(reference.get("entry_id") or "")
if entry_id and _entry_by_id(resume_content, entry_id) is not None:
return
section_kind = str(reference.get("section") or "")
section = next(
(item for item in resume_content.get("sections") or [] if item.get("kind") == section_kind),
None,
)
entries = section.get("items") if isinstance(section, dict) else None
if isinstance(entries, list) and entries and isinstance(entries[-1], dict):
reference["entry_id"] = str(entries[-1].get("id") or "")
return
state["last_confirmed_entry"] = None
def _continue_recent_entry(
agent: Any, profile: dict[str, Any], content: str, resume_content: dict[str, Any]
) -> Transition | None:
state = ensure_builder_state(profile)
reference = state.get("last_confirmed_entry")
if not isinstance(reference, dict):
return None
entry_id = str(reference.get("entry_id") or "").strip()
target = _entry_by_id(resume_content, entry_id)
if not entry_id or target is None:
state["last_confirmed_entry"] = None
return None
section_data, saved_entry = target
section = str(section_data.get("kind") or reference.get("section") or "")
if section not in SECTION_HEADINGS:
return None
entry = _public_entry(saved_entry)
original_facts = str(reference.get("fact_description") or entry.get("description") or "").strip()
entry["description"] = _merge_fact_text(original_facts, content.strip())
entry["highlights"] = _highlights(str(entry["description"]))
entry["_proposal"] = _candidate_rewrite(agent, profile, entry, section)
state["active_section"] = section
state["identity_draft"] = _public_entry(saved_entry)
state["editing_base_entry"] = _public_entry(saved_entry)
state["editing_entry_id"] = entry_id
state["pending_entry"] = entry
state["selection_candidates"] = []
_reset_gap_state(state)
_set_stream_phases(profile, "structuring", "rewriting")
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
assistant_turn(
"好的,已收到这条补充信息。我已基于原内容重新整理候选改写,尚未写入简历。请确认后再保存。",
[
component(
"ExperienceConfirmCard",
title="确认更新这段经历",
value=entry,
labels=FIELD_LABELS,
ai_proposal=entry["_proposal"],
)
],
mode=ComposerMode.CHAT,
),
)
def _regenerate_entry_candidate(
agent: Any,
profile: dict[str, Any],
section_data: dict[str, Any],
saved_entry: dict[str, Any],
instruction: str,
) -> Transition:
"""Re-run the light rewrite for a confirmed entry (e.g. "帮我重新优化这段")."""
state = ensure_builder_state(profile)
section = str(section_data.get("kind") or "")
entry = _public_entry(saved_entry)
entry["_proposal"] = _candidate_rewrite(agent, profile, entry, section, instruction=instruction)
state["active_section"] = section
state["identity_draft"] = _public_entry(saved_entry)
state["editing_base_entry"] = _public_entry(saved_entry)
state["editing_entry_id"] = saved_entry.get("id")
state["pending_entry"] = entry
state["selection_candidates"] = []
_reset_gap_state(state)
_set_stream_phases(profile, "structuring", "rewriting")
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
assistant_turn(
"好的,已按你的要求重新生成候选改写,尚未写入简历。请在原始内容与候选稿之间选择。",
[
component(
"ExperienceConfirmCard",
title="确认更新这段经历",
value=entry,
labels=FIELD_LABELS,
ai_proposal=entry["_proposal"],
)
],
mode=ComposerMode.CHAT,
),
)
def _redisplay_revision_candidate(agent: Any, profile: dict[str, Any], instruction: str) -> Transition:
state = ensure_builder_state(profile)
section = str(state.get("active_section") or "education")
entry = _public_entry(dict(state.get("identity_draft") or {}))
entry["_proposal"] = _candidate_rewrite(agent, profile, entry, section, instruction=instruction, ensure_facts=True)
state["pending_entry"] = entry
state["revision_mode"] = False
_set_stream_phases(profile, "structuring", "rewriting")
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
assistant_turn(
u("好的,已理解你的调整说明。我会保留这段已确认的事实,并重新展示候选改写供你确认。"),
[component("ExperienceConfirmCard", title=u("确认更新这段经历"), value=entry, labels=FIELD_LABELS, ai_proposal=entry["_proposal"])],
mode=ComposerMode.CHAT,
),
)
def _revise_pending_candidate(profile: dict[str, Any], pending: dict[str, Any], instruction: str) -> Transition:
"""Regenerate the pending proposal with guidance (confirm card's revise action).
Card events carry no agent reference, so the shared expander is used.
"""
state = ensure_builder_state(profile)
state["identity_draft"] = _public_entry(pending)
agent = SimpleNamespace(expander=shared_expander())
return _redisplay_revision_candidate(agent, profile, instruction)
@@ -0,0 +1,114 @@
"""Rule predicates for Builder messages (legacy keyword routing, kept as fallback)."""
from __future__ import annotations
import re
from typing import Any
from .constants import (
GAP_PROMPTS,
IDENTITY_CHANGE_TERMS,
MAX_GAP_DIMENSIONS,
MAX_GAPS_PER_TURN,
SECTION_GAP_DIMENSIONS,
SECTION_KEYWORDS,
)
def _requests_new_entry(content: str) -> bool:
normalized = content.casefold()
return any(token in normalized for token in ("新增", "新建", "再添加", "再补充", "另一段", "另一个", "第二段", "写一段"))
def _is_revision_instruction(content: str) -> bool:
normalized = re.sub(r"[\s,,。;;!?]", "", content.casefold())
correction_terms = ("不要跳过", "没有跳过", "没说要跳过", "没有说要跳过", "不是这个意思", "保留原文", "保留这段", "不要删除", "不要删", "无需跳过")
return any(term in normalized for term in correction_terms)
def _requested_section(content: str) -> str | None:
normalized = content.casefold()
if not any(token in normalized for token in ("补充", "新增", "添加", "新建", "写一段")):
return None
return next((kind for kind, tokens in SECTION_KEYWORDS.items() if any(token in normalized for token in tokens)), None)
def _looks_like_recent_continuation(state: dict[str, Any], content: str) -> bool:
if not isinstance(state.get("last_confirmed_entry"), dict):
return False
normalized = re.sub(r"[,。!!?\s]", "", content.casefold())
if len(normalized) < 4:
return False
if normalized in {"可以", "好的", "继续", "没问题", "谢谢", "知道了"}:
return False
continuation_terms = ("对了", "", "另外", "前面", "之前", "补充", "获得", "拿过", "拿到")
return any(term in normalized for term in continuation_terms) and not any(
token in normalized for token in ("新增", "新建", "写一段", "另一段", "别的经历")
)
def _matching_entries(resume_content: dict[str, Any], content: str) -> list[tuple[dict[str, Any], dict[str, Any]]]:
normalized = content.casefold()
if not any(token in normalized for token in ("修改", "编辑", "调整", "补充")):
return []
all_entries = [(section, entry) for section in resume_content.get("sections") or [] if isinstance(section, dict) for entry in section.get("items") or [] if isinstance(entry, dict)]
named = [
pair for pair in all_entries
if any(str(pair[1].get(key) or "").strip().casefold() in normalized for key in ("company", "project_name", "school", "organization", "position", "role") if str(pair[1].get(key) or "").strip())
]
if named:
return named
kinds = [kind for kind, tokens in SECTION_KEYWORDS.items() if any(token in normalized for token in tokens)]
return [pair for pair in all_entries if str(pair[0].get("kind") or "") in kinds]
def _entry_by_id(resume_content: dict[str, Any], entry_id: str) -> tuple[dict[str, Any], dict[str, Any]] | None:
for section in resume_content.get("sections") or []:
if not isinstance(section, dict):
continue
for entry in section.get("items") or []:
if isinstance(entry, dict) and entry.get("id") == entry_id:
return section, entry
return None
def _requests_identity_change(content: str) -> bool:
normalized = content.casefold()
return any(token in normalized for token in ("", "修改", "变更", "")) and any(term in normalized for term in IDENTITY_CHANGE_TERMS)
def _is_no_information_reply(content: str) -> bool:
normalized = re.sub(r"[\s,,。;;!?]", "", content.casefold())
return normalized in {
"没有", "", "", "暂无", "没了", "没有了", "不清楚", "不确定",
"跳过", "先跳过", "跳过吧", "暂时跳过", "略过", "不用了", "先不用", "不需要", "暂时不用", "以后再说", "再说吧",
}
def _dimension_present(dimension: str, entry: dict[str, Any]) -> bool:
# Identity fields such as dates are not evidence of an experience outcome or scale.
text = str(entry.get("description") or "").casefold()
patterns = {
"academic_result": r"gpa|均分|成绩|绩点|排名|top\s*\d+|前\s*\d+|奖学金|荣誉|获奖|奖项",
"practice_evidence": r"课程项目|课程设计|项目|竞赛|实验室|实践|实训|研究|论文",
"contribution_method": r"负责|主导|参与|设计|开发|实现|搭建|分析|调研|协调|测试|维护|优化|使用|通过|python|sql|java|vue|react|excel",
"delivery_or_outcome": r"交付|上线|发布|落地|完成|产出|验收|结果|成果|提升|降低|减少|增长|获得|达成",
"scale_or_metric": r"\d|百分比|%|人|次|天|周|月|小时|万元|万|千|覆盖|规模|效率|质量",
"responsibility_execution": r"负责|主导|参与|组织|策划|执行|协调|运营|宣传|招募|管理",
"scale_or_result": r"\d|人|次|场|覆盖|规模|参与|报名|增长|完成|结果|成果|获奖",
}
return bool(re.search(patterns[dimension], text, flags=re.IGNORECASE))
def _next_gap_dimensions(section: str, entry: dict[str, Any], state: dict[str, Any]) -> list[str]:
asked = set(state["asked"])
skipped = set(state["skipped"])
if len(asked) >= MAX_GAP_DIMENSIONS:
return []
candidates = [dimension for dimension in SECTION_GAP_DIMENSIONS.get(section, ()) if dimension not in skipped and not _dimension_present(dimension, entry)]
remaining_capacity = MAX_GAP_DIMENSIONS - len(asked)
return candidates[: min(MAX_GAPS_PER_TURN, remaining_capacity)]
def _gap_prompt(dimensions: list[str]) -> str:
return "\n".join(GAP_PROMPTS[dimension] for dimension in dimensions)
+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
+30
View File
@@ -0,0 +1,30 @@
"""Persist Builder entries into the resume document."""
from __future__ import annotations
from typing import Any
from ..fsm import FSMError
from ..resume_document_core import find_entry, new_id, normalize_document
from .constants import SECTION_HEADINGS
def save_entry(resume_content: dict[str, Any], section_kind: str, entry: dict[str, Any], *, entry_id: str | None) -> dict[str, Any]:
content = normalize_document(resume_content)
if entry_id:
found = find_entry(content, entry_id)
if found is None:
raise FSMError("builder_entry_not_found", "The selected resume entry no longer exists", status_code=409)
_, existing = found
entry["id"] = existing["id"]
existing.clear()
existing.update(entry)
return content
sections = content.setdefault("sections", [])
section = next((item for item in sections if item.get("kind") == section_kind), None)
if section is None:
section = {"id": new_id("sec"), "kind": section_kind, "heading": SECTION_HEADINGS.get(section_kind, section_kind), "items": []}
sections.append(section)
entry["id"] = entry.get("id") or new_id("entry")
section.setdefault("items", []).append(entry)
return content
+121
View File
@@ -0,0 +1,121 @@
"""Skill-suggestion cards and selection handling for the Builder."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from ..fsm import FSMError, Transition, component
from ..models import Stage
from ..resume_skill_advisor import recommend_skill_candidates
from ..skill_groups import update_skill_groups
from .constants import u
from .state import _set_stream_phases
from .turns import _next_step_turn, recommended_section
def _skill_choice_card(candidates: list[dict[str, Any]]) -> dict[str, Any]:
return component(
"ChoiceChips",
module="builder_skill_select",
title=u("确认岗位技能"),
description=u("请选择你愿意确认加入简历的技能;未选择的候选不会写入。没有合适的也可以暂时跳过。"),
multiple=True,
skippable=True,
skip_label=u("暂不添加"),
options=[
{
"value": str(candidate["skill"]),
"label": f'{candidate["skill"]}{u("")}{candidate["category"]}{u("")}',
}
for candidate in candidates
],
)
def _builder_skill_candidates(
profile: dict[str, Any],
resume_content: dict[str, Any],
skill_suggester: Any | None,
) -> list[dict[str, Any]]:
if skill_suggester is None:
return []
existing = [
str(skill).strip()
for group in resume_content.get("skill_groups") or []
if isinstance(group, dict)
for skill in group.get("skills") or []
if str(skill).strip()
]
working_profile = deepcopy(profile)
working_profile["tags"] = {**dict(working_profile.get("tags") or {}), "skills": existing}
facts: list[dict[str, Any]] = []
for section in resume_content.get("sections") or []:
if not isinstance(section, dict):
continue
for entry in section.get("items") or []:
if isinstance(entry, dict):
facts.append(deepcopy(entry))
working_profile["experiences"] = facts
return recommend_skill_candidates(
working_profile,
existing,
u("根据我选择的目标岗位推荐可确认技能"),
skill_suggester,
)
def _process_skill_selection(
profile: dict[str, Any],
state: dict[str, Any],
action: str,
payload: dict[str, Any],
resume_content: dict[str, Any],
) -> Transition:
if action == "skip":
state["pending_skill_candidates"] = []
_set_stream_phases(profile, "suggesting_next")
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
_next_step_turn(recommended_section(profile, resume_content), prefix=u("好的,先不添加技能。")),
lifecycle="dismissed",
)
if action != "select":
raise FSMError("invalid_builder_skill_selection", "Confirm or skip the skill suggestions", status_code=422)
selected = payload.get("values")
if not isinstance(selected, list):
selected = [payload.get("value")]
allowed = {
str(item.get("skill") or "").strip()
for item in state.get("pending_skill_candidates") or []
if isinstance(item, dict) and str(item.get("skill") or "").strip()
}
chosen = [str(value).strip() for value in selected if str(value or "").strip() in allowed]
if not chosen:
raise FSMError("invalid_builder_skill_selection", "Select at least one suggested skill or skip", status_code=422)
existing = [
str(skill).strip()
for group in resume_content.get("skill_groups") or []
if isinstance(group, dict)
for skill in group.get("skills") or []
if str(skill).strip()
]
preferred = {
str(item.get("skill") or "").strip(): str(item.get("category") or "").strip()
for item in state.get("pending_skill_candidates") or []
if isinstance(item, dict) and str(item.get("skill") or "").strip() and str(item.get("category") or "").strip()
}
content = update_skill_groups(resume_content, [*existing, *chosen], preferred_categories=preferred)
state["pending_skill_candidates"] = []
_set_stream_phases(profile, "saving", "suggesting_next")
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
_next_step_turn(
recommended_section(profile, content),
prefix=u("已添加") + " " + u("").join(chosen) + u(""),
),
lifecycle="confirmed",
resume_content=content,
)
+77
View File
@@ -0,0 +1,77 @@
"""Builder profile-state helpers: drafts, gap tracking, and text utilities."""
from __future__ import annotations
from copy import deepcopy
import re
from typing import Any
def ensure_builder_state(profile: dict[str, Any]) -> dict[str, Any]:
state = profile.setdefault("builder", {})
state.setdefault("active_section", None)
state.setdefault("identity_draft", {})
state.setdefault("pending_entry", None)
state.setdefault("editing_entry_id", None)
state.setdefault("editing_base_entry", None)
state.setdefault("selection_candidates", [])
state.setdefault("gap_state", {"asked": [], "skipped": [], "rounds": 0})
state.setdefault("revision_mode", False)
state.setdefault("last_confirmed_entry", None)
state.setdefault("pending_skill_candidates", [])
state.setdefault("last_stream_phases", [])
state.setdefault("imported", False)
return state
def _gap_state(state: dict[str, Any]) -> dict[str, Any]:
raw = state.setdefault("gap_state", {"asked": [], "skipped": [], "rounds": 0})
raw["asked"] = [str(value) for value in raw.get("asked") or []]
raw["skipped"] = [str(value) for value in raw.get("skipped") or []]
raw["rounds"] = int(raw.get("rounds") or 0)
return raw
def _reset_gap_state(state: dict[str, Any]) -> None:
state["gap_state"] = {"asked": [], "skipped": [], "rounds": 0}
def _set_stream_phases(profile: dict[str, Any], *phases: str) -> None:
ensure_builder_state(profile)["last_stream_phases"] = list(dict.fromkeys(phases))
def _clear_draft(state: dict[str, Any]) -> None:
state["revision_mode"] = False
state["active_section"] = None
state["identity_draft"] = {}
state["pending_entry"] = None
state["editing_entry_id"] = None
state["editing_base_entry"] = None
state["selection_candidates"] = []
_reset_gap_state(state)
def _public_entry(entry: dict[str, Any]) -> dict[str, Any]:
return {key: deepcopy(value) for key, value in entry.items() if not key.startswith("_")}
def _dedupe_strings(values: list[str]) -> list[str]:
return list(dict.fromkeys(values))
def _highlights(text: str) -> list[str]:
return [part.strip() for part in re.split(r"[。;;\n]+", text) if part.strip()][:5]
def _merge_fact_text(original: str, detail: str, *, skip_no_information: bool = False) -> str:
if not detail.strip():
return original
if not original or original == detail:
return detail or original
if skip_no_information:
return original
return f"{original}\n{detail}"
def _completed_sections(resume_content: dict[str, Any]) -> set[str]:
return {str(section.get("kind") or "") for section in resume_content.get("sections") or [] if isinstance(section, dict) and any(isinstance(entry, dict) for entry in section.get("items") or [])}
@@ -0,0 +1,95 @@
"""Builder 个人总结再生入口:finish 按钮与对话指令统一走"显式请求即重生成"
agent 侧只在总结缺失或 stale 时才生成(避免自动覆盖用户手工文本);用户的显式
请求必须先把现有总结标记为 stale,让既有闸门放行。对话路径无法直接写简历
add_message 不合并 resume_content),所以走"生成候选 → ChoiceChips 确认 →
组件事件写入"的既有 Builder 模式。
"""
from __future__ import annotations
import re
from typing import Any
from ..fsm import FSMError, Transition, assistant_turn, component
from ..models import ComposerMode, Stage
from ..resume_document import mark_profile_summary_stale, set_generated_profile_summary
from .constants import u
from .state import _set_stream_phases, ensure_builder_state
SUMMARY_APPLY_MODULE = "builder_summary_apply"
_REGEN_VERB = re.compile(r"(重新|再次|再来|重写|更新|刷新|换|再).{0,6}总结")
_ASK_GENERATE = re.compile(r"(?:帮我|请|我要|我想|给我).{0,6}生成.{0,4}总结|^生成.{0,4}总结")
def requests_summary_regen(content: str) -> bool:
""""重新生成个人总结"类指令;提供总结原文或陈述事实的消息不算。"""
normalized = re.sub(r"[\s,,。;;!?]", "", content.casefold())
if "总结" not in normalized or "总结是" in normalized:
return False
return bool(_REGEN_VERB.search(normalized) or _ASK_GENERATE.search(normalized))
def finish_transition(profile: dict[str, Any], resume_content: dict[str, Any]) -> Transition:
""""完成并生成个人总结":已有总结也强制重生成(先标记 stale 放行闸门)。"""
_set_stream_phases(profile, "saving")
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
assistant_turn(
u("好的,已根据当前简历预览信息生成个人总结,内容仍可在右侧预览中编辑。"),
[],
mode=ComposerMode.CHAT,
),
resume_content=mark_profile_summary_stale(resume_content),
generate_profile_summary=True,
)
def summary_regen_turn(agent: Any, profile: dict[str, Any], resume_content: dict[str, Any]) -> Transition:
"""对话"重新生成个人总结":立即生成候选文本,确认后经组件事件写入简历。"""
try:
proposal = agent.profile_summary_generator.generate(resume_content)
except Exception:
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
assistant_turn(u("个人总结生成失败,请稍后重试。"), [], mode=ComposerMode.CHAT),
)
ensure_builder_state(profile)["pending_summary_proposal"] = proposal
card = component(
"ChoiceChips",
module=SUMMARY_APPLY_MODULE,
options=[
{"value": "apply", "label": u("写入简历")},
{"value": "dismiss", "label": u("暂不写入")},
],
)
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
assistant_turn(u("好的,已根据当前简历内容重新生成个人总结:\n") + proposal, [card], mode=ComposerMode.CHAT),
)
def apply_summary_proposal(
profile: dict[str, Any], action: str, payload: dict[str, Any], resume_content: dict[str, Any]
) -> Transition:
"""确认卡事件:apply 写入候选总结(随组件事件合并进简历),否则丢弃。"""
if action != "select":
raise FSMError("invalid_builder_choice", "Choose whether to apply the summary", status_code=422)
proposal = str(ensure_builder_state(profile).pop("pending_summary_proposal", "") or "").strip()
if str(payload.get("value") or "") != "apply" or not proposal:
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
assistant_turn(u("好的,保留当前个人总结。"), [], mode=ComposerMode.CHAT),
)
content = set_generated_profile_summary(mark_profile_summary_stale(resume_content), proposal, replace_stale=True)
return Transition(
Stage.BUILDER_CONVERSATION,
profile,
assistant_turn(u("已写入新的个人总结,仍可在右侧预览中编辑。"), [], mode=ComposerMode.CHAT),
lifecycle="confirmed",
resume_content=content,
)
+98
View File
@@ -0,0 +1,98 @@
"""Turn and card builders for the Builder conversation."""
from __future__ import annotations
from typing import Any
from ..fsm import anchor_field_specs, assistant_turn, component
from ..models import ComposerMode
from .constants import SECTION_HEADINGS, SECTION_PRIORITY, u
from .state import _completed_sections, _set_stream_phases, ensure_builder_state
def recommended_section(profile: dict[str, Any], resume_content: dict[str, Any] | None = None) -> str:
priorities = SECTION_PRIORITY.get(str(profile.get("job_type") or "campus"), SECTION_PRIORITY["campus"])
completed = _completed_sections(resume_content or {})
return next((section for section in priorities if section not in completed), priorities[0])
def welcome_turn(
profile: dict[str, Any],
resume_id: str | None,
*,
imported: bool = False,
resume_content: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
state = ensure_builder_state(profile)
state["imported"] = imported
if imported:
turn = assistant_turn("简历已经导入。你想先修改哪一段经历,还是补充一段新的内容?", [], mode=ComposerMode.CHAT)
_set_stream_phases(profile, "suggesting_next")
return profile, turn
turn = _next_step_turn(recommended_section(profile, resume_content))
_set_stream_phases(profile, "suggesting_next")
return profile, turn
def _next_step_turn(section: str, *, prefix: str = "") -> dict[str, Any]:
lead = f"{prefix} " if prefix else ""
return assistant_turn(
f"{lead}接下来建议补充{SECTION_HEADINGS[section]}。你想继续这类经历,还是改选其他经历类型?",
[_section_choice_card(section)],
mode=ComposerMode.CHAT,
)
def _section_choice_card(recommended: str) -> dict[str, Any]:
return component(
"ChoiceChips",
module="builder_next_section",
title=u("选择下一步"),
description=f"{u('建议先补充')}{SECTION_HEADINGS[recommended]}{u(',也可以换一种经历、推荐岗位技能,或直接完成。')}",
options=[
*({"value": kind, "label": heading} for kind, heading in SECTION_HEADINGS.items()),
{"value": "builder_recommend_skills", "label": u("推荐岗位技能")},
{"value": "builder_finish", "label": u("完成并生成个人总结")},
],
value=recommended,
)
def _entry_choice_card(matches: list[tuple[dict[str, Any], dict[str, Any]]]) -> dict[str, Any]:
options = [{"value": str(entry.get("id") or ""), "label": _entry_label(entry, str(section.get("kind") or ""))} for section, entry in matches]
return component("ChoiceChips", module="builder_entry_select", title="选择要修改的经历", options=options)
def _record_card(section: str, *, title: str, value: dict[str, Any] | None = None, entry_id: Any = None, skippable: bool = False) -> dict[str, Any]:
props: dict[str, Any] = {
"module": "builder_identity",
"record_type": section,
"title": title,
"fields": anchor_field_specs(section),
"show_description": False,
"require_description": False,
"skippable": skippable,
"skip_label": "稍后补充",
}
if value:
props["value"] = value
if entry_id:
props["entry_id"] = entry_id
return component("RecordFields", **props)
def _fact_prompt(section: str) -> str:
prompts = {
"education": "请补充这段教育经历的真实信息,例如课程项目、竞赛、实践或学习成果;没有也可以直接说没有。",
"campus_experience": "请补充你实际承担的职责,或规模和结果中的一两项;没有也可以直接说没有。",
"project_experience": "请补充个人动作、方法或工具,以及交付物、规模或量化结果中的一两项;没有也可以直接说没有。",
"work_experience": "请补充个人动作、方法或工具,以及交付物、规模或量化结果中的一两项;没有也可以直接说没有。",
"internship_experience": "请补充个人动作、方法或工具,以及交付物、规模或量化结果中的一两项;没有也可以直接说没有。",
}
return prompts[section]
def _entry_label(entry: dict[str, Any], kind: str) -> str:
identity = next((str(entry.get(key) or "").strip() for key in ("school", "company", "project_name", "organization") if str(entry.get(key) or "").strip()), SECTION_HEADINGS.get(kind, "经历"))
role = next((str(entry.get(key) or "").strip() for key in ("major", "position", "project_role", "role") if str(entry.get(key) or "").strip()), "")
return f"{identity} · {role}" if role else identity