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