Files
ai-agent-admin/backend-fastapi/ai_platform/services/agent_service.py
T

1119 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
智能体服务
"""
import logging
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.models import (
Agent, AgentConversation, AgentMessage, AIWorkflow, LLMModel,
)
from .llm_service import LLMService
logger = logging.getLogger(__name__)
class AgentService:
"""
智能体服务
管理智能体的对话和推理
"""
def __init__(self, db: Optional[AsyncSession] = None):
self._db = db
self.llm_service = LLMService(db)
def _agent_collaboration_metadata(self, agent: Agent) -> Dict[str, Any]:
metadata = {
"agent_id": str(agent.id),
"agent_code": agent.code,
"agent_name": agent.name,
"collaboration_role": agent.name or agent.code,
"collaboration_mode": agent.mode,
}
if agent.model_id:
metadata["model_id"] = str(agent.model_id)
return {key: value for key, value in metadata.items() if value}
def _attach_agent_collaboration(self, event: Dict[str, Any], agent: Agent) -> Dict[str, Any]:
enriched = dict(event)
collaboration = dict(enriched.get("collaboration") or {})
for key, value in self._agent_collaboration_metadata(agent).items():
collaboration.setdefault(key, value)
enriched["collaboration"] = collaboration
communication = dict(enriched.get("communication") or {})
actor = dict(communication.get("actor") or {})
for key in (
"agent_id",
"agent_code",
"agent_name",
"collaboration_role",
"collaboration_mode",
"model_id",
):
if collaboration.get(key):
actor.setdefault(key, collaboration[key])
communication.setdefault("event", enriched.get("type") or "")
communication.setdefault("status", "failed" if enriched.get("type") == "error" else "completed")
communication.setdefault("channel", "agent_chat")
communication.setdefault("actor", actor)
communication.setdefault(
"message",
enriched.get("content")
or enriched.get("message")
or enriched.get("accumulated_content")
or "",
)
enriched["communication"] = communication
return enriched
@staticmethod
def _event_to_reasoning_step(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
event_type = event.get('type')
if event_type not in {
'action',
'annotation_reply',
'error',
'knowledge_retrieval',
'loop_complete',
'loop_iteration_complete',
'loop_iteration_error',
'loop_iteration_start',
'node_complete',
'node_start',
'observation',
'parallel_complete',
'parallel_start',
'thought',
'tool_call',
'tool_result',
'waiting_input',
}:
return None
collaboration = event.get('collaboration') or {}
communication = event.get('communication') or {}
config = event.get('waiting_config') or event.get('config') or {}
content = (
event.get('node_label')
or event.get('content')
or communication.get('message')
or config.get('title')
or config.get('question')
or event.get('node_type')
or event_type
)
step = {
'type': event_type,
'content': content,
'tool': event.get('tool') or '',
'params': event.get('params') or config,
'node_id': event.get('node_id') or '',
'node_type': event.get('node_type') or '',
'branch_id': event.get('branch_id') or collaboration.get('branch_id') or '',
'branch_label': event.get('branch_label') or collaboration.get('branch_label') or '',
'agent_code': collaboration.get('agent_code') or event.get('agent_code') or '',
'agent_name': collaboration.get('agent_name') or event.get('agent_name') or '',
'model': collaboration.get('model') or event.get('model') or '',
'model_id': collaboration.get('model_id') or event.get('model_id') or '',
'subflow_name': collaboration.get('subflow_name') or event.get('subflow_name') or '',
'from_subflow': bool(collaboration.get('from_subflow') or event.get('from_subflow')),
'collaboration_role': collaboration.get('collaboration_role') or '',
'collaboration_mode': collaboration.get('collaboration_mode') or event.get('collaboration_mode') or '',
'communication': communication,
'output': event.get('outputs') or event.get('output'),
'status': 'running' if event_type in {'node_start', 'parallel_start', 'waiting_input', 'loop_iteration_start'} else 'completed',
'timestamp': datetime.now().isoformat(),
}
return {key: value for key, value in step.items() if value not in (None, '', {}, [])}
async def chat(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
application_id: str = None,
form_code: str = None,
attachments: List[Dict] = None,
):
"""
与智能体对话(流式)
Args:
agent: 智能体
conversation: 对话
user_message: 用户消息
application_id: 子应用 ID(用于表单创建等场景)
form_code: 表单编码(从表单列表调用时传入)
attachments: 附件列表 [{id, type, name, url, mime_type, size}]
Yields:
事件流
"""
if agent.mode == 'autonomous':
async for event in self._chat_autonomous(agent, conversation, user_message, attachments):
yield self._attach_agent_collaboration(event, agent)
elif agent.mode == 'dialog_flow':
async for event in self._chat_dialog_flow(agent, conversation, user_message, application_id, form_code, attachments):
yield self._attach_agent_collaboration(event, agent)
else:
yield self._attach_agent_collaboration(
{'type': 'error', 'content': f'不支持的模式: {agent.mode}'},
agent,
)
async def _chat_autonomous(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
attachments: List[Dict] = None,
):
"""
自主规划模式对话
优先使用原生 Function Calling,如果模型不支持则回退到 ReAct 模式
Yields:
流式事件
"""
# 检查模型是否支持 Function Calling
model_id = await self._resolve_agent_model_id(agent)
supports_function_call = await self._check_model_supports_function_call(model_id)
if supports_function_call:
async for event in self._chat_autonomous_function_calling(agent, conversation, user_message, attachments):
yield event
else:
async for event in self._chat_autonomous_react(agent, conversation, user_message, attachments):
yield event
async def _check_model_supports_function_call(self, model_id) -> bool:
"""检查模型是否支持 Function Calling"""
if not self._db or not model_id:
return False
result = await self._db.execute(
select(LLMModel).where(LLMModel.id == model_id, LLMModel.is_deleted == False)
)
model = result.scalar_one_or_none()
return model.supports_function_call if model else False
async def _resolve_agent_model_id(self, agent: Agent) -> Optional[str]:
if not self._db:
return str(agent.model_id) if agent.model_id else None
if agent.model_id:
result = await self._db.execute(
select(LLMModel).where(
LLMModel.id == agent.model_id,
LLMModel.is_deleted == False,
LLMModel.is_active == True,
)
)
if result.scalar_one_or_none():
return str(agent.model_id)
result = await self._db.execute(
select(LLMModel)
.where(
LLMModel.is_deleted == False,
LLMModel.is_active == True,
LLMModel.model_type == "chat",
)
.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
)
model = result.scalars().first()
if not model:
return None
agent.model_id = model.id
await self._db.flush()
logger.info(
"Agent %s has no active model, fallback to default chat model %s",
getattr(agent, "code", agent.id),
model.id,
)
return str(model.id)
async def _chat_autonomous_function_calling(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
attachments: List[Dict] = None,
):
"""
自主规划模式对话(支持多模态附件)
Yields:
流式事件
"""
from ai_platform.providers.base import LLMMessage
start_time = time.time()
if not self._db:
yield {'type': 'error', 'content': '数据库会话未初始化'}
return
# 创建用户消息(包含附件)
# 附件格式: [{file_id, type, name, mime_type, size}]
stored_attachments = []
if attachments:
for att in attachments:
stored_attachments.append({
'file_id': att.get('file_id'),
'type': att.get('type', 'file'),
'name': att.get('name', ''),
'mime_type': att.get('mime_type', ''),
'size': att.get('size', 0),
})
user_msg = AgentMessage(
conversation_id=conversation.id,
role='user',
content=user_message,
attachments=stored_attachments,
status='completed',
)
self._db.add(user_msg)
await self._db.flush()
# 创建助手消息(pending 状态)
assistant_msg = AgentMessage(
conversation_id=conversation.id,
role='assistant',
content='',
status='pending',
)
self._db.add(assistant_msg)
await self._db.flush()
reasoning_steps: List[Dict[str, Any]] = []
yield self._attach_agent_collaboration({
'type': 'start',
'conversation_id': str(conversation.id),
'message_id': str(assistant_msg.id),
}, agent)
try:
# 构建系统提示词
system_prompt = self._build_system_prompt_simple(agent)
model_id = await self._resolve_agent_model_id(agent)
if not model_id:
raise ValueError('未找到可用的 chat 模型,请先在模型配置中启用一个模型')
event = self._attach_agent_collaboration({
'type': 'thought',
'content': '智能体开始分析用户需求并调用默认 chat 模型',
'model_id': model_id,
}, agent)
step = self._event_to_reasoning_step(event)
if step:
reasoning_steps.append(step)
assistant_msg.reasoning_steps = reasoning_steps.copy()
yield event
# 标注直接回复检查(高相似度时跳过 LLM,直接返回标注答案)
annotation_reply = await self._check_annotation_direct_reply(agent, user_message)
if annotation_reply:
final_answer = annotation_reply['answer']
elapsed_time = int((time.time() - start_time) * 1000)
assistant_msg.content = final_answer
assistant_msg.status = 'completed'
assistant_msg.elapsed_time = elapsed_time
yield {
'type': 'annotation_reply',
'content': f"标注直接回复 (相似度: {annotation_reply['score']:.2f})",
}
yield {
'type': 'answer',
'content': final_answer,
}
conversation.message_count = (conversation.message_count or 0) + 2
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
self._generate_title(conversation, user_message)
agent.message_count = (agent.message_count or 0) + 2
await self._db.commit()
yield {
'type': 'complete',
'message_id': str(assistant_msg.id),
'conversation_id': str(conversation.id),
'tokens_used': 0,
'elapsed_time': elapsed_time,
}
return
# 知识库检索增强(RAG
knowledge_context = await self._retrieve_knowledge_context(agent, user_message)
if knowledge_context:
system_prompt = self._inject_knowledge_context(system_prompt, knowledge_context)
event = {
'type': 'knowledge_retrieval',
'content': knowledge_context['summary'],
'result_count': knowledge_context['result_count'],
}
step = self._event_to_reasoning_step(event)
if step:
reasoning_steps.append(step)
assistant_msg.reasoning_steps = reasoning_steps.copy()
yield event
# 获取对话历史
history = await self._get_conversation_history(conversation, limit=10)
# 构建当前用户消息内容(支持多模态)
user_content = LLMMessage.create_multimodal_content(user_message, attachments)
history.append({'role': 'user', 'content': user_content})
# 构建消息
messages = [{'role': 'system', 'content': system_prompt}] + history
# 检查是否启用流式输出
enable_streaming = getattr(agent, 'enable_streaming', True)
model_id = await self._resolve_agent_model_id(agent)
if not model_id:
raise ValueError('未找到可用的 chat 模型,请先在模型配置中启用一个模型')
total_tokens = 0
final_answer = ''
if enable_streaming:
# 流式输出
accumulated_content = ''
async for chunk in self.llm_service.chat_stream(
model_id=model_id,
messages=messages,
temperature=agent.temperature or 0.7,
max_tokens=agent.max_tokens or 2048,
):
if chunk.content:
accumulated_content += chunk.content
yield {
'type': 'llm_chunk',
'content': chunk.content,
'accumulated_content': accumulated_content,
'model_id': model_id,
}
if chunk.is_finished:
total_tokens = chunk.total_tokens
final_answer = accumulated_content
else:
# 非流式输出
response = await self.llm_service.chat_async(
model_id=model_id,
messages=messages,
temperature=agent.temperature or 0.7,
max_tokens=agent.max_tokens or 2048,
)
total_tokens = response.total_tokens
final_answer = response.content
yield {
'type': 'answer',
'content': final_answer,
'model_id': model_id,
}
# 更新助手消息
elapsed_time = int((time.time() - start_time) * 1000)
assistant_msg.content = final_answer
assistant_msg.status = 'completed'
assistant_msg.total_tokens = total_tokens
assistant_msg.elapsed_time = elapsed_time
assistant_msg.reasoning_steps = reasoning_steps.copy()
# 更新对话统计
conversation.message_count = (conversation.message_count or 0) + 2
conversation.total_tokens = (conversation.total_tokens or 0) + total_tokens
# 自动生成标题(第一次对话时,且标题为默认值)
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
self._generate_title(conversation, user_message)
# 更新智能体统计
agent.message_count = (agent.message_count or 0) + 2
agent.total_tokens = (agent.total_tokens or 0) + total_tokens
await self._db.commit()
yield {
'type': 'complete',
'message_id': str(assistant_msg.id),
'conversation_id': str(conversation.id),
'tokens_used': total_tokens,
'elapsed_time': elapsed_time,
}
except Exception as e:
logger.exception(f'Agent chat error: {e}')
assistant_msg.status = 'failed'
assistant_msg.error_message = str(e)
error_step = self._event_to_reasoning_step({'type': 'error', 'content': str(e)})
if error_step:
reasoning_steps.append(error_step)
assistant_msg.reasoning_steps = reasoning_steps.copy()
await self._db.commit()
yield {
'type': 'error',
'content': str(e),
}
async def _chat_autonomous_react(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
attachments: List[Dict] = None,
):
"""
ReAct 模式(简化版,不使用工具,直接复用 function calling 逻辑)
Yields:
流式事件
"""
# 直接复用简化后的 function calling 逻辑
async for event in self._chat_autonomous_function_calling(agent, conversation, user_message, attachments):
yield event
async def _chat_dialog_flow(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
application_id: str = None,
form_code: str = None,
attachments: List[Dict] = None,
):
"""
对话流模式对话
直接复用工作流服务的执行逻辑,透传工作流事件
Args:
agent: 智能体
conversation: 对话
user_message: 用户消息
application_id: 子应用 ID(用于表单创建等场景)
form_code: 表单编码(从表单列表调用时传入)
attachments: 附件列表
Yields:
流式事件
"""
start_time = time.time()
if not self._db:
yield {'type': 'error', 'content': '数据库会话未初始化'}
return
# 创建用户消息(包含附件)
# 附件格式: [{file_id, type, name, mime_type, size}]
stored_attachments = []
if attachments:
for att in attachments:
stored_attachments.append({
'file_id': att.get('file_id'),
'type': att.get('type', 'file'),
'name': att.get('name', ''),
'mime_type': att.get('mime_type', ''),
'size': att.get('size', 0),
})
user_msg = AgentMessage(
conversation_id=conversation.id,
role='user',
content=user_message,
attachments=stored_attachments,
status='completed',
)
self._db.add(user_msg)
await self._db.flush()
# 创建助手消息
assistant_msg = AgentMessage(
conversation_id=conversation.id,
role='assistant',
content='',
status='pending',
)
self._db.add(assistant_msg)
await self._db.flush()
reasoning_steps: List[Dict[str, Any]] = []
yield {
'type': 'start',
'conversation_id': str(conversation.id),
'message_id': str(assistant_msg.id),
}
try:
from ai_platform.services.workflow_service import AIWorkflowService
workflow_service = AIWorkflowService(self._db)
# 检查是否有进行中的工作流运行
workflow_run_id = None
if conversation.extra_data:
workflow_run_id = conversation.extra_data.get('workflow_run_id')
final_content = ''
is_waiting = False
total_tokens = 0
current_run_id = workflow_run_id
# 获取对话历史(如果启用了记忆功能)
conversation_history = []
if agent.enable_memory:
conversation_history = await self._get_conversation_history(
conversation,
limit=agent.memory_window or 10
)
if workflow_run_id:
# 恢复工作流执行 - 使用异步方法
# 将附件内容(文件解析内容或图片OCR)整合到 user_input 中
enhanced_user_input = user_message
if attachments:
attachment_texts = []
for att in attachments:
text_content = att.get('text_content', '')
if text_content:
file_name = att.get('name', 'unknown')
attachment_texts.append(f"\n[附件: {file_name}]\n{text_content}")
if attachment_texts:
enhanced_user_input = user_message + '\n' + '\n'.join(attachment_texts)
async for event in workflow_service.resume_workflow_stream_async(
run_id=workflow_run_id,
user_input=enhanced_user_input,
conversation_history=conversation_history,
):
event_type = event.get('type')
if event_type == 'answer':
final_content = event.get('content', '')
elif event_type == 'llm_chunk':
accumulated = event.get('accumulated_content', '')
if accumulated:
final_content = accumulated
elif event_type == 'node_complete':
output = (event.get('outputs') or {}).get('output', '')
if output:
final_content = output
elif event_type == 'waiting_input':
is_waiting = True
elif event_type == 'complete':
total_tokens = event.get('total_tokens', 0)
output = (event.get('outputs') or {}).get('output', '')
if output:
final_content = output
step = self._event_to_reasoning_step(event)
if step:
existing_index = next(
(
index
for index, item in enumerate(reasoning_steps)
if item.get('node_id')
and item.get('node_id') == step.get('node_id')
and item.get('type') == 'node_start'
and step.get('type') == 'node_complete'
),
-1,
)
if existing_index >= 0:
reasoning_steps[existing_index] = step
else:
reasoning_steps.append(step)
assistant_msg.reasoning_steps = reasoning_steps.copy()
yield event
else:
# 新对话,启动工作流 - 使用异步方法
if not agent.workflow_id:
yield {'type': 'error', 'content': '智能体未配置工作流'}
return
# 构建工作流输入变量
# 将附件内容(文件解析内容或图片OCR)整合到 user_input 中
enhanced_user_input = user_message
if attachments:
attachment_texts = []
for att in attachments:
text_content = att.get('text_content', '')
if text_content:
file_name = att.get('name', 'unknown')
attachment_texts.append(f"\n[附件: {file_name}]\n{text_content}")
if attachment_texts:
enhanced_user_input = user_message + '\n' + '\n'.join(attachment_texts)
workflow_inputs = await self._build_workflow_inputs(agent, enhanced_user_input)
if application_id:
workflow_inputs['application_id'] = application_id
if form_code:
workflow_inputs['form_code'] = form_code
# 调试日志
import logging
logger = logging.getLogger(__name__)
logger.info(f"[AgentService] form_code parameter: {form_code}")
logger.info(f"[AgentService] workflow_inputs: {workflow_inputs}")
async for event in workflow_service.run_workflow_stream_async(
workflow_id=str(agent.workflow_id),
inputs=workflow_inputs,
conversation_id=str(conversation.id),
conversation_history=conversation_history,
trigger_type='agent',
):
event_type = event.get('type')
if event_type == 'start':
current_run_id = event.get('run_id')
elif event_type == 'answer':
final_content = event.get('content', '')
elif event_type == 'llm_chunk':
accumulated = event.get('accumulated_content', '')
if accumulated:
final_content = accumulated
elif event_type == 'node_complete':
output = (event.get('outputs') or {}).get('output', '')
if output:
final_content = output
elif event_type == 'waiting_input':
is_waiting = True
# 保存工作流运行 ID 到对话元数据
if current_run_id:
conversation.extra_data = conversation.extra_data or {}
conversation.extra_data['workflow_run_id'] = current_run_id
elif event_type == 'complete':
total_tokens = event.get('total_tokens', 0)
output = (event.get('outputs') or {}).get('output', '')
if output:
final_content = output
step = self._event_to_reasoning_step(event)
if step:
existing_index = next(
(
index
for index, item in enumerate(reasoning_steps)
if item.get('node_id')
and item.get('node_id') == step.get('node_id')
and item.get('type') == 'node_start'
and step.get('type') == 'node_complete'
),
-1,
)
if existing_index >= 0:
reasoning_steps[existing_index] = step
else:
reasoning_steps.append(step)
assistant_msg.reasoning_steps = reasoning_steps.copy()
yield event
# 更新助手消息
elapsed_time = int((time.time() - start_time) * 1000)
if final_content:
assistant_msg.content = final_content
assistant_msg.status = 'completed'
assistant_msg.elapsed_time = elapsed_time
assistant_msg.total_tokens = total_tokens
assistant_msg.reasoning_steps = reasoning_steps.copy()
# 更新对话统计
conversation.message_count = (conversation.message_count or 0) + 2
conversation.total_tokens = (conversation.total_tokens or 0) + total_tokens
# 自动生成标题(第一次对话时,且标题为默认值)
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
self._generate_title(conversation, user_message)
# 如果工作流完成(非等待状态),清除对话元数据
if not is_waiting:
if conversation.extra_data:
conversation.extra_data.pop('workflow_run_id', None)
await self._db.commit()
yield {
'type': 'complete',
'elapsed_time': elapsed_time,
'tokens_used': total_tokens,
'total_tokens': total_tokens,
'conversation_id': str(conversation.id),
'message_id': str(assistant_msg.id),
}
except Exception as e:
logger.exception(f'Dialog flow error: {e}')
assistant_msg.status = 'failed'
assistant_msg.error_message = str(e)
error_step = self._event_to_reasoning_step({'type': 'error', 'content': str(e)})
if error_step:
reasoning_steps.append(error_step)
assistant_msg.reasoning_steps = reasoning_steps.copy()
await self._db.commit()
yield {
'type': 'error',
'content': str(e),
}
async def _build_workflow_inputs(self, agent: Agent, user_input: str) -> Dict[str, Any]:
workflow_inputs: Dict[str, Any] = {'user_input': user_input}
if not self._db or not agent.workflow_id:
return workflow_inputs
result = await self._db.execute(
select(AIWorkflow).where(
AIWorkflow.id == agent.workflow_id,
AIWorkflow.is_deleted == False,
)
)
workflow = result.scalar_one_or_none()
if not workflow:
return workflow_inputs
input_variables = workflow.input_variables or []
if any((item or {}).get('name') == 'request' for item in input_variables):
workflow_inputs.setdefault('request', user_input)
if any((item or {}).get('name') == 'issue_id' for item in input_variables):
workflow_inputs.setdefault('issue_id', self._extract_issue_id(user_input))
return workflow_inputs
@staticmethod
def _extract_issue_id(text: str) -> str:
import re
match = re.search(r'\b[A-Z][A-Z0-9]+-\d+\b', text or '', re.IGNORECASE)
return match.group(0).upper() if match else 'MANUAL-1'
def _build_system_prompt_simple(self, agent: Agent) -> str:
"""构建简化的系统提示词"""
# 基础提示词
base_prompt = agent.system_prompt
# 如果没有系统提示词,从人设生成
if not base_prompt and agent.persona:
base_prompt = self._generate_prompt_from_persona(agent.persona)
if not base_prompt:
base_prompt = "你是一个智能助手,可以帮助用户完成各种任务。回答要简洁明了,使用中文。"
return base_prompt
def _generate_prompt_from_persona(self, persona: Dict[str, Any]) -> str:
"""从人设配置生成提示词"""
parts = []
if persona.get('role'):
parts.append(persona['role'])
if persona.get('personality'):
personalities = persona['personality']
if isinstance(personalities, list):
parts.append(f"你的性格特点是:{', '.join(personalities)}")
if persona.get('skills'):
skills = persona['skills']
if isinstance(skills, list):
parts.append(f"你擅长:{', '.join(skills)}")
if persona.get('background'):
parts.append(persona['background'])
if persona.get('constraints'):
constraints = persona['constraints']
if isinstance(constraints, list):
parts.append("注意事项:\n" + "\n".join(f"- {c}" for c in constraints))
return "\n\n".join(parts)
async def _get_conversation_history(
self,
conversation: AgentConversation,
limit: int = 10,
) -> List[Dict[str, str]]:
"""获取对话历史"""
if not self._db:
return []
result = await self._db.execute(
select(AgentMessage).where(
AgentMessage.conversation_id == conversation.id,
AgentMessage.is_deleted == False,
AgentMessage.role.in_(['user', 'assistant']),
AgentMessage.status == 'completed'
).order_by(AgentMessage.sys_create_datetime.desc()).limit(limit)
)
messages = result.scalars().all()
history = []
for msg in reversed(list(messages)):
history.append({
'role': msg.role,
'content': msg.content,
})
return history
def _generate_title(self, conversation, first_message: str):
"""自动生成对话标题"""
# 简单截取前 20 个字符作为标题
title = first_message[:20]
if len(first_message) > 20:
title += '...'
conversation.title = title
async def _check_annotation_direct_reply(
self,
agent: Agent,
query: str,
) -> Optional[Dict[str, Any]]:
"""
检查标注直接回复(Dify 风格)
当启用标注回复且匹配到高相似度标注时,直接返回标注答案,跳过 LLM 调用。
Returns:
{'answer': str, 'score': float, 'question': str} 或 None
"""
knowledge_base_ids = getattr(agent, 'knowledge_base_ids', None) or []
if not knowledge_base_ids or not self._db:
return None
knowledge_config = getattr(agent, 'knowledge_config', None) or {}
annotation_reply_enabled = knowledge_config.get('annotation_reply_enabled', False)
if not annotation_reply_enabled:
return None
annotation_threshold = knowledge_config.get('annotation_threshold', 0.9)
try:
from ai_platform.knowledge.services.retrieval_service import RetrievalService
from ai_platform.knowledge.models import KnowledgeBase
from sqlalchemy import select
# 获取第一个知识库的 embedding 配置
kb_result = await self._db.execute(
select(KnowledgeBase).where(
KnowledgeBase.id.in_(knowledge_base_ids),
KnowledgeBase.is_deleted == False,
)
)
first_kb = kb_result.scalars().first()
if not first_kb or not first_kb.embedding_model_id:
return None
service = RetrievalService(self._db)
annotation_results = await service._match_annotations(
query=query,
knowledge_base_ids=knowledge_base_ids,
embedding_model_id=first_kb.embedding_model_id,
score_threshold=annotation_threshold,
dimensions=first_kb.embedding_dimensions,
max_results=1,
)
if annotation_results:
best = annotation_results[0]
metadata = getattr(best, 'metadata', {}) or {}
logger.info(
f'标注直接回复命中: score={best.score}, '
f'question={metadata.get("question", "")}, answer={best.content[:50]}'
)
# 记录检索日志
try:
from ai_platform.knowledge.models import KnowledgeRetrievalLog
log = KnowledgeRetrievalLog(
query=query,
knowledge_base_ids=knowledge_base_ids,
retrieval_mode='annotation',
top_k=1,
score_threshold=annotation_threshold,
result_count=1,
results=[{'segment_id': best.segment_id, 'score': best.score, 'kb_id': best.knowledge_base_id}],
rerank_applied='false',
elapsed_time=0,
source='agent_annotation',
)
self._db.add(log)
await self._db.flush()
except Exception as e:
logger.warning(f'记录标注回复日志失败: {e}')
return {
'answer': best.content,
'score': best.score,
'question': metadata.get('question', ''),
}
except Exception as e:
logger.warning(f'标注直接回复检查失败: {e}')
return None
async def _retrieve_knowledge_context(
self,
agent: Agent,
query: str,
) -> Optional[Dict[str, Any]]:
"""
检索知识库上下文(RAG)
Args:
agent: 智能体(包含 knowledge_base_ids 和 knowledge_config
query: 用户查询文本
Returns:
知识库上下文字典,无结果时返回 None
"""
knowledge_base_ids = getattr(agent, 'knowledge_base_ids', None) or []
if not knowledge_base_ids or not self._db:
logger.info(f'RAG 跳过: knowledge_base_ids={knowledge_base_ids}, db={bool(self._db)}')
return None
knowledge_config = getattr(agent, 'knowledge_config', None) or {}
logger.info(f'RAG 开始: kb_ids={knowledge_base_ids}, config={knowledge_config}, query={query[:100]}')
top_k = knowledge_config.get('top_k', 5)
score_threshold = knowledge_config.get('score_threshold', 0.5)
retrieval_mode = knowledge_config.get('retrieval_mode', None)
rerank_enabled = knowledge_config.get('rerank_enabled', None)
rerank_model_id = knowledge_config.get('rerank_model_id', None)
try:
from ai_platform.knowledge.services.retrieval_service import RetrievalService
start_time = time.time()
service = RetrievalService(self._db)
results = await service.retrieve(
query=query,
knowledge_base_ids=knowledge_base_ids,
top_k=top_k,
score_threshold=score_threshold,
retrieval_mode=retrieval_mode,
rerank_enabled=rerank_enabled,
rerank_model_id=rerank_model_id,
)
elapsed = int((time.time() - start_time) * 1000)
logger.info(f'RAG 检索结果: {len(results)}')
for r in results:
logger.info(f' - segment_id={r.segment_id}, score={r.score}, doc={getattr(r, "document_name", "")}, type={getattr(r, "metadata", {}).get("type", "segment")}')
# 记录检索日志(与召回测试共用同一张日志表)
try:
from ai_platform.knowledge.models import KnowledgeRetrievalLog
log = KnowledgeRetrievalLog(
query=query,
knowledge_base_ids=knowledge_base_ids,
retrieval_mode=retrieval_mode or 'hybrid',
top_k=top_k,
score_threshold=score_threshold,
result_count=len(results),
results=[
{'segment_id': r.segment_id, 'score': r.score, 'kb_id': r.knowledge_base_id}
for r in results
],
rerank_applied='true' if rerank_enabled else 'false',
elapsed_time=elapsed,
source='agent',
)
self._db.add(log)
await self._db.flush()
except Exception as e:
logger.warning(f'记录 Agent 检索日志失败: {e}')
if not results:
return None
# 构建上下文文本
context_parts = []
for i, r in enumerate(results, 1):
score = getattr(r, 'score', 0)
content = getattr(r, 'content', '')
metadata = getattr(r, 'metadata', {}) or {}
if metadata.get('type') == 'annotation':
# 标注类型:显示为 Q&A 对,让 LLM 明确知道这是预设的标准答案
question = metadata.get('question', '')
context_parts.append(
f"[{i}] [标准问答] (相似度: {score:.2f})\n"
f"问题: {question}\n"
f"标准答案: {content}"
)
else:
source = getattr(r, 'document_name', '') or ''
context_parts.append(
f"[{i}] (来源: {source}, 相似度: {score:.2f})\n{content}"
)
context_text = '\n\n'.join(context_parts)
summary = f"检索到 {len(results)} 条相关结果"
return {
'context': context_text,
'result_count': len(results),
'summary': summary,
'results': [
{
'segment_id': getattr(r, 'segment_id', ''),
'document_name': getattr(r, 'document_name', ''),
'score': getattr(r, 'score', 0),
}
for r in results
],
}
except Exception as e:
logger.warning(f'知识库检索失败: {e}')
return None
@staticmethod
def _inject_knowledge_context(
system_prompt: str,
knowledge_context: Dict[str, Any],
) -> str:
"""
将知识库检索结果注入系统提示词
参考 Dify 的 RAG 注入方式:在 system prompt 末尾追加参考资料段落,
并指示 LLM 基于资料回答,资料中无相关信息时如实告知。
"""
context_text = knowledge_context.get('context', '')
if not context_text:
return system_prompt
rag_instruction = (
"\n\n"
"# 参考资料\n"
"以下是从知识库中检索到的与用户问题相关的参考资料。\n"
"- 标记为[标准问答]的条目是预设的权威问答对,当用户问题与其匹配时,请直接使用标准答案回答,不要自行发挥。\n"
"- 其他条目为文档参考内容,请基于这些资料回答用户的问题。\n"
"- 如果参考资料中没有相关信息,请基于你自身的知识回答,并说明该回答未基于知识库。\n\n"
f"{context_text}"
)
return system_prompt + rag_instruction