feat: builder 简历生成 + 轻度优化 + 简历导入交付副本

自内部仓库剥离深度优化与 RAG 知识库后的交付版本:
- Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强)
- 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护
- 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏
- PostgreSQL 运行时 + Alembic 迁移链

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hyp
2026-08-05 11:08:30 +08:00
co-authored by Claude
commit ae2d9b128d
191 changed files with 27719 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
"""Chat intent classifier: LLM primary, legacy keyword rules as degraded fallback.
The LLM only *proposes* an intent from the fixed registry in chat_intents.py;
handler binding stays deterministic in code. RuleBasedChatIntentClassifier mirrors
the legacy keyword routing so the conversation keeps working when the LLM is down.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Protocol, runtime_checkable
from .builder_conversation.predicates import (
_is_no_information_reply,
_is_revision_instruction,
_requested_section,
_requests_identity_change,
_requests_new_entry,
)
from .chat_intents import (
CHAT_INTENT_REGISTRY_VERSION,
INTENT_DESCRIPTIONS,
INTENT_FEWSHOTS,
ChatIntent,
ChatTurnClassification,
ExtractedFact,
)
from .llm_services import OpenAICompatibleStructuredClient, log_ai_event
from .settings import Settings
_ENTRY_LABEL_KEYS = ("company", "project_name", "school", "organization", "title", "name", "position", "role")
_CHITCHAT = {"可以", "好的", "", "谢谢", "感谢", "继续", "没问题", "知道了", "", "ok", "okay"}
_QUESTION_TOKENS = ("?", "", "", "怎么", "如何", "为什么", "", "能不能", "可以不可以")
_EDIT_TOKENS = ("修改", "编辑", "调整", "改一下", "改下")
@runtime_checkable
class ChatIntentClassifier(Protocol):
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification: ...
def build_chat_state_summary(profile: dict[str, Any], state: dict[str, Any] | None = None) -> dict[str, Any]:
"""Compact, metadata-only snapshot fed to the classifier (never full resume text)."""
state = state or {}
entries: list[dict[str, str]] = []
resume_content = profile.get("resume_content") or {}
for section in resume_content.get("sections") or []:
if not isinstance(section, dict):
continue
kind = str(section.get("kind") or "")
for entry in section.get("items") or []:
if not isinstance(entry, dict):
continue
label = next(
(str(entry.get(key)).strip() for key in _ENTRY_LABEL_KEYS if str(entry.get(key) or "").strip()),
"",
)
entries.append({"section": kind, "label": label})
draft = state.get("draft") if isinstance(state.get("draft"), dict) else None
return {
"job_type": str(profile.get("job_type") or ""),
"target_position": str(profile.get("target_position") or ""),
"confirmed_entries": entries,
"draft_section": str(draft.get("section") or "") if draft else None,
}
def _intent_system_prompt() -> str:
lines = [
"你是简历对话的意图分类器。根据用户消息与对话状态,从固定意图集合中选择唯一意图。",
"facts 只能摘录或紧贴改写用户原话中的事实,不得编造用户没说过的内容。",
f"注册表版本: {CHAT_INTENT_REGISTRY_VERSION}",
"意图定义:",
]
lines += [f"- {intent.value}: {INTENT_DESCRIPTIONS[intent]}" for intent in ChatIntent]
lines.append("示例:")
lines += [f"- 消息: {shot['message']}{shot['intent'].value}" for shot in INTENT_FEWSHOTS]
return "\n".join(lines)
_INTENT_SYSTEM_PROMPT = _intent_system_prompt()
class RuleBasedChatIntentClassifier:
"""Legacy keyword routing, kept verbatim as the degraded path."""
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification:
content = message.strip()
normalized = re.sub(r"[\s,,。;;!?]", "", content.casefold())
if _is_no_information_reply(content):
return self._result(ChatIntent.NO_INFO)
if _is_revision_instruction(content):
return self._result(ChatIntent.REVISE_PROPOSAL, revision_instruction=content)
if _requests_identity_change(content):
return self._result(ChatIntent.EDIT_IDENTITY)
section = _requested_section(content)
if section or _requests_new_entry(content):
return self._result(ChatIntent.NEW_ENTRY, target_section=section)
if any(token in normalized for token in _EDIT_TOKENS):
hint = self._entry_hint(normalized, state_summary)
return self._result(ChatIntent.EDIT_ENTRY, target_entry_hint=hint)
if any(token in content for token in _QUESTION_TOKENS):
return self._result(ChatIntent.ASK_QUESTION, user_question=content)
parts = [part for part in re.split(r"[\s,,。;;!?]+", content.casefold()) if part]
if parts and all(part in _CHITCHAT for part in parts):
return self._result(ChatIntent.CHITCHAT)
facts = [ExtractedFact(text=content)] if content else []
return self._result(ChatIntent.PROVIDE_FACTS, facts=facts)
@staticmethod
def _entry_hint(normalized: str, state_summary: dict[str, Any]) -> str | None:
for entry in state_summary.get("confirmed_entries") or []:
label = str(entry.get("label") or "").strip()
squashed = re.sub(r"[\s,,。;;!?]", "", label.casefold())
if squashed and squashed in normalized:
return label
return None
@staticmethod
def _result(intent: ChatIntent, **fields: Any) -> ChatTurnClassification:
return ChatTurnClassification(intent=intent, confidence=0.4, reason="rule_keyword", **fields)
class LLMChatIntentClassifier:
def __init__(self, client: OpenAICompatibleStructuredClient) -> None:
self._client = client
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification:
return self._client.complete(
schema=ChatTurnClassification,
schema_name="chat_intent_classification",
system_prompt=_INTENT_SYSTEM_PROMPT,
payload={
"message": message,
"state_summary": state_summary,
"registry_version": CHAT_INTENT_REGISTRY_VERSION,
},
)
class FallbackChatIntentClassifier:
def __init__(self, primary: ChatIntentClassifier, fallback: ChatIntentClassifier) -> None:
self.primary = primary
self.fallback = fallback
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification:
try:
return self.primary.classify(message, state_summary=state_summary)
except Exception as exc:
log_ai_event(
"chat_intent_classification_failed",
level=logging.ERROR,
reason_code=getattr(exc, "reason_code", type(exc).__name__.lower()[:48]),
trace_id=getattr(exc, "trace_id", None),
exception=type(exc).__name__,
)
return self.fallback.classify(message, state_summary=state_summary)
def build_chat_intent_classifier(settings: Settings, client: Any | None = None) -> ChatIntentClassifier:
rules = RuleBasedChatIntentClassifier()
if not settings.use_openai:
return rules
completion = OpenAICompatibleStructuredClient(settings, client)
return FallbackChatIntentClassifier(LLMChatIntentClassifier(completion), rules)