@bangdao-ai/zentao-mcp 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -1,11 +1,7 @@
1
- # ═══════════════ MCP 通用 ═══════════════
2
- ZENTAO_BASE_URL=https://zentao.example.com
3
- ZENTAO_PRODUCT_ID=365
4
- ZENTAO_OPENED_BY=your_account
5
- ZENTAO_KEY=your_api_key
6
- ZENTAO_USE_TEST_ENV=false
7
1
 
8
- # ═══════════════ ZenBot 专属 ═══════════════
2
+
3
+ # ═══════════════ ZenBot 部署必填 ═══════════════
4
+
9
5
  # 钉钉应用
10
6
  DINGTALK_APP_KEY=your_app_key
11
7
  DINGTALK_APP_SECRET=your_app_secret
@@ -14,8 +10,9 @@ DINGTALK_APP_SECRET=your_app_secret
14
10
  OPENAI_API_KEY=your_openai_api_key
15
11
  OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
16
12
  LLM_MODEL=qwen-plus
13
+ LLM_VISION_MODEL=qwen-vl-plus
17
14
 
18
- # 禅道 MySQL(用于用户/产品自动映射)
15
+ # 禅道 MySQL(用户工号→禅道账号自动映射)
19
16
  MYSQL_ZENTAO_HOST=127.0.0.1
20
17
  MYSQL_ZENTAO_PORT=3306
21
18
  MYSQL_ZENTAO_USER=root
package/README.md CHANGED
@@ -159,18 +159,24 @@
159
159
 
160
160
  ```
161
161
  zentao-mcp/
162
- ├── index.js # Node.js 入口(自动检测Python、安装依赖)
162
+ ├── index.js # Node.js 入口(自动检测Python、安装依赖)
163
+ ├── main.py # 钉钉机器人入口
163
164
  ├── src/
164
- │ ├── mcp_server.py # MCP 服务器(36 工具注册和路由)
165
- │ ├── zentao_nexus.py # 禅道原生API客户端 + 业务层
166
- │ └── bug_classifier.py # Bug 智能分类引擎
165
+ │ ├── core/
166
+ ├── zentao_nexus.py # 禅道原生API客户端 + 业务层
167
+ └── bug_classifier.py # Bug 智能分类引擎
168
+ │ ├── mcp/
169
+ │ │ └── server.py # MCP 服务器(工具注册和路由)
170
+ │ └── zenbot/ # 钉钉机器人模块
171
+ │ ├── bot_service.py # 意图路由 & handler 调度
172
+ │ ├── handlers/ # 各领域 handler
173
+ │ └── ...
167
174
  ├── testing/
168
- │ ├── test_zentao_api.py # Pytest 全能力测试(40 条,严格断言)
169
- ├── TEST_README.md # 测试说明
170
- │ └── mcp.json # 测试环境配置
175
+ │ ├── test_zentao_api.py # Pytest 全能力测试
176
+ └── mcp.json # 测试环境配置
171
177
  ├── docs/
172
- │ ├── zentao-native-api.md # 原生 API 文档
173
- │ └── RELEASE.md # 发布说明
178
+ │ ├── zentao-native-api.md # 原生 API 文档
179
+ │ └── RELEASE.md # 发布说明
174
180
  ├── package.json
175
181
  ├── requirements.txt
176
182
  └── README.md
@@ -181,9 +187,9 @@ zentao-mcp/
181
187
  ```
182
188
  AI 编辑器 (Cursor/Claude)
183
189
  ↕ MCP 协议 (stdio)
184
- MCP Server (mcp_server.py)
185
- 36 Tool 定义 & 路由
186
- ZenTaoNexus (zentao_nexus.py) — 业务封装层
190
+ MCP Server (src/mcp/server.py)
191
+ ↕ Tool 定义 & 路由
192
+ ZenTaoNexus (src/core/zentao_nexus.py) — 业务封装层
187
193
 
188
194
  ZenTaoNativeClient — 原生 API 客户端
189
195
  ↕ HTTP POST (api.php?m=module&f=action)
package/index.js CHANGED
@@ -243,10 +243,10 @@ function main() {
243
243
  console.error('\n故障排查:');
244
244
  console.error('1. 检查 Python 是否安装: python3 --version');
245
245
  if (isWindows) {
246
- console.error('2. 检查脚本是否存在: dir src\\mcp_server.py');
246
+ console.error('2. 检查脚本是否存在: dir src\\mcp\\server.py');
247
247
  console.error('3. 检查依赖是否安装: pip3 list | findstr mcp');
248
248
  } else {
249
- console.error('2. 检查脚本是否存在: ls -la src/mcp_server.py');
249
+ console.error('2. 检查脚本是否存在: ls -la src/mcp/server.py');
250
250
  console.error('3. 检查依赖是否安装: pip3 list | grep mcp');
251
251
  }
252
252
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bangdao-ai/zentao-mcp",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "禅道项目管理AI中枢 v2.0 — 35+MCP工具覆盖需求·任务·Bug·用例·发布·我的地盘全生命周期,原生API模式,Bug智能分类引擎+CSV批量用例导入+截图上传,一句话驱动禅道",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -583,6 +583,36 @@ class ZenTaoNativeClient:
583
583
  params={'productID': product_id}, form_data=case_data)
584
584
  return self.normalize(result)
585
585
 
586
+ def testcase_import_batch(self, product_id, cases: list) -> Dict:
587
+ """
588
+ 通过禅道 testcase.showImport 批量导入用例。
589
+
590
+ 与 testcase.create 的区别:
591
+ - create 的步骤用 steps[1], steps[2]... 逐条,步骤编号由参数下标决定
592
+ - showImport 的步骤用 stepDesc[N] 整段多行文本,禅道自动解析编号
593
+
594
+ Args:
595
+ product_id: 产品 ID
596
+ cases: [{title, pri, type, stage, module, story, keywords,
597
+ precondition, stepDesc, stepExpect}, ...]
598
+ """
599
+ form_data = {}
600
+ for idx, case in enumerate(cases, 1):
601
+ form_data[f'title[{idx}]'] = case.get('title', '')
602
+ form_data[f'module[{idx}]'] = case.get('module', '0')
603
+ form_data[f'story[{idx}]'] = case.get('story', '')
604
+ form_data[f'pri[{idx}]'] = case.get('pri', '3')
605
+ form_data[f'type[{idx}]'] = case.get('type', 'feature')
606
+ form_data[f'stage[{idx}]'] = case.get('stage', '')
607
+ form_data[f'keywords[{idx}]'] = case.get('keywords', '')
608
+ form_data[f'precondition[{idx}]'] = case.get('precondition', '')
609
+ form_data[f'stepDesc[{idx}]'] = case.get('stepDesc', '')
610
+ form_data[f'stepExpect[{idx}]'] = case.get('stepExpect', '')
611
+ result = self._call('testcase', 'showImport',
612
+ params={'productID': product_id, 'branch': '0'},
613
+ form_data=form_data)
614
+ return self.normalize(result)
615
+
586
616
  def testcase_create_by_gateway(self, case_data: Dict) -> Dict:
587
617
  """
588
618
  通过历史稳定网关创建用例(allneedlist/createcasebyapi)。
@@ -757,18 +787,43 @@ class ZenTaoNativeClient:
757
787
 
758
788
  def file_upload(self, object_type: str, object_id, file_path: str,
759
789
  custom_filename: Optional[str] = None) -> Dict:
790
+ """
791
+ 上传文件并关联到指定对象。
792
+ 优先使用 allneedlist/uploadbyapi(token 认证兼容),
793
+ 回退到 file/upload(需要 web session 权限,部分环境 401)。
794
+ """
760
795
  file_path = _safe_path(file_path, must_exist=True)
761
796
  if not os.path.exists(file_path):
762
797
  return {"status": 0, "message": "fail",
763
798
  "info": f"文件不存在: {file_path}", "data": {}}
764
799
 
765
800
  filename = custom_filename or os.path.basename(file_path)
801
+ oid = str(object_id)
802
+
766
803
  try:
804
+ with open(file_path, 'rb') as f:
805
+ files = {'files[]': (filename, f, 'image/png')}
806
+ form_data = {'objectType': object_type, 'objectID': oid, 'id': oid}
807
+ result = self._call('allneedlist', 'uploadbyapi',
808
+ form_data=form_data, files=files)
809
+ normalized = self.normalize(result)
810
+ if normalized.get("status") == 1:
811
+ return normalized
812
+
813
+ gateway_error = normalized
814
+
767
815
  with open(file_path, 'rb') as f:
768
816
  files = {'files[]': (filename, f, 'application/octet-stream')}
769
- form_data = {'objectType': object_type, 'objectID': str(object_id)}
770
- result = self._call('file', 'upload', form_data=form_data, files=files)
771
- return self.normalize(result)
817
+ result = self._call('file', 'upload',
818
+ params={'objectType': object_type,
819
+ 'objectID': oid},
820
+ files=files)
821
+ normalized = self.normalize(result)
822
+ if normalized.get("status") == 1:
823
+ return normalized
824
+
825
+ return gateway_error
826
+
772
827
  except Exception as e:
773
828
  return {"status": 0, "message": "fail",
774
829
  "info": f"文件处理失败: {e}", "data": {}}
@@ -794,11 +849,11 @@ class ZenTaoNexus:
794
849
  self.product_id = os.getenv("ZENTAO_PRODUCT_ID") or ""
795
850
 
796
851
  code = code or os.getenv("ZENTAO_OPENED_BY")
797
- key = key or os.getenv("ZENTAO_KEY")
852
+ key = key or os.getenv("ZENTAO_KEY") or os.getenv("KEY")
798
853
  if not code:
799
854
  raise ValueError("未设置 code 参数或环境变量 ZENTAO_OPENED_BY")
800
855
  if not key:
801
- raise ValueError("未设置 key 参数或环境变量 ZENTAO_KEY")
856
+ raise ValueError("未设置 key 参数或环境变量 ZENTAO_KEY / KEY")
802
857
 
803
858
  self.opened_by = code
804
859
  use_test = os.getenv("ZENTAO_USE_TEST_ENV", "").lower() in ('1', 'true', 'yes')
@@ -859,7 +914,7 @@ class ZenTaoNexus:
859
914
 
860
915
  def create_bug(self, title, steps, product_id=None, opened_by=None,
861
916
  assigned_to=None, screenshot_path=None, **kwargs) -> Dict:
862
- from bug_classifier import classify_bug
917
+ from core.bug_classifier import classify_bug
863
918
 
864
919
  product_id = product_id or self.product_id
865
920
  opened_by = opened_by or self.opened_by
@@ -1359,90 +1414,169 @@ class ZenTaoNexus:
1359
1414
  "gateway_error": gateway_result,
1360
1415
  }
1361
1416
 
1417
+ @staticmethod
1418
+ def _build_numbered_text(lines: list) -> str:
1419
+ """将步骤/预期列表组装为带编号的多行文本(供 showImport 接口使用)。"""
1420
+ if not lines:
1421
+ return ''
1422
+ return '\n'.join(f"{i}. {ln}" for i, ln in enumerate(lines, 1))
1423
+
1424
+ @staticmethod
1425
+ def _split_numbered_lines(text: str) -> list:
1426
+ """将多行编号文本拆分为独立条目,去掉编号前缀。"""
1427
+ if not text or not text.strip():
1428
+ return []
1429
+ lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]
1430
+ result = []
1431
+ for ln in lines:
1432
+ cleaned = re.sub(r'^(\d+)\s*[.、)]\s*', '', ln)
1433
+ cleaned = re.sub(r'^[-–—]\s*', '', cleaned)
1434
+ if cleaned:
1435
+ result.append(cleaned)
1436
+ return result if result else [text.strip()]
1437
+
1438
+ def _parse_csv_rows(self, csv_file_path, case_type=None):
1439
+ """解析 CSV 文件,返回 (fieldnames, rows, case_type)。
1440
+
1441
+ 支持两种 CSV 格式:
1442
+ - 富格式(含 步骤/预期 列):用例标题, 步骤, 预期, 前置条件, 优先级, 关键词
1443
+ - 接口格式(含 接口地址/请求方法 列):用例名称, 接口地址, 请求方法, 请求体, 断言, 预期结果
1444
+ - 简易格式:用例名称, 预期结果
1445
+ """
1446
+ with open(csv_file_path, 'r', encoding='utf-8', newline='') as f:
1447
+ reader = csv.DictReader(f)
1448
+ fieldnames = list(reader.fieldnames or [])
1449
+
1450
+ if case_type is None:
1451
+ if '接口地址' in fieldnames and '请求方法' in fieldnames:
1452
+ case_type = "interface"
1453
+ else:
1454
+ case_type = "feature"
1455
+ elif case_type == "接口测试":
1456
+ case_type = "interface"
1457
+ elif case_type == "功能测试":
1458
+ case_type = "feature"
1459
+
1460
+ rows = list(reader)
1461
+ return fieldnames, rows, case_type
1462
+
1362
1463
  def create_case_from_csv(self, csv_file_path, case_type=None) -> list:
1363
1464
  csv_file_path = _safe_path(csv_file_path, must_exist=True)
1364
1465
  if not os.path.exists(csv_file_path):
1365
1466
  return [{"status": 0, "message": f"CSV文件不存在: {csv_file_path}"}]
1366
1467
 
1367
- current_time = datetime.now()
1368
- time_str = current_time.strftime("%H:%M:%S")
1369
- results = []
1370
- updated_rows = []
1468
+ time_str = datetime.now().strftime("%H:%M:%S")
1371
1469
 
1372
1470
  try:
1373
- with open(csv_file_path, 'r', encoding='utf-8', newline='') as f:
1374
- reader = csv.DictReader(f)
1375
- fieldnames = reader.fieldnames
1471
+ fieldnames, rows, case_type = self._parse_csv_rows(csv_file_path, case_type)
1472
+ except Exception as e:
1473
+ return [{"status": 0, "message": f"读取CSV文件失败: {e}"}]
1376
1474
 
1377
- if case_type is None:
1378
- if '接口地址' in fieldnames and '请求方法' in fieldnames:
1379
- case_type = "interface"
1380
- else:
1381
- case_type = "feature"
1382
- elif case_type == "接口测试":
1383
- case_type = "interface"
1384
- elif case_type == "功能测试":
1385
- case_type = "feature"
1475
+ has_rich_steps = '步骤' in fieldnames and '预期' in fieldnames
1476
+ title_col = '用例标题' if '用例标题' in fieldnames else '用例名称'
1386
1477
 
1387
- for row in reader:
1388
- if not row.get('用例名称'):
1389
- row['用例ID'] = ''
1390
- updated_rows.append(row)
1391
- continue
1478
+ batch_cases = []
1479
+ valid_row_indices = []
1392
1480
 
1393
- case_data = {
1394
- "product": self.product_id,
1395
- "type": case_type,
1396
- "title": row['用例名称'],
1397
- "module": "0",
1398
- "stage": "intergrate",
1399
- "keywords": f"ai {time_str}",
1400
- "openedBy": self.opened_by,
1401
- }
1402
-
1403
- if case_type == "interface":
1404
- interface_url = row.get('接口地址', '')
1405
- request_method = row.get('请求方法', 'POST')
1406
- request_body = row.get('请求体', '')
1407
- assertion = row.get('断言', '')
1408
- expected_result = row.get('预期结果', '')
1409
-
1410
- steps = ["准备测试数据", f"发送请求体:{request_body}",
1411
- f"调用接口 {interface_url}", "验证响应结果"]
1412
- expects = ["测试数据准备完成", f"请求体格式正确:{request_body}",
1413
- f"接口调用成功,方法:{request_method}",
1414
- f"断言验证:{assertion}\\n预期结果:{expected_result}"]
1415
- case_data["precondition"] = f"前置条件:\\n1. 系统已启动\\n2. 接口服务正常运行\\n3. 接口地址:{interface_url}\\n4. 请求方法:{request_method}"
1416
- else:
1417
- steps = ["打开系统页面", "执行相关操作", "验证功能结果"]
1418
- expects = ["系统页面正常打开", "操作执行成功",
1419
- row.get('预期结果', '功能符合预期')]
1420
- case_data["precondition"] = "前置条件:\\n1. 系统已启动\\n2. 用户已登录"
1421
-
1422
- for i, step in enumerate(steps, 1):
1423
- case_data[f"steps[{i}]"] = step
1424
- for i, expect in enumerate(expects, 1):
1425
- case_data[f"expects[{i}]"] = expect
1426
-
1427
- result = self.create_case(case_data)
1428
- result['case_name'] = row['用例名称']
1429
- results.append(result)
1430
-
1431
- if result.get('status') == 1:
1432
- row['用例ID'] = result.get('data', {}).get('id', result.get('info', ''))
1433
- else:
1434
- row['用例ID'] = f"创建失败: {result.get('info', '未知错误')}"
1435
- updated_rows.append(row)
1481
+ for idx, row in enumerate(rows):
1482
+ title = (row.get(title_col) or '').strip()
1483
+ if not title:
1484
+ continue
1436
1485
 
1437
- if '用例ID' not in fieldnames:
1438
- fieldnames.append('用例ID')
1486
+ kw = (row.get('关键词') or '').strip()
1487
+ pri = (row.get('优先级') or '').strip()
1488
+
1489
+ case = {
1490
+ 'title': title,
1491
+ 'module': '0',
1492
+ 'story': '',
1493
+ 'pri': pri if pri in ('1', '2', '3', '4') else '3',
1494
+ 'type': case_type,
1495
+ 'stage': '',
1496
+ 'keywords': f"{kw} {time_str}" if kw else f"ai {time_str}",
1497
+ }
1498
+
1499
+ if has_rich_steps:
1500
+ raw_steps = row.get('步骤', '')
1501
+ raw_expects = row.get('预期', '')
1502
+ case['stepDesc'] = raw_steps.strip()
1503
+ case['stepExpect'] = raw_expects.strip()
1504
+ precond = (row.get('前置条件') or '').strip()
1505
+ case['precondition'] = precond if precond else ''
1506
+ elif case_type == "interface":
1507
+ interface_url = row.get('接口地址', '')
1508
+ request_method = row.get('请求方法', 'POST')
1509
+ request_body = row.get('请求体', '')
1510
+ assertion = row.get('断言', '')
1511
+ expected_result = row.get('预期结果', '')
1512
+ steps = ["准备测试数据", f"发送请求体:{request_body}",
1513
+ f"调用接口 {interface_url}", "验证响应结果"]
1514
+ expects = ["测试数据准备完成", f"请求体格式正确:{request_body}",
1515
+ f"接口调用成功,方法:{request_method}",
1516
+ f"断言验证:{assertion},预期结果:{expected_result}"]
1517
+ case['stepDesc'] = self._build_numbered_text(steps)
1518
+ case['stepExpect'] = self._build_numbered_text(expects)
1519
+ case['precondition'] = (
1520
+ f"1. 系统已启动\n2. 接口服务正常运行"
1521
+ f"\n3. 接口地址:{interface_url}\n4. 请求方法:{request_method}"
1522
+ )
1523
+ else:
1524
+ steps = ["打开系统页面", "执行相关操作", "验证功能结果"]
1525
+ expects = ["系统页面正常打开", "操作执行成功",
1526
+ row.get('预期结果', '功能符合预期')]
1527
+ case['stepDesc'] = self._build_numbered_text(steps)
1528
+ case['stepExpect'] = self._build_numbered_text(expects)
1529
+ case['precondition'] = "1. 系统已启动\n2. 用户已登录"
1530
+
1531
+ batch_cases.append(case)
1532
+ valid_row_indices.append(idx)
1533
+
1534
+ if not batch_cases:
1535
+ return [{"status": 0, "message": "CSV 中无有效用例行"}]
1536
+
1537
+ result = self.client.testcase_import_batch(self.product_id, batch_cases)
1538
+ ok = result.get('status') == 1
1539
+
1540
+ if ok:
1541
+ latest = self.client.testcase_browse(
1542
+ self.product_id, orderBy='id_desc',
1543
+ recPerPage=str(len(batch_cases) + 5))
1544
+ latest_cases = latest.get("data", {}).get("cases", [])
1545
+ if isinstance(latest_cases, dict):
1546
+ latest_cases = list(latest_cases.values())
1547
+ title_to_id = {}
1548
+ for c in latest_cases:
1549
+ t = str(c.get('title', '')).strip()
1550
+ cid = str(c.get('id', '')).strip()
1551
+ if t and cid and t not in title_to_id:
1552
+ title_to_id[t] = cid
1553
+
1554
+ results = []
1555
+ for i, case in enumerate(batch_cases):
1556
+ row_idx = valid_row_indices[i]
1557
+ entry = {'case_name': case['title'], 'status': 1 if ok else 0}
1558
+ if ok:
1559
+ cid = title_to_id.get(case['title'], '')
1560
+ entry['caseID'] = cid
1561
+ rows[row_idx]['用例ID'] = cid
1562
+ else:
1563
+ entry['info'] = result.get('info', result.get('message', '批量导入失败'))
1564
+ rows[row_idx]['用例ID'] = f"失败: {entry['info']}"
1565
+ results.append(entry)
1566
+
1567
+ for idx, row in enumerate(rows):
1568
+ if idx not in valid_row_indices:
1569
+ row.setdefault('用例ID', '')
1570
+
1571
+ if '用例ID' not in fieldnames:
1572
+ fieldnames.append('用例ID')
1573
+ try:
1439
1574
  with open(csv_file_path, 'w', encoding='utf-8', newline='') as f:
1440
1575
  writer = csv.DictWriter(f, fieldnames=fieldnames)
1441
1576
  writer.writeheader()
1442
- writer.writerows(updated_rows)
1443
-
1577
+ writer.writerows(rows)
1444
1578
  except Exception as e:
1445
- results.append({"status": 0, "message": f"处理CSV文件失败: {e}"})
1579
+ logger.warning("写回CSV失败: %s", e)
1446
1580
 
1447
1581
  return results
1448
1582
 
@@ -1457,10 +1591,10 @@ class ZenTaoNexus:
1457
1591
 
1458
1592
  results = self.create_case_from_csv(csv_file_path, case_type)
1459
1593
  success_count = sum(1 for r in results if r.get('status') == 1)
1460
- failed_count = len(results) - success_count
1594
+ failed_count = sum(1 for r in results if r.get('status') != 1)
1461
1595
 
1462
1596
  return {
1463
- "status": "success", "message": "CSV文件测试用例提交完成",
1597
+ "status": "success", "message": "CSV批量导入完成(showImport接口)",
1464
1598
  "total_cases": len(results), "success_count": success_count,
1465
1599
  "failed_count": failed_count,
1466
1600
  "success_rate": (success_count / len(results) * 100) if results else 0.0,
@@ -1474,10 +1608,14 @@ class ZenTaoNexus:
1474
1608
 
1475
1609
  # ─────────── 我的地盘 ───────────
1476
1610
 
1611
+ _MY_BUG_TYPE_MAP = {
1612
+ 'assigntome': 'assignedTo',
1613
+ 'assignedtome': 'assignedTo',
1614
+ }
1615
+
1477
1616
  def get_my_bug(self, type_='assigntome') -> Dict:
1478
- if type_ == 'assigntome':
1479
- return self.client.bug_browse(0, 'assigntome')
1480
- return self.client.my_bug(type_)
1617
+ api_type = self._MY_BUG_TYPE_MAP.get(type_, type_)
1618
+ return self.client.my_bug(api_type)
1481
1619
 
1482
1620
  def get_my_task(self, type_='assignedtome') -> Dict:
1483
1621
  return self.client.my_task(type_)
@@ -1490,7 +1628,7 @@ class ZenTaoNexus:
1490
1628
 
1491
1629
  def get_my_work(self) -> Dict:
1492
1630
  """获取"我的待办"概览"""
1493
- bugs = self.client.my_bug('assigntome')
1631
+ bugs = self.client.my_bug('assignedTo')
1494
1632
  tasks = self.client.my_task('assignedtome')
1495
1633
  stories = self.client.my_story('assignedtome')
1496
1634
  todos = self.client.my_todo('all')
@@ -8,11 +8,14 @@ from .config import build_user_context
8
8
  logger = logging.getLogger("zentaoagent")
9
9
  audit_logger = logging.getLogger("zentao_audit")
10
10
  from .intent_parser import parse_intent, IntentResult
11
- from .session_manager import get_session, create_session, update_session, clear_session, is_cancel_keyword
12
- from .session_manager import get_last_bug_ids
11
+ from .session_manager import (
12
+ get_session, create_session, update_session, clear_session, is_cancel_keyword,
13
+ get_last_bug_ids, set_last_action, get_last_action,
14
+ )
13
15
  from .handlers import HANDLERS
14
16
  from .formatters import format_unbound_user, format_unknown, format_error
15
17
  from .zentao_client import ZentaoPermissionError
18
+ from . import llm_client
16
19
 
17
20
  _ALLOWED_INTENTS = frozenset(HANDLERS.keys()) | {"unknown"}
18
21
 
@@ -50,6 +53,7 @@ def dispatch(
50
53
  session_webhook: str,
51
54
  conversation_id: str,
52
55
  conversation_type: int,
56
+ image_paths: list[str] | None = None,
53
57
  ) -> str:
54
58
  user_context = build_user_context(staff_id, staff_nick, session_webhook, conversation_id, conversation_type)
55
59
  if not user_context:
@@ -68,7 +72,9 @@ def dispatch(
68
72
  if pending and content.strip().lower() in _CONFIRM_KEYWORDS:
69
73
  _pending_confirms.pop(session_key, None)
70
74
  logger.info("二次确认通过 intent=%s session=%s", pending.intent, session_key)
71
- return _execute_handler(pending, user_context)
75
+ reply = _execute_handler(pending, user_context)
76
+ set_last_action(session_key, pending.intent, pending.params, reply)
77
+ return reply
72
78
  elif pending:
73
79
  _pending_confirms.pop(session_key, None)
74
80
 
@@ -81,11 +87,12 @@ def dispatch(
81
87
  )
82
88
  merged_params = {**session_state.params, **intent_result.params}
83
89
  intent_result.params = merged_params
84
- intent_result.missing_params = [p for p in intent_result.missing_params if p not in merged_params]
90
+ intent_result.missing_params = [p for p in intent_result.missing_params if not merged_params.get(p)]
85
91
  if intent_result.intent == "bug_close":
86
92
  _fix_bug_close_missing(intent_result, session_key)
87
93
  if intent_result.intent == "bug_resolve":
88
94
  _fix_bug_resolve_missing(intent_result)
95
+ _inject_image_paths(intent_result, image_paths)
89
96
  if intent_result.missing_params:
90
97
  _enrich_product_suggestion(intent_result, user_context)
91
98
  if intent_result.intent == "bug_close" and ("bug_id" in intent_result.missing_params or "bug_ids" in intent_result.missing_params):
@@ -103,16 +110,22 @@ def dispatch(
103
110
  return _format_missing_params(intent_result.missing_params)
104
111
  clear_session(session_key)
105
112
  else:
106
- intent_result = parse_intent(content, user_context)
113
+ last_action = get_last_action(session_key)
114
+ intent_result = parse_intent(content, user_context, last_action=last_action)
107
115
 
108
- if intent_result.confidence < 0.6 and intent_result.clarify_question:
116
+ if intent_result.intent != "unknown" and intent_result.confidence < 0.6 and intent_result.clarify_question:
109
117
  return intent_result.clarify_question
110
118
 
119
+ if intent_result.intent == "unknown" or intent_result.intent not in HANDLERS:
120
+ return _handle_general_chat(content, image_paths, user_context)
121
+
111
122
  if intent_result.intent == "bug_close":
112
123
  _fix_bug_close_missing(intent_result, session_key)
113
124
  if intent_result.intent == "bug_resolve":
114
125
  _fix_bug_resolve_missing(intent_result)
115
126
 
127
+ _inject_image_paths(intent_result, image_paths)
128
+
116
129
  if intent_result.missing_params:
117
130
  _enrich_product_suggestion(intent_result, user_context)
118
131
  if intent_result.intent == "bug_close" and ("bug_id" in intent_result.missing_params or "bug_ids" in intent_result.missing_params):
@@ -139,7 +152,9 @@ def dispatch(
139
152
  logger.info("意图解析 intent=%s confidence=%.2f params=%s missing=%s",
140
153
  intent_result.intent, intent_result.confidence,
141
154
  intent_result.params, intent_result.missing_params)
142
- return _execute_handler(intent_result, user_context)
155
+ reply = _execute_handler(intent_result, user_context)
156
+ set_last_action(session_key, intent_result.intent, intent_result.params, reply)
157
+ return reply
143
158
 
144
159
 
145
160
  _WRITE_INTENTS = frozenset({
@@ -178,6 +193,16 @@ def _execute_handler(intent_result: IntentResult, user_context) -> str:
178
193
  return format_error(str(e))
179
194
 
180
195
 
196
+ def _inject_image_paths(intent_result: IntentResult, image_paths: list[str] | None) -> None:
197
+ """当用户通过钉钉直接发图且意图需要文件时,自动注入本地文件路径。"""
198
+ if not image_paths:
199
+ return
200
+ if intent_result.intent == "bug_upload" and not intent_result.params.get("file_path"):
201
+ intent_result.params["file_path"] = image_paths[0]
202
+ intent_result.missing_params = [p for p in intent_result.missing_params if p != "file_path"]
203
+ logger.info("自动注入图片路径 file_path=%s", image_paths[0])
204
+
205
+
181
206
  def _fix_bug_resolve_missing(intent_result: IntentResult) -> None:
182
207
  """resolution 默认 fixed,不追问用户"""
183
208
  if "resolution" in intent_result.missing_params:
@@ -246,3 +271,22 @@ def _format_missing_params(missing_params: list) -> str:
246
271
  lines.append(f"{i}️⃣ {name}是什么?")
247
272
  lines.append("\n直接告诉我就行,比如「登录报错,指派给张三」")
248
273
  return "\n".join(lines)
274
+
275
+
276
+ def _handle_general_chat(content: str, image_paths: list[str] | None, user_context) -> str:
277
+ """非禅道意图 → 千问通用问答(含图片分析),失败时回退到 format_unknown"""
278
+ try:
279
+ from .intent_parser import _load_soul
280
+ soul = _load_soul()
281
+ reply = llm_client.general_chat(
282
+ content,
283
+ image_paths=image_paths,
284
+ user_name=user_context.zentao_name,
285
+ soul_content=soul,
286
+ )
287
+ if reply:
288
+ logger.info("通用问答回复 user=%s reply=%s", user_context.zentao_code, reply[:100])
289
+ return reply
290
+ except Exception:
291
+ logger.exception("通用问答异常")
292
+ return format_unknown()