fix(aibox): Batch M 波次4 命令面输入规整(W1~W8/W11/W12+W5)

- W1: WS 三入口 action/command/content 非 str 规整;task title/content 类型拦截
- W2: task add delay float 转换+[0,10年]钳制+友好 error
- W3: task complete 缺 task_id 回 error(新增 i18n 键)
- W4: /search 空关键词回用法提示,不再 LIKE %% 全表刷屏
- W5: main() 暂存 _cli_overrides,/reload 重放并回包注明,不再静默丢 CLI 覆盖
- W6: 启动失败 sys.exit(1);--port argparse 1~65535 校验
- W7: --scheduler-interval 统一 max(0.1,v) 钳制
- W8: --temperature main() 入口 [0.0,2.0] 钳制
- W11: config system/scheduler 节非 dict 回退默认节
- W12: --memo-add title strip/空串→None;db.add_memo 标题非空 ValueError

冒烟 12/12 通过;测试 493 passed/4 failed(既有平台项)/1 skipped 零回归
This commit is contained in:
qing committed 2026-08-20 13:54:36 +08:00
1 parent 9db2bc112d
commit d1357ae483
4 files changed
+141 -10

No files matched your search

+109 -10
View File
@@ -2385,7 +2385,12 @@ class WebSocketHandler:
async def handle_task_message(self, websocket, data):
"""处理任务相关消息 - 简化版,所有任务都是对话任务"""
action = data.get("action", "").strip()
# W1(审计 2026-08-20):字段类型规整——非 str 此前在 .strip() 抛
# AttributeError,落顶层兜底以笼统"处理失败"收场
action = data.get("action", "")
if not isinstance(action, str):
action = ""
action = action.strip()
if action == "add":
# 添加任务
@@ -2400,12 +2405,32 @@ class WebSocketHandler:
repeat_interval_unit = data.get("repeat_interval_unit", "days")
repeat_end_time = data.get("repeat_end_time")
if not title:
# W1(审计 2026-08-20):title 传 dict 等类型此前直达 sqlite 抛
# InterfaceError(J1 except ValueError 拦不住)
if not isinstance(title, str) or not title.strip():
await self.send_message(websocket, {
"type": "error",
"content": "需要提供任务标题"
})
return
if content is not None and not isinstance(content, str):
await self.send_message(websocket, {
"type": "error",
"content": "任务内容必须是字符串"
})
return
# W2(审计 2026-08-20):delay 无校验——非数字 TypeError、负数立即
# 执行、10^18 OverflowError。float 转换 + 钳制 [0, 10 年]
try:
delay = float(delay)
except (TypeError, ValueError):
await self.send_message(websocket, {
"type": "error",
"content": f"任务延迟必须是数字(秒): {delay!r}"
})
return
delay = min(max(delay, 0.0), 10 * 365 * 86400)
reminder_time = None
if is_immediate:
@@ -2481,6 +2506,12 @@ class WebSocketHandler:
"type": "error",
"content": i18n.get('memo_complete_failed', memo_id)
})
else:
# W3(审计 2026-08-20):缺 task_id 此前静默无响应
await self.send_message(websocket, {
"type": "error",
"content": i18n.get('memo_complete_missing_id')
})
else:
# J15(审计 2026-08-19):未知 action 此前静默吞掉,客户端无任何反馈
await self.send_message(websocket, {
@@ -2490,7 +2521,10 @@ class WebSocketHandler:
async def handle_command(self, websocket, data):
"""处理命令消息"""
command = data.get("command", "").strip()
command = data.get("command", "")
if not isinstance(command, str): # W1(审计 2026-08-20):非 str 字段规整
return
command = command.strip()
if not command:
return
@@ -2609,6 +2643,13 @@ class WebSocketHandler:
elif command.startswith("/search "):
query = command[8:].strip()
# W4(审计 2026-08-20):空关键词此前 LIKE %% 全表兜底刷屏,回用法提示
if not query:
await self.send_message(websocket, {
"type": "command_result",
"content": i18n.get('search_usage')
})
return
results = self.ai_manager.memory_db.search_memories([query])
result = i18n.get('search_results', query, len(results)) + "\n"
for mem in results:
@@ -2635,9 +2676,20 @@ class WebSocketHandler:
elif command == "/reload":
self.ai_manager.config.reload()
# W5(审计 2026-08-20):reload 经 _load_config 重建 _config,会静默
# 丢弃 CLI 进程级覆盖(--debug/--port/--no-scheduler 等)——此处重放
# main() 暂存在 config._cli_overrides 的覆盖
overrides = getattr(self.ai_manager.config, "_cli_overrides", None)
note = ""
if overrides:
for section, kv in overrides.items():
target = self.ai_manager.config._config.get(section)
if isinstance(target, dict):
target.update(kv)
note = i18n.get('config_reloaded_cli_kept')
await self.send_message(websocket, {
"type": "command_result",
"content": i18n.get('config_reloaded')
"content": i18n.get('config_reloaded') + note
})
elif command == "/reload_prompts":
@@ -2737,7 +2789,10 @@ class WebSocketHandler:
async def handle_user_message(self, websocket, data):
"""处理用户消息 - 实时流式输出(每个连接使用独立的AI实例,支持多用户并发)"""
content = data.get("content", "").strip()
content = data.get("content", "")
if not isinstance(content, str): # W1(审计 2026-08-20):非 str 字段规整
return
content = content.strip()
if not content:
return
@@ -3116,10 +3171,24 @@ class EnhancedAIManager:
print(f"\n{i18n.get('error_prefix')}{e}")
import traceback
traceback.print_exc()
# W6(审计 2026-08-20):启动/运行失败此前打印堆栈后以退出码 0
# 收场,supervisor/脚本误判成功
sys.exit(1)
# ==================== 命令行参数处理 ====================
def _port_number(value):
"""W6(审计 2026-08-20):--port argparse 类型校验,非法值启动前即报错"""
try:
iv = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"端口必须是整数: {value!r}")
if not (1 <= iv <= 65535):
raise argparse.ArgumentTypeError(f"端口必须在 1~65535 之间: {iv}")
return iv
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description='单AI智能体系统 - WebSocket版本(所有任务都是对话任务)')
@@ -3140,7 +3209,7 @@ def parse_args():
# WebSocket选项
parser.add_argument('--host', type=str, help='WebSocket服务器主机')
parser.add_argument('--port', type=int, help='WebSocket服务器端口')
parser.add_argument('--port', type=_port_number, help='WebSocket服务器端口')
# 调度器选项
parser.add_argument('--no-scheduler', action='store_true', help='禁用任务调度器')
@@ -3224,9 +3293,11 @@ def handle_memo_commands(manager):
if args.memo_add:
try:
parts = args.memo_add.split('|')
title = parts[0]
# W12(审计 2026-08-20):"|x" 空标题此前直接入库;"a|b|" 尾部空段
# 以 "" 传入绕过 J1 时间校验——空串规整为 None,空标题由 db 层拦
title = parts[0].strip()
content = parts[1] if len(parts) > 1 else ""
reminder = parts[2] if len(parts) > 2 else None
reminder = (parts[2].strip() or None) if len(parts) > 2 else None
# 解析重复参数
repeat_type = parts[3] if len(parts) > 3 else "none"
@@ -3419,6 +3490,17 @@ def main():
config = ConfigManager()
# W8(审计 2026-08-20):--temperature 入口钳制,防 99/-1 直达 API 400
if args.temperature is not None:
args.temperature = min(2.0, max(0.0, args.temperature))
# W11(审计 2026-08-20):config.json 的 system/scheduler 节被写成非 dict
# 时,下方 CLI 覆盖赋值 TypeError 崩启动——回退默认节
_section_defaults = config._get_default_config()
for _section in ("system", "scheduler"):
if not isinstance(config._config.get(_section), dict):
config._config[_section] = _section_defaults.get(_section, {})
if args.debug is not None:
config._config["system"]["debug_level"] = args.debug
@@ -3434,8 +3516,25 @@ def main():
# 调度器设置
if args.no_scheduler:
config._config["scheduler"]["enabled"] = False
if args.scheduler_interval:
config._config["scheduler"]["check_interval"] = args.scheduler_interval
# W7(审计 2026-08-20):负值直达 asyncio.sleep(负) 空转热循环,0 被 falsy
# 静默忽略——统一钳制下限 0.1s
config._config["scheduler"]["check_interval"] = max(0.1, args.scheduler_interval)
# W5(审计 2026-08-20):暂存 CLI 进程级覆盖,WS /reload 后重放(否则
# config.reload() 重建 _config 会静默丢弃)
_cli_overrides = {"system": {}, "scheduler": {}}
if args.debug is not None:
_cli_overrides["system"]["debug_level"] = args.debug
if args.verbose:
_cli_overrides["system"]["debug_level"] = 3
if args.host:
_cli_overrides["system"]["websocket_host"] = args.host
if args.port:
_cli_overrides["system"]["websocket_port"] = args.port
if args.no_scheduler:
_cli_overrides["scheduler"]["enabled"] = False
_cli_overrides["scheduler"]["check_interval"] = config._config["scheduler"]["check_interval"]
config._cli_overrides = {k: v for k, v in _cli_overrides.items() if v}
# 确保 ~/.aibox 目录存在
os.makedirs(constants.SAVE_DIR, exist_ok=True)
+6
View File
@@ -98,6 +98,7 @@ class I18n:
'memo_overdue': "⚠️",
'memo_pending': "⏰",
'search_results': "🔍 搜索 '{}' 找到 {} 条记忆:",
'search_usage': "用法: /search 关键词",
'current_config': "⚙️ 当前配置:",
'config_debug': " 调试级别: {}",
'config_data_dir': " 数据目录: {}",
@@ -110,6 +111,7 @@ class I18n:
'enabled': "启用",
'disabled': "禁用",
'config_reloaded': "✅ 配置已重新加载",
'config_reloaded_cli_kept': "(命令行进程级覆盖已保留)",
'prompts_reloaded': "✅ 提示词已重新加载 ({} 个)",
'checking_memos': "🔍 检查任务...",
'overdue_found': "发现 {} 个到期任务:",
@@ -157,6 +159,7 @@ class I18n:
'memo_add_failed': "❌ 添加失败: {}",
'memo_completed': "✅ 任务 #{} 已完成",
'memo_complete_failed': "❌ 无法完成任务 #{}",
'memo_complete_missing_id': "❌ 缺少 task_id,无法完成任务",
# 即时任务相关
'immediate_memo_added': "⚡ 已添加为即时任务并立即检查",
@@ -258,6 +261,7 @@ class I18n:
'memo_overdue': "⚠️",
'memo_pending': "⏰",
'search_results': "🔍 Searching '{}' found {} memories:",
'search_usage': "Usage: /search <keyword>",
'current_config': "⚙️ Current configuration:",
'config_debug': " Debug level: {}",
'config_data_dir': " Data directory: {}",
@@ -270,6 +274,7 @@ class I18n:
'enabled': "enabled",
'disabled': "disabled",
'config_reloaded': "✅ Configuration reloaded",
'config_reloaded_cli_kept': " (CLI process-level overrides preserved)",
'prompts_reloaded': "✅ Prompts reloaded ({})",
'checking_memos': "🔍 Checking tasks...",
'overdue_found': "Found {} overdue tasks:",
@@ -317,6 +322,7 @@ class I18n:
'memo_add_failed': "❌ Failed to add: {}",
'memo_completed': "✅ Task #{} completed",
'memo_complete_failed': "❌ Cannot complete task #{}",
'memo_complete_missing_id': "❌ Missing task_id, cannot complete task",
# Immediate task related
'immediate_memo_added': "⚡ Added as immediate task and checked now",
+3
View File
@@ -365,6 +365,9 @@ class MemoDatabase:
# J1(审计 2026-08-19):入库校验——畸形时间/数字字段此前原样入库,
# 下游无防护解析点引发调度器失败热循环与新连接握手崩溃(审计报告
# §13.1 J1)。CLI/WS/memo 工具三入口均经本方法,校验一处覆盖。
# W12(审计 2026-08-20):空标题任务此前可入库(CLI "|x" / 调用方疏忽)
if not isinstance(title, str) or not title.strip():
raise ValueError("任务标题不能为空")
if reminder_time:
try:
datetime.strptime(reminder_time, constants.DATETIME_FORMAT)
+23
View File
@@ -930,3 +930,26 @@ Linux 基线(`aibox/venv`)维持 **490 passed** 不受影响。
- 全量测试 **493 passed / 4 failed(既有平台环境项)/ 1 skipped**,与波次 1/2 基线完全一致,零回归(前端无 pytest 覆盖,语法级验证兜底)
**波次 4~5 待落地**:命令面规整(W1~W8/W11/W12 + W5 择优)、客户端收尾(M1~M5/M7/M8)。
## 二十一、Batch M 修复记录(波次 4:命令面输入规整,2026-08-20)
范围:§17.6 波次 4——W1~W8/W11/W12 + W5 择优,全部落在 `aibox.py`(W12 含 `subsystems/db.py`)+ `common/i18n.py` 三个新键(zh/en)。W9/W10(WS payload 体积上限、data 非 dict 归一)按审计口径"可不做/产品决策"跳过。
| # | 修复内容 | 关键变更 |
|---|---|---|
| W1 | WS 三入口字段类型规整 | `handle_task_message`/`handle_command`/`handle_user_message` 的 action/command/content 非 str 时规整为 `""`/直接 return(此前 `.strip()` AttributeError 落顶层"处理失败");task add 的 `title` 必须非空 str、`content` 必须 str 或 None(此前 dict 直达 sqlite InterfaceError,J1 except 拦不住) |
| W2 | task add `delay` 校验 | float 转换失败回友好 error;钳制 `[0, 10年]`(此前负数立即执行、`1e18` OverflowError) |
| W3 | task complete 缺 task_id | 新增 else 回 error(i18n `memo_complete_missing_id`),此前静默无响应 |
| W4 | `/search ` 空关键词 | 空 query 回用法提示(i18n `search_usage`),此前 LIKE `%%` 全表兜底刷屏 |
| W5 | `/reload` 丢弃 CLI 进程级覆盖 | `main()` 暂存 `_cli_overrides`(debug/host/port/scheduler 等),`/reload` 后重放并在回包注明"命令行进程级覆盖已保留" |
| W6 | 启动失败退出码 + `--port` 校验 | `run()` 异常分支补 `sys.exit(1)`(此前堆栈后退出码 0);argparse 自定义 `_port_number` 限制 1~65535 |
| W7 | `--scheduler-interval` 钳制 | 统一 `max(0.1, v)`(此前 `-1` 空转热循环、`0` 被 falsy 静默忽略) |
| W8 | `--temperature` 入口钳制 | `main()` 内钳制 `[0.0, 2.0]`(此前 99/-1 直达 API 400) |
| W11 | config 节非 dict 回退 | `main()` CLI 覆盖赋值前 isinstance 检查 `system`/`scheduler` 节,非 dict 回退默认节(此前启动即 TypeError) |
| W12 | `--memo-add` 边界 | CLI:`title` strip、尾段空串 `""`→`None`(此前绕过 J1 时间校验);`db.add_memo` 标题非空 ValueError(`"|x"` 空标题入库拦截) |
**验证**:
- 冒烟测试 12/12 通过:`--port` 五例(8765 放行 / 0/65536/-1/abc 拒绝)、`add_memo` 空/纯空格标题拒绝、正常标题入库、i18n 三新键解析、`"a|b|"` 尾段规整 None
- 全量测试 **493 passed / 4 failed(既有平台环境项)/ 1 skipped**,与波次 1~3 基线完全一致,零回归
**波次 5 待落地**:客户端收尾(M1~M5/M7/M8,model_config/voice_client/client + aibox.py scheduler 超时/记忆上限,~25 行)。