""" 意图分析器 - 改写 + 双层判断 核心功能: 1. 问题改写:指代消解、省略补全 2. 双层判断: - 历史是否可答 - 是否需要外部知识 3. 输出:决策(use_context / need_retrieval) 4. 语义缓存:相似问题复用结果 设计原则: - 使用轻量 LLM 完成改写和判断 - 替代硬编码规则,更灵活可维护 - 语义缓存减少 LLM 调用 """ import json import logging import hashlib import threading from dataclasses import dataclass, asdict from typing import List, Dict, Optional, Tuple from config import INTENT_TEMPERATURE, INTENT_MAX_TOKENS, INTENT_HISTORY_WINDOW, INTENT_MODEL logger = logging.getLogger(__name__) @dataclass class IntentAnalysis: """意图分析结果""" rewritten_query: str # 改写后的完整问题 use_context: bool # 是否使用上下文回答 need_retrieval: bool # 是否需要检索 confidence: float # 置信度 reason: str # 判断理由 context_images: List[dict] # 上下文中的图片信息 sub_queries: List[str] # 用于检索的子查询列表 intent: str # 意图类型: factual/comparison/reasoning/instruction/other def to_dict(self) -> dict: """转换为字典(用于缓存)""" return { "rewritten_query": self.rewritten_query, "use_context": self.use_context, "need_retrieval": self.need_retrieval, "confidence": self.confidence, "reason": self.reason, "context_images": self.context_images, "sub_queries": self.sub_queries, "intent": self.intent } @classmethod def from_dict(cls, data: dict) -> "IntentAnalysis": """从字典创建(用于缓存读取)""" return cls( rewritten_query=data.get("rewritten_query", ""), use_context=data.get("use_context", False), need_retrieval=data.get("need_retrieval", True), confidence=data.get("confidence", 0.5), reason=data.get("reason", ""), context_images=data.get("context_images", []), sub_queries=data.get("sub_queries", []), intent=data.get("intent", "factual") ) class IntentAnalyzer: """ 意图分析器 使用 LLM 一次调用完成: 1. 问题改写(指代消解) 2. 意图判断(是否需要检索) """ SYSTEM_PROMPT = """你是一个意图分析助手。你的任务是分析用户问题,判断它需要什么类型的回答。 ## 任务说明 根据对话历史和当前用户消息,输出一个 JSON 对象,包含以下字段: 1. **rewritten_query**: 改写后的完整问题 - **指代消解**:如果问题包含指代(如"这两张图片"、"继续说"),将其改写为完整、独立的问题 - 例如:"分析一下这两张图片" → "分析一下对话历史中提到的图片" - **追问补全**:如果问题是省略式追问(省略了上一轮讨论的主题实体),必须补全为完整问题 - 判断方法:当前问题缺少主语/宾语,且对话历史中可以推断出省略的实体 - 补全方法:从上一轮用户问题中提取主题实体,与追问组合成完整问题 - 例如: - 上一轮问"吸烟点C1类是什么区?",追问"有完整表格吗?" → "吸烟点C1类有完整表格吗?" - 上一轮问"三峡工程的投资情况",追问"建设地点在哪?" → "三峡工程的建设地点在哪?" - 上一轮问"货源投放有哪些原则?",追问"具体内容是什么?" → "货源投放原则的具体内容是什么?" - 如果问题本身已经完整且独立,直接返回原文 2. **use_context**: 布尔值 - true: 问题依赖历史对话中的信息,答案已经在历史回答中 - false: 问题是新问题,需要新的回答 3. **need_retrieval**: 布尔值 - true: 需要从知识库检索信息才能回答 - false: 可以直接使用历史上下文或自己的知识回答 4. **reason**: 简短说明判断理由 5. **sub_queries**: 用于检索的子查询列表 - 对比类(intent="comparison"):生成最多3个子查询 * 关于A的一个最佳检索查询 * 关于B的一个最佳检索查询 * 直接对比A和B的检索查询 - 推理类(intent="reasoning"):生成最多2个子查询 * 原问题的检索查询 * 一个补充角度的检索查询(如原因、背景、影响等),帮助获取更全面的上下文 - 其他类(factual/instruction/other):严格只生成1个子查询 * 子查询应基于 rewritten_query(改写后的完整问题),而非用户原始输入 * 例如:追问"有完整表格吗?"改写为"吸烟点C1类有完整表格吗?"后,子查询应为"吸烟点C1类的完整表格内容" - 不要为同一实体生成语义重叠的查询 - 子查询应保持原问题的关键词,长度20-60字符为宜 6. **intent**: 意图分类(5种类型,请严格按以下定义判断) **"comparison"(对比查询)** —— 明确对比两个或多个事物的差异 - 关键特征:问题中出现两个以上被比较的对象,且问的是它们之间的差异、异同或优劣 - 关键词:区别、不同、差异、对比、相比、各自区别、A和B有什么不同、哪个更好 - ⚠️ 注意:问"如何定义和区分"不是在对比,是在问定义标准 → factual - ⚠️ 注意:"各自有什么"如果只是分别描述而非对比差异 → factual **"reasoning"(推理查询)** —— 需要因果分析、原因推断或影响评估 - 关键特征:问题要求分析"为什么"、推导因果关系、或评估某事的影响/后果 - 关键词:为什么、原因、导致、影响、后果、为何、怎样导致、如何影响、分析原因 **"instruction"(操作指导)** —— 询问具体操作步骤、执行方法或实施流程 - 关键特征:问题要求的是"怎么做某事"——需要具体的步骤、方法或可执行的指导 - 关键词:怎么做、如何操作、如何实施、步骤是什么、操作流程、配置方法、如何申请、如何办理、升级路径、需要满足哪些条件 - ⚠️ 注意:问"某机制是怎样的"如果涉及评价标准和执行流程 → instruction **"factual"(事实查询)** —— 查询事实、定义、标准、规定、数据、枚举项 - 关键特征:问题要求的是文档中明确记载的事实信息 - 关键词:是什么、有哪些、包括什么、多少、规定、标准、要求、内容、原则、办法、类型、种类、评分标准、占比限制、时限 - 这是最常见的类型——问"某东西包含哪些内容/原则/办法/标准"都属于事实查询 **"other"(其他)** —— 闲聊、问候、不属于以上类型的问题 ## intent 判断流程(按优先级依次检查) 1. 问题是否在对比两个事物的差异?→ comparison 2. 问题是否在问"为什么"或分析原因/影响?→ reasoning 3. 问题是否在问具体怎么做、操作步骤、申请流程、升级路径?→ instruction 4. 以上都不是 → factual(大多数知识库查询属于此类) ### 常见易混淆情况: - "如何定义和区分" → factual(问的是定义和标准,不是对比差异) - "有哪些原则/办法/标准" → factual(枚举事实,不是操作指导) - "如何优化/如何实施" → instruction(问的是具体做法和步骤) - "评价标准和退出机制是怎样的" → instruction(涉及评价流程和退出流程) - "升级路径是什么,需要满足哪些条件" → instruction(涉及升级步骤和条件) - "A和B有什么区别/不同" → comparison(明确对比两者差异) - "分别包含什么/各自含义" → factual(分别描述,不是对比差异) ## 判断原则(两个判断是独立的!) ### use_context = true 的情况: - 用户引用了历史对话中的内容("这两张图片"、"上面的数据"、"继续说") - 问题是对历史回答的追问或深入询问 - 答案可以从历史回答中直接得出 - **重复提问**:用户再次问相同问题 ### need_retrieval = true 的情况: - 问题询问新的事实、数据、流程 - 问题提到了具体的图号、表号、章节 - 问题需要知识库中的文档来回答 - 用户明确要求查看知识库内容 ## ⚠️ 重要规则:重复提问必须检索! **当用户重复提问相同或相似问题时:** - `use_context` = true(因为确实依赖历史上下文) - `need_retrieval` = **true**(必须重新检索!) **原因:** 1. 用户重复提问说明之前的回答不满意或不正确 2. 历史回答可能包含错误信息,不能直接复用 3. 必须重新检索确保回答准确性 **判断重复提问的方法:** - 当前问题与历史中的用户问题语义相同或高度相似 - 例如:用户之前问"发电量",现在又问"发电量统计" - 例如:用户之前问过某问题,现在换个方式再问 ### 两者都可以为 false: - 简单的闲聊、问候 - 基于附件/图片的视觉分析请求(图片信息在上下文中) - 常识性问题 ## 输出格式 只输出一个 JSON 对象,不要其他内容: ```json { "rewritten_query": "改写后的问题", "use_context": true/false, "need_retrieval": true/false, "reason": "判断理由", "sub_queries": ["检索子查询1", "检索子查询2"], "intent": "factual" } ``` 示例: - 对比类:"新现代终端和普通现代终端有什么区别?" → sub_queries: ["新现代终端的定义和特点", "普通现代终端的定义和特点", "新现代终端与普通现代终端的区别"], intent: "comparison" - 推理类:"为什么市场状态评估结果与实际投放不一致?" → sub_queries: ["市场状态评估结果与实际投放不一致的原因"], intent: "reasoning" - 操作指导:"从普通终端升级到加盟终端需要怎么做?" → sub_queries: ["从普通终端升级到加盟终端的流程和条件"], intent: "instruction" - 事实类(枚举):"货源投放工作有哪些原则?" → sub_queries: ["货源投放工作的原则"], intent: "factual" - 事实类(定义):"市场状态有哪几种类型?" → sub_queries: ["市场状态的类型和评分标准"], intent: "factual" """ def __init__(self, model: str = None, use_cache: bool = True): """ 初始化意图分析器 Args: model: 使用的 LLM 模型(默认从配置读取) use_cache: 是否启用语义缓存 """ self.model = model self.use_cache = use_cache self._client = None self._cache = None self._embedding_model = None # 精确匹配缓存(后备方案,不依赖 embedding) self._exact_cache: Dict[str, IntentAnalysis] = {} self._exact_cache_max = 500 def _get_client(self): """获取 LLM 客户端(百炼快速模型)""" if self._client is None: from config import get_intent_client self._client = get_intent_client() return self._client def _get_cache(self): """获取语义缓存""" if self._cache is None and self.use_cache: try: from core.semantic_cache import get_semantic_cache self._cache = get_semantic_cache() except Exception as e: logger.warning(f"语义缓存初始化失败: {e}") self._cache = None return self._cache def _get_embedding(self, text: str): """获取文本向量,优先复用 engine 已加载的模型""" if self._embedding_model is None: # 优先从 engine 复用已加载的 embedding 模型 try: from core.engine import get_engine engine = get_engine() self._embedding_model = engine.embedding_model if self._embedding_model is not None: logger.info("意图分析缓存: 复用 engine 的 embedding 模型") else: logger.warning("意图分析缓存: engine.embedding_model 为 None") except Exception as e: # fallback: 单独加载 logger.warning(f"意图分析缓存: 无法获取 engine 的 embedding 模型: {e},尝试单独加载") try: from sentence_transformers import SentenceTransformer self._embedding_model = SentenceTransformer('models/bge-base-zh-v1.5') logger.info("意图分析缓存: 单独加载 embedding 模型成功") except Exception as e2: logger.warning(f"意图分析缓存: 嵌入模型初始化失败: {e2}") return None try: import numpy as np emb = self._embedding_model.encode(text) return np.array(emb, dtype='float32') except Exception as e: logger.warning(f"向量化失败: {e}") return None def analyze( self, query: str, history: List[dict], context_images: List[dict] = None ) -> IntentAnalysis: """ 分析用户意图 Args: query: 用户当前问题 history: 对话历史 [{"role": "user/assistant", "content": "...", "metadata": {...}}] context_images: 上下文中的图片信息 Returns: IntentAnalysis: 分析结果 """ # 构建历史摘要 history_summary = self._build_history_summary(history, context_images) # 1. 精确匹配缓存(快速路径,不依赖 embedding) exact_key = self._build_cache_key(query, history) if exact_key in self._exact_cache: logger.info(f"意图分析精确缓存命中: {exact_key[:50]}") return self._exact_cache[exact_key] # 2. 尝试从语义缓存获取 cache = self._get_cache() if cache: # 使用 query + 历史关键信息作为缓存键 cache_key = self._build_cache_key(query, history) cache_emb = self._get_embedding(cache_key) if cache_emb is not None: cached = cache.get(cache_emb) # 仅接受明确标记的意图缓存,避免未来新增缓存类型时交叉命中。 if cached and cached.get("cache_type") == "intent_analysis": logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}") return IntentAnalysis.from_dict(cached) else: logger.debug(f"意图分析缓存未命中,缓存状态: {cache.get_stats()}") else: logger.warning("意图分析缓存: _get_embedding 返回 None,跳过缓存") else: logger.warning("意图分析缓存: _get_cache 返回 None,缓存不可用") # 构建 prompt user_prompt = f"""## 对话历史 {history_summary} ## 当前用户消息 {query} 请分析用户的意图,输出 JSON 对象。""" try: client = self._get_client() model = self.model or self._get_default_model() from core.llm_utils import call_llm content = call_llm( client, "", model, temperature=INTENT_TEMPERATURE, max_tokens=INTENT_MAX_TOKENS, messages=[ {"role": "system", "content": self.SYSTEM_PROMPT}, {"role": "user", "content": user_prompt} ] ) # 解析 JSON result = self._parse_json(content) if result: # 提取子查询,默认使用原始查询 sub_queries = result.get("sub_queries", [query]) if not sub_queries: sub_queries = [query] # 非对比类查询强制截断子查询(LLM 可能不遵守规则) intent_type = result.get("intent", "factual") if intent_type == "comparison": # 对比类:确保原始查询始终作为第一个子查询(避免拆分后丢失核心信息) if query not in sub_queries: sub_queries = [query] + sub_queries sub_queries = sub_queries[:3] # 最多3个 elif intent_type == "reasoning": sub_queries = sub_queries[:2] # 推理类最多2个子查询 elif len(sub_queries) > 1: sub_queries = sub_queries[:1] # 其他类只保留1个 analysis = IntentAnalysis( rewritten_query=result.get("rewritten_query", query), use_context=result.get("use_context", False), need_retrieval=result.get("need_retrieval", True), confidence=0.9, reason=result.get("reason", ""), context_images=context_images or [], sub_queries=sub_queries, intent=intent_type ) # 存入语义缓存(标记类型,避免与 RAG 回答缓存混淆) if cache and cache_emb is not None: cache_data = analysis.to_dict() cache_data["cache_type"] = "intent_analysis" cache.set(cache_emb, cache_data) # 存入精确匹配缓存 if len(self._exact_cache) < self._exact_cache_max: self._exact_cache[exact_key] = analysis return analysis except Exception as e: logger.warning(f"意图分析失败: {e},使用默认值") # 默认值:需要检索 return IntentAnalysis( rewritten_query=query, use_context=False, need_retrieval=True, confidence=0.5, reason="意图分析失败,默认走检索流程", context_images=context_images or [], sub_queries=[query], intent="factual" ) def _build_cache_key(self, query: str, history: List[dict]) -> str: """ 构建缓存键 结合 query 和历史关键信息,确保相同上下文下的相同问题能命中缓存 """ parts = [query] # 添加最近历史的关键信息 if history: for msg in history[-2:]: # 最近1轮 role = msg.get("role", "") content = msg.get("content", "")[:100] # 截断 parts.append(f"{role[:1]}:{content}") return " | ".join(parts) def _build_history_summary( self, history: List[dict], context_images: List[dict] = None ) -> str: """ 构建历史摘要 Args: history: 对话历史 context_images: 上下文中的图片 Returns: 历史摘要文本 """ if not history: return "(无历史对话)" import re parts = [] # 提取最近 3 轮对话 recent_history = history[-INTENT_HISTORY_WINDOW:] for msg in recent_history: role = "用户" if msg.get("role") == "user" else "助手" content = msg.get("content", "") original_content = content # 保留原始内容用于结构化提取 # 截断过长的内容 if len(content) > 500: content = content[:500] + "..." parts.append(f"【{role}】{content}") # 提取结构化信息(从 assistant 消息中提取章节、表格、来源等) metadata = msg.get("metadata", {}) if isinstance(metadata, dict): # 已有的图片提取 images = metadata.get("images", []) if images: for img in images[:3]: if isinstance(img, dict): desc = img.get("description", "")[:100] img_type = img.get("type", "图片") parts.append(f" └─ {img_type}: {desc}") # 来源文件提取 sources = metadata.get("sources", []) if sources: source_names = [] for s in sources[:3]: if isinstance(s, dict): name = s.get("source", "") or s.get("name", "") if name: source_names.append(name) elif isinstance(s, str): source_names.append(s) if source_names: parts.append(f" └─ 来源文件: {', '.join(source_names)}") # collections(检索知识库)提取 colls = metadata.get("collections", []) if colls: parts.append(f" └─ 检索知识库: {', '.join(colls)}") # 从 assistant 原始内容中提取章节路径和表格结构 if role == "助手" and original_content: # 提取章节路径:━ xxx ━ 格式 sections = re.findall(r'━\s*(.+?)\s*━', original_content) if sections: unique_sections = list(dict.fromkeys(sections)) # 去重保序 parts.append(f" └─ 涉及章节: {'; '.join(unique_sections[:3])}") # 提取表格列名:| A | B | C | 格式的表头行 table_headers = re.findall(r'^\|\s*(.+?)\s*\|', original_content, re.MULTILINE) if table_headers: # 取第一个表格的列名 first_header = table_headers[0] cols = [c.strip() for c in first_header.split('|') if c.strip()] # 排除分隔符行(--- 格式) if cols and not all(re.match(r'^[-:]+$', c) for c in cols): parts.append(f" └─ 含表格,列名: {', '.join(cols[:6])}") # 添加图片上下文 if context_images: parts.append("\n【上下文中的图片】") for img in context_images[:5]: desc = img.get("description", "")[:100] img_type = img.get("type", "图片") parts.append(f" - {img_type}: {desc}") return "\n".join(parts) def _parse_json(self, content: str) -> Optional[dict]: """解析 JSON 响应""" # 尝试直接解析 try: parsed = json.loads(content) # mimo 等模型可能返回 JSON 数组而非对象,取第一个元素 if isinstance(parsed, list) and len(parsed) > 0: first = parsed[0] if isinstance(first, dict): return first return None if isinstance(parsed, dict): return parsed return None except json.JSONDecodeError: pass # 尝试提取 JSON 块 import re json_match = re.search(r'\{[\s\S]*\}', content) if json_match: try: return json.loads(json_match.group()) except json.JSONDecodeError: pass return None def _get_default_model(self) -> str: """获取意图分析专用模型""" try: from config import INTENT_MODEL return INTENT_MODEL except (ImportError, NameError): return INTENT_MODEL # ==================== 便捷函数 ==================== _analyzer = None _analyzer_lock = threading.Lock() # 单例创建锁,防止多线程竞态条件 def get_intent_analyzer() -> IntentAnalyzer: """获取意图分析器单例(双重检查锁定,线程安全)""" global _analyzer if _analyzer is None: with _analyzer_lock: if _analyzer is None: _analyzer = IntentAnalyzer() return _analyzer def analyze_intent( query: str, history: List[dict], context_images: List[dict] = None ) -> IntentAnalysis: """ 便捷函数:分析用户意图 Args: query: 用户问题 history: 对话历史 context_images: 上下文中的图片 Returns: IntentAnalysis: 分析结果 """ analyzer = get_intent_analyzer() return analyzer.analyze(query, history, context_images) # ==================== 测试 ==================== if __name__ == "__main__": import sys if sys.platform == 'win32': sys.stdout.reconfigure(encoding='utf-8') # 测试用例 test_cases = [ { "query": "分析一下这两张图片", "history": [ {"role": "user", "content": "三峡工程有什么图片"}, {"role": "assistant", "content": "我找到了三峡大坝的示意图...", "metadata": {"images": [{"type": "示意图", "description": "三峡大坝全景"}]}}, ], "expected": {"use_context": True, "need_retrieval": False} }, { "query": "三峡水库补水调度统计", "history": [], "expected": {"use_context": False, "need_retrieval": True} }, { "query": "继续说", "history": [ {"role": "user", "content": "介绍一下三峡工程"}, {"role": "assistant", "content": "三峡工程是..."}, ], "expected": {"use_context": True, "need_retrieval": False} }, ] print("=" * 60) print("意图分析器测试") print("=" * 60) analyzer = IntentAnalyzer() for i, case in enumerate(test_cases, 1): print(f"\n--- 测试 {i} ---") print(f"问题: {case['query']}") result = analyzer.analyze(case["query"], case["history"]) print(f"改写后: {result.rewritten_query}") print(f"use_context: {result.use_context}") print(f"need_retrieval: {result.need_retrieval}") print(f"理由: {result.reason}") print(f"子查询: {result.sub_queries}") print(f"意图: {result.intent}")