"""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)