feat: 更新配置版本至 8.4.0,新增工具筛选相关字段和方法
This commit is contained in:
@@ -57,7 +57,7 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config"
|
||||
BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
|
||||
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
|
||||
MMC_VERSION: str = "1.0.0"
|
||||
CONFIG_VERSION: str = "8.3.0"
|
||||
CONFIG_VERSION: str = "8.2.0"
|
||||
MODEL_CONFIG_VERSION: str = "1.13.1"
|
||||
|
||||
logger = get_logger("config")
|
||||
|
||||
@@ -1550,6 +1550,35 @@ class MaiSakaConfig(ConfigBase):
|
||||
)
|
||||
"""每个入站消息的最大内部规划轮数"""
|
||||
|
||||
tool_filter_task_name: str = Field(
|
||||
default="utils",
|
||||
json_schema_extra={
|
||||
"x-widget": "input",
|
||||
"x-icon": "sparkles",
|
||||
},
|
||||
)
|
||||
"""工具筛选预判使用的模型任务名"""
|
||||
|
||||
tool_filter_threshold: int = Field(
|
||||
default=20,
|
||||
ge=1,
|
||||
json_schema_extra={
|
||||
"x-widget": "input",
|
||||
"x-icon": "filter",
|
||||
},
|
||||
)
|
||||
"""当可用工具总数超过该阈值时,先进行一轮工具筛选"""
|
||||
|
||||
tool_filter_max_keep: int = Field(
|
||||
default=5,
|
||||
ge=1,
|
||||
json_schema_extra={
|
||||
"x-widget": "input",
|
||||
"x-icon": "list-filter",
|
||||
},
|
||||
)
|
||||
"""工具筛选阶段最多保留的非内置工具数量"""
|
||||
|
||||
terminal_image_preview: bool = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
|
||||
@@ -8,9 +8,11 @@ from time import perf_counter
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
|
||||
from PIL import Image as PILImage
|
||||
from pydantic import BaseModel, Field as PydanticField
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.pretty import Pretty
|
||||
@@ -22,10 +24,11 @@ from src.common.data_models.message_component_data_model import MessageSequence,
|
||||
from src.common.logger import get_logger
|
||||
from src.common.prompt_i18n import load_prompt
|
||||
from src.config.config import global_config
|
||||
from src.core.tooling import ToolRegistry
|
||||
from src.core.tooling import ToolRegistry, ToolSpec
|
||||
from src.know_u.knowledge import extract_category_ids_from_result
|
||||
from src.llm_models.model_client.base_client import BaseClient
|
||||
from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType
|
||||
from src.llm_models.payload_content.resp_format import RespFormat, RespFormatType
|
||||
from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, ToolOption, normalize_tool_options
|
||||
from src.services.llm_service import LLMServiceClient
|
||||
|
||||
@@ -43,6 +46,13 @@ class ChatResponse:
|
||||
raw_message: AssistantMessage
|
||||
|
||||
|
||||
class ToolFilterSelection(BaseModel):
|
||||
"""工具筛选响应。"""
|
||||
|
||||
selected_tool_names: list[str] = PydanticField(default_factory=list)
|
||||
"""经过预筛后保留的候选工具名称列表。"""
|
||||
|
||||
|
||||
logger = get_logger("maisaka_chat_loop")
|
||||
|
||||
|
||||
@@ -76,6 +86,10 @@ class MaisakaChatLoopService:
|
||||
else:
|
||||
self._chat_system_prompt = chat_system_prompt
|
||||
self._llm_chat = LLMServiceClient(task_name="planner", request_type="maisaka_planner")
|
||||
self._tool_filter_llm = LLMServiceClient(
|
||||
task_name=global_config.maisaka.tool_filter_task_name,
|
||||
request_type="maisaka_tool_filter",
|
||||
)
|
||||
|
||||
@property
|
||||
def personality_prompt(self) -> str:
|
||||
@@ -156,7 +170,15 @@ class MaisakaChatLoopService:
|
||||
self._interrupt_flag = interrupt_flag
|
||||
|
||||
def _build_request_messages(self, selected_history: List[LLMContextMessage]) -> List[Message]:
|
||||
"""构造发给大模型的消息列表。"""
|
||||
"""构造发给大模型的消息列表。
|
||||
|
||||
Args:
|
||||
selected_history: 已选中的上下文消息列表。
|
||||
|
||||
Returns:
|
||||
List[Message]: 发送给大模型的消息列表。
|
||||
"""
|
||||
|
||||
messages: List[Message] = []
|
||||
system_msg = MessageBuilder().set_role(RoleType.System)
|
||||
system_msg.add_text_content(self._chat_system_prompt)
|
||||
@@ -169,6 +191,248 @@ class MaisakaChatLoopService:
|
||||
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _is_builtin_tool_spec(tool_spec: ToolSpec) -> bool:
|
||||
"""判断一个工具是否属于默认内置工具。
|
||||
|
||||
Args:
|
||||
tool_spec: 待判断的工具声明。
|
||||
|
||||
Returns:
|
||||
bool: 是否为默认内置工具。
|
||||
"""
|
||||
|
||||
return tool_spec.provider_type == "builtin" or tool_spec.provider_name == "maisaka_builtin"
|
||||
|
||||
@classmethod
|
||||
def _split_builtin_and_candidate_tools(
|
||||
cls,
|
||||
tool_specs: List[ToolSpec],
|
||||
) -> tuple[List[ToolSpec], List[ToolSpec]]:
|
||||
"""拆分内置工具与可筛选工具列表。
|
||||
|
||||
Args:
|
||||
tool_specs: 当前全部工具声明。
|
||||
|
||||
Returns:
|
||||
tuple[List[ToolSpec], List[ToolSpec]]: `(内置工具, 可筛选工具)`。
|
||||
"""
|
||||
|
||||
builtin_tool_specs: List[ToolSpec] = []
|
||||
candidate_tool_specs: List[ToolSpec] = []
|
||||
for tool_spec in tool_specs:
|
||||
if cls._is_builtin_tool_spec(tool_spec):
|
||||
builtin_tool_specs.append(tool_spec)
|
||||
else:
|
||||
candidate_tool_specs.append(tool_spec)
|
||||
return builtin_tool_specs, candidate_tool_specs
|
||||
|
||||
@staticmethod
|
||||
def _truncate_tool_filter_text(text: str, max_length: int = 180) -> str:
|
||||
"""截断工具筛选阶段展示的文本。
|
||||
|
||||
Args:
|
||||
text: 原始文本。
|
||||
max_length: 最长保留字符数。
|
||||
|
||||
Returns:
|
||||
str: 截断后的文本。
|
||||
"""
|
||||
|
||||
normalized_text = text.strip()
|
||||
if len(normalized_text) <= max_length:
|
||||
return normalized_text
|
||||
return f"{normalized_text[: max_length - 1]}…"
|
||||
|
||||
def _build_tool_filter_prompt(
|
||||
self,
|
||||
selected_history: List[LLMContextMessage],
|
||||
candidate_tool_specs: List[ToolSpec],
|
||||
max_keep: int,
|
||||
) -> str:
|
||||
"""构造小模型工具预筛选提示词。
|
||||
|
||||
Args:
|
||||
selected_history: 已选中的对话上下文。
|
||||
candidate_tool_specs: 非内置候选工具列表。
|
||||
max_keep: 最多保留的候选工具数量。
|
||||
|
||||
Returns:
|
||||
str: 用于工具预筛的小模型提示词。
|
||||
"""
|
||||
|
||||
history_lines: List[str] = []
|
||||
for message in selected_history[-10:]:
|
||||
plain_text = message.processed_plain_text.strip()
|
||||
if not plain_text:
|
||||
continue
|
||||
history_lines.append(
|
||||
f"- {message.role}: {self._truncate_tool_filter_text(plain_text, max_length=200)}"
|
||||
)
|
||||
|
||||
if history_lines:
|
||||
history_section = "\n".join(history_lines)
|
||||
else:
|
||||
history_section = "- 当前没有可用的对话上下文。"
|
||||
|
||||
tool_lines = [
|
||||
f"- {tool_spec.name}: {tool_spec.brief_description.strip() or '无简要描述'}"
|
||||
for tool_spec in candidate_tool_specs
|
||||
]
|
||||
tool_section = "\n".join(tool_lines) if tool_lines else "- 当前没有候选工具。"
|
||||
|
||||
return (
|
||||
"你是 Maisaka 的工具预筛选器。\n"
|
||||
"你的任务是在正式进入 planner 前,根据当前情景从候选工具中挑出最可能马上会用到的工具。\n"
|
||||
"默认内置工具已经自动保留,不在候选列表中,你不需要再次选择它们。\n"
|
||||
"你只能参考工具的简要描述,不要假设未描述的隐藏能力。\n"
|
||||
f"最多保留 {max_keep} 个候选工具;如果都不合适,可以返回空数组。\n"
|
||||
"请严格返回 JSON 对象,格式为:"
|
||||
'{"selected_tool_names":["工具名1","工具名2"]}\n\n'
|
||||
f"【最近对话】\n{history_section}\n\n"
|
||||
f"【候选工具(仅简要描述)】\n{tool_section}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_filter_response(
|
||||
response_text: str,
|
||||
candidate_tool_specs: List[ToolSpec],
|
||||
max_keep: int,
|
||||
) -> List[ToolSpec] | None:
|
||||
"""解析工具预筛选响应。
|
||||
|
||||
Args:
|
||||
response_text: 小模型返回的原始文本。
|
||||
candidate_tool_specs: 非内置候选工具列表。
|
||||
max_keep: 最多保留的候选工具数量。
|
||||
|
||||
Returns:
|
||||
List[ToolSpec] | None: 成功解析时返回筛选后的工具列表;解析失败时返回 ``None``。
|
||||
"""
|
||||
|
||||
normalized_response = response_text.strip()
|
||||
if not normalized_response:
|
||||
return None
|
||||
|
||||
selected_tool_names: List[str]
|
||||
try:
|
||||
selected_tool_names = ToolFilterSelection.model_validate_json(normalized_response).selected_tool_names
|
||||
except Exception:
|
||||
try:
|
||||
parsed_payload = json.loads(normalized_response)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if isinstance(parsed_payload, dict):
|
||||
raw_tool_names = parsed_payload.get("selected_tool_names", [])
|
||||
elif isinstance(parsed_payload, list):
|
||||
raw_tool_names = parsed_payload
|
||||
else:
|
||||
return None
|
||||
|
||||
if not isinstance(raw_tool_names, list):
|
||||
return None
|
||||
|
||||
selected_tool_names = []
|
||||
for item in raw_tool_names:
|
||||
normalized_name = str(item).strip()
|
||||
if normalized_name:
|
||||
selected_tool_names.append(normalized_name)
|
||||
|
||||
candidate_map = {tool_spec.name: tool_spec for tool_spec in candidate_tool_specs}
|
||||
filtered_tool_specs: List[ToolSpec] = []
|
||||
seen_names: set[str] = set()
|
||||
for tool_name in selected_tool_names:
|
||||
normalized_name = tool_name.strip()
|
||||
if not normalized_name or normalized_name in seen_names:
|
||||
continue
|
||||
tool_spec = candidate_map.get(normalized_name)
|
||||
if tool_spec is None:
|
||||
continue
|
||||
|
||||
seen_names.add(normalized_name)
|
||||
filtered_tool_specs.append(tool_spec)
|
||||
if len(filtered_tool_specs) >= max_keep:
|
||||
break
|
||||
|
||||
return filtered_tool_specs
|
||||
|
||||
async def _filter_tool_specs_for_planner(
|
||||
self,
|
||||
selected_history: List[LLMContextMessage],
|
||||
tool_specs: List[ToolSpec],
|
||||
) -> List[ToolSpec]:
|
||||
"""在将工具交给 planner 前进行快速预筛选。
|
||||
|
||||
Args:
|
||||
selected_history: 已选中的对话上下文。
|
||||
tool_specs: 当前全部可用工具声明。
|
||||
|
||||
Returns:
|
||||
List[ToolSpec]: 最终交给 planner 的工具声明列表。
|
||||
"""
|
||||
|
||||
threshold = max(1, int(global_config.maisaka.tool_filter_threshold))
|
||||
max_keep = max(1, int(global_config.maisaka.tool_filter_max_keep))
|
||||
if len(tool_specs) <= threshold:
|
||||
return tool_specs
|
||||
|
||||
builtin_tool_specs, candidate_tool_specs = self._split_builtin_and_candidate_tools(tool_specs)
|
||||
if not candidate_tool_specs:
|
||||
return tool_specs
|
||||
if len(candidate_tool_specs) <= max_keep:
|
||||
return [*builtin_tool_specs, *candidate_tool_specs]
|
||||
|
||||
filter_prompt = self._build_tool_filter_prompt(selected_history, candidate_tool_specs, max_keep)
|
||||
logger.info(
|
||||
"工具预筛选开始: "
|
||||
f"总工具数={len(tool_specs)} "
|
||||
f"内置工具数={len(builtin_tool_specs)} "
|
||||
f"候选工具数={len(candidate_tool_specs)} "
|
||||
f"最多保留候选数={max_keep}"
|
||||
)
|
||||
|
||||
try:
|
||||
generation_result = await self._tool_filter_llm.generate_response(
|
||||
prompt=filter_prompt,
|
||||
options=LLMGenerationOptions(
|
||||
temperature=0.0,
|
||||
max_tokens=256,
|
||||
response_format=RespFormat(
|
||||
format_type=RespFormatType.JSON_SCHEMA,
|
||||
schema=ToolFilterSelection,
|
||||
),
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"工具预筛选失败,保留全部工具。错误={exc}")
|
||||
return tool_specs
|
||||
|
||||
filtered_candidate_tool_specs = self._parse_tool_filter_response(
|
||||
generation_result.response or "",
|
||||
candidate_tool_specs,
|
||||
max_keep,
|
||||
)
|
||||
if filtered_candidate_tool_specs is None:
|
||||
logger.warning(
|
||||
"工具预筛选返回结果无法解析,保留全部工具。"
|
||||
f" 原始返回={generation_result.response or ''!r}"
|
||||
)
|
||||
return tool_specs
|
||||
|
||||
filtered_tool_specs = [*builtin_tool_specs, *filtered_candidate_tool_specs]
|
||||
if not filtered_tool_specs:
|
||||
logger.warning("工具预筛选得到空结果,保留全部工具以避免主流程失去工具能力。")
|
||||
return tool_specs
|
||||
|
||||
logger.info(
|
||||
"工具预筛选完成: "
|
||||
f"筛选前总数={len(tool_specs)} "
|
||||
f"筛选后总数={len(filtered_tool_specs)} "
|
||||
f"保留候选工具={[tool_spec.name for tool_spec in filtered_candidate_tool_specs]}"
|
||||
)
|
||||
return filtered_tool_specs
|
||||
|
||||
async def analyze_knowledge_need(
|
||||
self,
|
||||
chat_history: List[LLMContextMessage],
|
||||
@@ -206,6 +470,15 @@ class MaisakaChatLoopService:
|
||||
|
||||
@staticmethod
|
||||
def _get_role_badge_style(role: str) -> str:
|
||||
"""返回终端中角色标签的样式。
|
||||
|
||||
Args:
|
||||
role: 消息角色名称。
|
||||
|
||||
Returns:
|
||||
str: Rich 可识别的样式字符串。
|
||||
"""
|
||||
|
||||
if role == "system":
|
||||
return "bold white on blue"
|
||||
if role == "user":
|
||||
@@ -218,6 +491,15 @@ class MaisakaChatLoopService:
|
||||
|
||||
@staticmethod
|
||||
def _get_role_badge_label(role: str) -> str:
|
||||
"""返回终端中角色标签的中文名称。
|
||||
|
||||
Args:
|
||||
role: 消息角色名称。
|
||||
|
||||
Returns:
|
||||
str: 用于展示的中文角色名称。
|
||||
"""
|
||||
|
||||
if role == "system":
|
||||
return "系统"
|
||||
if role == "user":
|
||||
@@ -230,6 +512,15 @@ class MaisakaChatLoopService:
|
||||
|
||||
@staticmethod
|
||||
def _build_terminal_image_preview(image_base64: str) -> Optional[str]:
|
||||
"""构造终端图片预览字符画。
|
||||
|
||||
Args:
|
||||
image_base64: 图片的 Base64 编码。
|
||||
|
||||
Returns:
|
||||
Optional[str]: 生成成功时返回字符画文本,否则返回 ``None``。
|
||||
"""
|
||||
|
||||
ascii_chars = " .:-=+*#%@"
|
||||
|
||||
try:
|
||||
@@ -257,6 +548,15 @@ class MaisakaChatLoopService:
|
||||
|
||||
@classmethod
|
||||
def _render_message_content(cls, content: Any) -> RenderableType:
|
||||
"""将消息内容渲染为终端可展示对象。
|
||||
|
||||
Args:
|
||||
content: 原始消息内容。
|
||||
|
||||
Returns:
|
||||
RenderableType: Rich 可渲染对象。
|
||||
"""
|
||||
|
||||
if isinstance(content, str):
|
||||
return Text(content)
|
||||
|
||||
@@ -299,6 +599,15 @@ class MaisakaChatLoopService:
|
||||
|
||||
@staticmethod
|
||||
def _format_tool_call_for_display(tool_call: Any) -> Dict[str, Any]:
|
||||
"""将工具调用对象格式化为易读字典。
|
||||
|
||||
Args:
|
||||
tool_call: 原始工具调用对象或字典。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 适合终端展示的工具调用字典。
|
||||
"""
|
||||
|
||||
if isinstance(tool_call, dict):
|
||||
function_info = tool_call.get("function", {})
|
||||
return {
|
||||
@@ -314,6 +623,17 @@ class MaisakaChatLoopService:
|
||||
}
|
||||
|
||||
def _render_tool_call_panel(self, tool_call: Any, index: int, parent_index: int) -> Panel:
|
||||
"""渲染单个工具调用面板。
|
||||
|
||||
Args:
|
||||
tool_call: 原始工具调用对象。
|
||||
index: 工具调用在当前消息中的序号。
|
||||
parent_index: 所属消息的序号。
|
||||
|
||||
Returns:
|
||||
Panel: 工具调用展示面板。
|
||||
"""
|
||||
|
||||
title = Text.assemble(
|
||||
Text(" 工具调用 ", style="bold white on magenta"),
|
||||
Text(f" #{parent_index}.{index}", style="muted"),
|
||||
@@ -326,6 +646,16 @@ class MaisakaChatLoopService:
|
||||
)
|
||||
|
||||
def _render_message_panel(self, message: Any, index: int) -> Panel:
|
||||
"""渲染单条消息面板。
|
||||
|
||||
Args:
|
||||
message: 原始消息对象或字典。
|
||||
index: 消息序号。
|
||||
|
||||
Returns:
|
||||
Panel: 终端展示面板。
|
||||
"""
|
||||
|
||||
if isinstance(message, dict):
|
||||
raw_role = message.get("role", "unknown")
|
||||
content = message.get("content")
|
||||
@@ -377,17 +707,28 @@ class MaisakaChatLoopService:
|
||||
|
||||
await self.ensure_chat_prompt_loaded()
|
||||
selected_history, selection_reason = self._select_llm_context_messages(chat_history)
|
||||
built_messages = self._build_request_messages(selected_history)
|
||||
|
||||
def message_factory(_client: BaseClient) -> List[Message]:
|
||||
"""返回当前轮次已经构建好的请求消息。
|
||||
|
||||
Args:
|
||||
_client: 当前模型客户端;此处不依赖客户端能力。
|
||||
|
||||
Returns:
|
||||
List[Message]: 已经构建好的消息列表。
|
||||
"""
|
||||
|
||||
del _client
|
||||
return self._build_request_messages(selected_history)
|
||||
return built_messages
|
||||
|
||||
all_tools: List[ToolDefinitionInput]
|
||||
if self._tool_registry is not None:
|
||||
all_tools = await self._tool_registry.get_llm_definitions()
|
||||
tool_specs = await self._tool_registry.list_tools()
|
||||
filtered_tool_specs = await self._filter_tool_specs_for_planner(selected_history, tool_specs)
|
||||
all_tools = [tool_spec.to_llm_definition() for tool_spec in filtered_tool_specs]
|
||||
else:
|
||||
all_tools = [*get_builtin_tools(), *self._extra_tools]
|
||||
built_messages = self._build_request_messages(selected_history)
|
||||
|
||||
ordered_panels: List[Panel] = []
|
||||
for index, msg in enumerate(built_messages, start=1):
|
||||
@@ -454,7 +795,15 @@ class MaisakaChatLoopService:
|
||||
|
||||
@staticmethod
|
||||
def _select_llm_context_messages(chat_history: List[LLMContextMessage]) -> tuple[List[LLMContextMessage], str]:
|
||||
"""选择真正发送给 LLM 的上下文消息。"""
|
||||
"""选择真正发送给 LLM 的上下文消息。
|
||||
|
||||
Args:
|
||||
chat_history: 当前全部对话历史。
|
||||
|
||||
Returns:
|
||||
tuple[List[LLMContextMessage], str]: `(已选上下文, 选择说明)`。
|
||||
"""
|
||||
|
||||
max_context_size = max(1, int(global_config.chat.max_context_size))
|
||||
selected_indices: List[int] = []
|
||||
counted_message_count = 0
|
||||
@@ -485,6 +834,15 @@ class MaisakaChatLoopService:
|
||||
|
||||
@staticmethod
|
||||
def build_chat_context(user_text: str) -> List[LLMContextMessage]:
|
||||
"""根据用户输入构造最小对话上下文。
|
||||
|
||||
Args:
|
||||
user_text: 用户输入文本。
|
||||
|
||||
Returns:
|
||||
List[LLMContextMessage]: 构造好的上下文消息列表。
|
||||
"""
|
||||
|
||||
timestamp = datetime.now()
|
||||
visible_text = format_speaker_content(
|
||||
global_config.maisaka.user_name.strip() or "用户",
|
||||
|
||||
Reference in New Issue
Block a user