generated from kgod/ai-review-template
167 lines
7.1 KiB
Python
167 lines
7.1 KiB
Python
"""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)
|