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