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