@hupan56/wlkj 2.7.5 → 2.7.7

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/bin/cli.js CHANGED
@@ -313,10 +313,24 @@ async function doInit(name, roleArg) {
313
313
  console.log(` setup.py 未找到, 跳过自动初始化`);
314
314
  }
315
315
 
316
+ // === 6.4. 装 pip 依赖 (必须在配 MCP 之前!)
317
+ // 顺序关键: 先装依赖 → 再写 mcp.json → mcp.json 选到"装了依赖的Python" → MCP 秒绿
318
+ // 旧版 init 不装依赖直接配 mcp.json → 新机 MCP 全黄 (没装 duckdb/pymysql)
319
+ console.log(`\n--- Python 依赖安装 (失败自动切镜像) ---`);
320
+ const reqFileInit = path.join(cwd, "requirements.txt");
321
+ if (pyCmd && fs.existsSync(reqFileInit)) {
322
+ installPipDeps(pyCmd, reqFileInit);
323
+ } else if (!pyCmd) {
324
+ console.log(` [跳过] Python 未装`);
325
+ } else {
326
+ console.log(` [跳过] requirements.txt 不存在`);
327
+ }
328
+
316
329
  // === 6.5. 自动配 QoderWork MCP (新机关键: 否则 mcp.json 不生成, 4 个 MCP 全连不上) ===
317
- // 复刻 doUpdate 第 7 步的逻辑: 拷 launcher + install_qoderwork --mcp-only
330
+ // 复刻 doUpdate 第 6 步的逻辑: 拷 launcher + install_qoderwork --mcp-only
318
331
  // setup.py 带了 --skip-qoderwork (offer_qoderwork 是交互式+装skill, 不适合 npx),
319
332
  // 这里用 --mcp-only (纯文件操作, 无交互) 把 mcp.json 配好
333
+ // 注意: 此时依赖已装好(6.4步), install_qoderwork 会选到装了依赖的 Python
320
334
  if (pyCmd) {
321
335
  console.log(`\n--- QoderWork MCP 自动配置 ---`);
322
336
  const home = process.env.USERPROFILE || require("os").homedir();
@@ -332,7 +346,7 @@ async function doInit(name, roleArg) {
332
346
  if (fs.existsSync(instScript)) {
333
347
  try {
334
348
  execSync(`${pyCmd} "${instScript}" --mcp-only`, { stdio: "inherit", timeout: 20000 });
335
- console.log(` [OK] mcp.json 已生成 (kg/mysql/lanhu/playwright, launcher 模式)`);
349
+ console.log(` [OK] mcp.json 已生成 (选装了依赖的Python, MCP 不会黄)`);
336
350
  } catch (e) {
337
351
  console.log(` [WARN] MCP 配置未完全成功 (不阻塞): ${(e.message || "").slice(0, 80)}`);
338
352
  console.log(` 可手动补: ${pyCmd} .qoder/scripts/install_qoderwork.py`);
@@ -557,13 +571,27 @@ function doUpdate() {
557
571
  console.log(` git: 跳过(${(e.message || "").slice(0, 50)})`);
558
572
  }
559
573
 
560
- // === 3. 刷新 QoderWork commands(强制覆盖已存在的)===
574
+ // === 3. 补装 pip 依赖 (必须在刷 mcp.json 之前!)
575
+ // 顺序很关键: 先装依赖 → 再写 mcp.json → mcp.json 能选到"已装依赖的Python"
576
+ // 旧版顺序反了(先写mcp.json再装依赖), 导致 mcp.json 指向没装依赖的Python → MCP 黄。
577
+ console.log(`\n--- Python 依赖补装 (失败自动切镜像) ---`);
578
+ const reqFile = path.join(cwd, "requirements.txt");
579
+ const pyCmd = detectPyCmd();
580
+ if (pyCmd && fs.existsSync(reqFile)) {
581
+ installPipDeps(pyCmd, reqFile);
582
+ } else if (!pyCmd) {
583
+ console.log(` [跳过] Python 未装`);
584
+ } else {
585
+ console.log(` [跳过] requirements.txt 不存在`);
586
+ }
587
+
588
+ // === 4. 刷新 QoderWork commands(强制覆盖已存在的)===
561
589
  runQoderWorkInstall(cwd, true);
562
590
 
563
- // === 4. 写版本戳 ===
591
+ // === 5. 写版本戳 ===
564
592
  writeEngineVersion(cwd);
565
593
 
566
- // === 5. 清理旧版废弃文件 ===
594
+ // === 5b. 清理旧版废弃文件 ===
567
595
  const OBSOLETE_FILES = [
568
596
  // 旧脚本(已替代)
569
597
  ".qoder/workflow.md", // 被 rules/wl-pipeline.md 替代
@@ -669,19 +697,8 @@ function doUpdate() {
669
697
  console.log(` [清理] 废弃文件 ${cleaned} 个 + 过期缓存 ${cacheCleaned} 个`);
670
698
  }
671
699
 
672
- // === 6. 补装 pip 依赖 (老用户升级时补齐, 走镜像兜底) ===
673
- console.log(`\n--- Python 依赖补装 (失败自动切镜像) ---`);
674
- const reqFile = path.join(cwd, "requirements.txt");
675
- const pyCmd = detectPyCmd();
676
- if (pyCmd && fs.existsSync(reqFile)) {
677
- installPipDeps(pyCmd, reqFile);
678
- } else if (!pyCmd) {
679
- console.log(` [跳过] Python 未装`);
680
- } else {
681
- console.log(` [跳过] requirements.txt 不存在`);
682
- }
683
-
684
- // === 7. 刷新 launcher + mcp.json (老用户的可能是旧版/写死路径) ===
700
+ // === 6. 刷新 launcher + mcp.json (老用户的可能是旧版/写死路径) ===
701
+ // 注意: pip 依赖已在第3步装好, 此处 install_qoderwork 会选到"已装依赖的Python"写进 mcp.json
685
702
  try {
686
703
  const home = process.env.USERPROFILE || require("os").homedir();
687
704
  const launcherSrc = path.join(cwd, ".qoder", "scripts", "mcp_launcher.py");
@@ -696,12 +713,12 @@ function doUpdate() {
696
713
  if (pyCmd && fs.existsSync(instScript)) {
697
714
  try {
698
715
  execSync(`${pyCmd} "${instScript}" --mcp-only`, { stdio: "pipe", timeout: 15000 });
699
- console.log(` [OK] mcp.json 已刷新为动态路径`);
700
- } catch { /* --mcp-only 可能不支持, 静默跳过 */ }
716
+ console.log(` [OK] mcp.json 已刷新 (选装了依赖的Python, 避免MCP黄)`);
717
+ } catch (e) { /* --mcp-only 可能不支持, 静默跳过 */ }
701
718
  }
702
719
  } catch (e) { /* 刷新失败不阻塞升级 */ }
703
720
 
704
- // === 8. 自检: 关键文件到底齐不齐 ===
721
+ // === 7. 自检: 关键文件到底齐不齐 ===
705
722
  console.log(`\n--- 自检 (关键文件验证) ---`);
706
723
  const checks = [
707
724
  ["requirements.txt", path.join(cwd, "requirements.txt"), "依赖清单(没它pip装不了)"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hupan56/wlkj",
3
- "version": "2.7.5",
3
+ "version": "2.7.7",
4
4
  "description": "AI Product R&D Workflow - PRD/Prototype/Search/Task/Report",
5
5
  "bin": {
6
6
  "wlkj": "bin/cli.js"
@@ -1525,6 +1525,35 @@ def cmd_recall(args):
1525
1525
  return 0
1526
1526
 
1527
1527
 
1528
+ # ---------------------------------------------------------------------------
1529
+ # 子命令: page (页面按钮+接口画像, 开发→测试交接物)
1530
+ # ---------------------------------------------------------------------------
1531
+
1532
+ def cmd_page(args):
1533
+ """查页面按钮+接口画像。薄入口, 实际逻辑在 page_probe.py。
1534
+ 数据来自知识图谱 traces 表, 无图谱则静默提示。
1535
+ """
1536
+ try:
1537
+ import page_probe
1538
+ except ImportError:
1539
+ # 直接当脚本跑时, 补一下 sys.path
1540
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
1541
+ import page_probe
1542
+
1543
+ profile = page_probe.build_page_profile(keyword=getattr(args, 'kw', None),
1544
+ url=getattr(args, 'url', None))
1545
+ label = args.kw or args.url
1546
+ print('## 页面画像: %s' % label)
1547
+ print()
1548
+ print(page_probe.render_profile_md(profile))
1549
+ if profile.get('ok'):
1550
+ n_pages = len(profile['pages'])
1551
+ n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
1552
+ print()
1553
+ print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
1554
+ return 0
1555
+
1556
+
1528
1557
  # ---------------------------------------------------------------------------
1529
1558
  # 子命令: report
1530
1559
  # ---------------------------------------------------------------------------
@@ -1683,6 +1712,13 @@ def build_parser():
1683
1712
  prc.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login)')
1684
1713
  prc.set_defaults(func=cmd_recall)
1685
1714
 
1715
+ # page: 查页面按钮+接口画像 (开发→测试交接物, 数据来自知识图谱 traces 表)
1716
+ ppg = sub.add_parser('page', help='查页面按钮+接口画像(开发交接给测试,照着写用例)')
1717
+ ppg_grp = ppg.add_mutually_exclusive_group(required=True)
1718
+ ppg_grp.add_argument('--kw', help='业务关键词(如 保险 / 排班)')
1719
+ ppg_grp.add_argument('--url', help='页面 URL 或路径关键词(如 /veh/insurance)')
1720
+ ppg.set_defaults(func=cmd_page)
1721
+
1686
1722
  return p
1687
1723
 
1688
1724
 
@@ -315,21 +315,68 @@ def setup_lanhu_mcp(dry=False):
315
315
 
316
316
 
317
317
  def detect_python_command():
318
- """探测本机可用的 python 命令名 (跨平台)
319
- Windows: py > python (无 python3); mac/linux: python3 > python。
320
- 返回命令名字符串 (在 PATH 里的), 失败返回 None。"""
321
- for cand in (('py', '-3'), ('python3', None), ('python', None)):
322
- name, extra = cand
318
+ """探测本机可用的 python 命令名 (跨平台), 用于写进 mcp.json 的 command 字段。
319
+
320
+ ⚠️ 关键约束: 返回值必须是单个可执行文件名/路径, 不能含空格或参数。
321
+ 旧版返回 'py -3' (带参数), MCP 客户端 spawn('py -3', args) 会把
322
+ 'py -3' 当字面文件名 → 找不到可执行文件 → MCP 显示黄色(连接失败)。
323
+
324
+ 优先级 (Windows): python > py > python3
325
+ - 故意把 python 放 py 前面: 'python' 是单文件名, 最稳;
326
+ 'py' 需要配合 -3 参数, 但我们返回纯 'py' (不带 -3) 也通常能用
327
+ (py 启动器会选默认版本)。
328
+ 优先级 (mac/linux): python3 > python
329
+
330
+ 返回: 命令名字符串 (如 'python' / 'py' / 'python3'), 失败返回 None。
331
+ """
332
+ if sys.platform == 'win32':
333
+ candidates = ('python', 'py', 'python3')
334
+ else:
335
+ candidates = ('python3', 'python')
336
+ for name in candidates:
323
337
  if not shutil.which(name):
324
338
  continue
325
- cmd = [name] + ([extra] if extra else []) + ['--version']
326
339
  try:
327
- r = subprocess.run(cmd, capture_output=True, text=True,
340
+ r = subprocess.run([name, '--version'], capture_output=True, text=True,
328
341
  encoding='utf-8', errors='replace')
329
342
  if r.returncode == 0:
330
- # 返回完整调用前缀 (py -3 / python3 / python)
331
- return ' '.join(cmd[:-1])
332
- except (OSError, subprocess.SubprocessError):
343
+ return name # 纯命令名, 不带参数
344
+ except (OSError, subprocess.SubprocessProcessError):
345
+ continue
346
+ return None
347
+
348
+
349
+ def find_python_with_core_deps():
350
+ """找一个装了核心依赖 (duckdb/pymysql/yaml/requests) 的 Python 可执行文件。
351
+
352
+ 用于写 mcp.json: 如果某个 Python 已经装了核心依赖, 直接用它当 command,
353
+ launcher 启动后 _find_python_with_deps 会秒命中 sys.executable, 不用再遍历。
354
+ 解决"多 Python 共存, 装到了 A 但 mcp.json 指向 B"的常见问题。
355
+
356
+ 返回: python 可执行文件路径(str) 或 None(没找到装了依赖的)。
357
+ """
358
+ # 先测当前 Python (install_qoderwork 正在跑的这个)
359
+ for mod in ('duckdb', 'pymysql', 'yaml', 'requests'):
360
+ try:
361
+ __import__(mod)
362
+ except ImportError:
363
+ break
364
+ else:
365
+ # 当前 Python 四个核心依赖都有 → 用它
366
+ return sys.executable
367
+
368
+ # 当前 Python 没装全, 找其它 Python
369
+ for name in ('python', 'python3', 'py'):
370
+ p = shutil.which(name)
371
+ if not p or p == sys.executable:
372
+ continue
373
+ try:
374
+ r = subprocess.run(
375
+ [p, '-c', 'import duckdb, pymysql, yaml, requests'],
376
+ capture_output=True, timeout=8)
377
+ if r.returncode == 0:
378
+ return p
379
+ except (OSError, subprocess.SubprocessProcessError):
333
380
  continue
334
381
  return None
335
382
 
@@ -357,7 +404,15 @@ def rewrite_mcp_json_for_launcher():
357
404
  if not isinstance(servers, dict):
358
405
  return
359
406
 
360
- py_cmd = detect_python_command() or "python"
407
+ # Python 写进 mcp.json:
408
+ # 优先级 1: 找一个已经装了核心依赖(duckdb/pymysql/yaml/requests)的 Python
409
+ # → 这样 launcher 启动后秒命中, MCP 秒绿
410
+ # 优先级 2: 任意可用 python 命令名 (launcher 内部会再找)
411
+ py_with_deps = find_python_with_core_deps()
412
+ if py_with_deps:
413
+ py_cmd = py_with_deps # 完整路径, 最可靠
414
+ else:
415
+ py_cmd = detect_python_command() or "python"
361
416
  launcher = str(_HOME / ".qoderwork" / "mcp_launcher.py")
362
417
  # MCP 名 → launcher 子命令
363
418
  name_map = {
@@ -374,9 +429,14 @@ def rewrite_mcp_json_for_launcher():
374
429
  servers[srv] = {"command": py_cmd, "args": [launcher, sub]}
375
430
  changed = True
376
431
  continue
377
- # 已是 launcher 模式?
432
+ # 已是 launcher 模式? 检查 command 是否需要更新 (Python 路径可能变了)
378
433
  args = entry.get("args", []) if isinstance(entry, dict) else []
379
434
  if args and any("mcp_launcher.py" in str(a) for a in args):
435
+ # launcher 模式, 但 command 可能指向旧的/错的 Python → 更新
436
+ old_cmd = entry.get("command", "") if isinstance(entry, dict) else ""
437
+ if old_cmd != py_cmd:
438
+ servers[srv] = {"command": py_cmd, "args": [launcher, sub]}
439
+ changed = True
380
440
  continue
381
441
  # mysql env 抽离到独立文件
382
442
  if srv == "qoder-mysql" and isinstance(entry, dict) and entry.get("env"):
@@ -396,7 +456,11 @@ def rewrite_mcp_json_for_launcher():
396
456
  QODERWORK_MCP_FILE.write_text(
397
457
  json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
398
458
  print(f"\n--- mcp.json 重写为 launcher 模式 ---")
399
- print(f" [UPD] kg/mysql/lanhu → command={py_cmd}, args=[mcp_launcher.py, <名>]")
459
+ if py_with_deps:
460
+ print(f" [UPD] kg/mysql/zentao/lanhu → command={py_cmd} (装了依赖的Python)")
461
+ else:
462
+ print(f" [UPD] kg/mysql/zentao/lanhu → command={py_cmd}")
463
+ print(f" args=[mcp_launcher.py, <名>] (launcher 内部找装依赖的 Python)")
400
464
  print(f" 不再写死仓库路径/venv路径, 跨机器通用")
401
465
  except OSError as e:
402
466
  print(f" [ERR] mcp.json 重写失败: {e}")
@@ -216,13 +216,15 @@ def launch_kg(repo):
216
216
  server = repo / '.qoder' / 'scripts' / 'kg_mcp_server.py'
217
217
  if not server.is_file():
218
218
  _log(f'kg server 不存在: {server}')
219
- sys.exit(1)
219
+ _launch_disabled_mcp('qoder-knowledge-graph',
220
+ 'kg_mcp_server.py 缺失。跑 npx @hupan56/wlkj update')
221
+ return
220
222
  py = _find_python_with_deps('duckdb')
221
223
  if not py:
222
224
  _log('⚠️ 找不到装了 duckdb 的 Python! 跑: pip install duckdb')
223
225
  _log(' 或: npx @hupan56/wlkj install-env (自动装依赖)')
224
226
  _launch_disabled_mcp('qoder-knowledge-graph',
225
- 'duckdb 未装。跑 pip install duckdb 或 npx @hupan56/wlkj install-env')
227
+ 'duckdb 未装。跑 npx @hupan56/wlkj install-env (自动装依赖)')
226
228
  return
227
229
  _log(f'启动 kg (python={py})')
228
230
  os.execv(py, [py, str(server)])
@@ -368,26 +370,42 @@ LAUNCHERS = {
368
370
 
369
371
 
370
372
  def main():
373
+ # ⚠️ 关键: 任何失败都走 _launch_disabled_mcp(), 绝不 sys.exit()。
374
+ # sys.exit(1) 会让进程退出 → QoderWork 显示黄色(连接错误), 用户以为坏了。
375
+ # _launch_disabled_mcp() 启动一个干净 stdio 循环响应 initialize →
376
+ # QoderWork 显示绿色(已连接, 0工具), 用户知道是"未启用"而非"坏了"。
371
377
  if len(sys.argv) < 2:
372
- _log('用法: mcp_launcher.py <kg|mysql|lanhu>')
378
+ _log('用法: mcp_launcher.py <kg|mysql|zentao|lanhu>')
373
379
  _log(f' argv={sys.argv}')
374
- sys.exit(1)
380
+ _launch_disabled_mcp('qoder-mcp',
381
+ '启动参数缺失: mcp_launcher.py <kg|mysql|zentao|lanhu>')
382
+ return
375
383
  name = sys.argv[1].lower()
376
384
 
377
385
  repo = find_repo_root()
378
386
  if not repo:
379
387
  _log('无法定位 QODER 仓库根。')
380
388
  _log(' 方式1: 设环境变量 QODER_REPO=<仓库根>')
381
- _log(' 方式2: 重跑 python .qoder/scripts/install_qoderwork.py ( .repo-root)')
389
+ _log(' 方式2: 重跑 npx @hupan56/wlkj update (刷新 .repo-root)')
382
390
  _log(' 方式3: 在仓库目录内运行')
383
- sys.exit(1)
391
+ _launch_disabled_mcp(name,
392
+ '未定位到 QODER 仓库根。跑 npx @hupan56/wlkj update')
393
+ return
384
394
  _log(f'仓库根: {repo}')
385
395
 
386
396
  fn = LAUNCHERS.get(name)
387
397
  if not fn:
388
398
  _log(f'未知 MCP 名: {name} (支持: {list(LAUNCHERS)})')
389
- sys.exit(1)
390
- fn(repo)
399
+ _launch_disabled_mcp(name,
400
+ '未知 MCP 名: %s (支持: %s)' % (name, ', '.join(LAUNCHERS)))
401
+ return
402
+ try:
403
+ fn(repo)
404
+ except Exception as e:
405
+ # 启动函数抛异常也不能让进程死 (死=黄, 捕获=绿)
406
+ _log(f'启动 {name} 时异常: {e}')
407
+ _launch_disabled_mcp(name,
408
+ '%s MCP 启动异常: %s。跑 npx @hupan56/wlkj update' % (name, e))
391
409
 
392
410
 
393
411
  if __name__ == '__main__':
@@ -0,0 +1,459 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ page_probe.py - 页面按钮+接口画像 (开发→测试交接物)
4
+
5
+ 从 DuckDB 知识图谱的 traces 表查"某个页面有哪些按钮、点按钮调哪个接口",
6
+ 输出一份测试人员能照着写用例的画像。解决痛点:开发写完代码交接给测试时,
7
+ 测试不用再满仓库找页面/按钮/接口。
8
+
9
+ 数据源: data/index/kg.duckdb 的 traces 表
10
+ (vue_file, button, handler, api_fn, endpoint, verb, confidence)
11
+ URL→Vue 映射: data/index/menu-routes.json (sys_menu 真实路由)
12
+
13
+ 非侵入: 无 DuckDB / 无 traces 表 → 静默回退, 只 print 提示, 不报错。
14
+
15
+ Usage (作为库):
16
+ from page_probe import build_page_profile, render_profile_md
17
+ md = render_profile_md(build_page_profile(keyword='保险'))
18
+
19
+ Usage (CLI):
20
+ python .qoder/scripts/page_probe.py --kw 保险
21
+ python .qoder/scripts/page_probe.py --url /veh/vehicle/vehAffair/insurance
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import sys
27
+ from typing import Optional, List, Dict, Any
28
+
29
+ if sys.platform == 'win32':
30
+ try:
31
+ sys.stdout.reconfigure(encoding='utf-8')
32
+ except (AttributeError, IOError):
33
+ pass
34
+
35
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
36
+ if _THIS_DIR not in sys.path:
37
+ sys.path.insert(0, _THIS_DIR)
38
+ _COMMON = os.path.join(_THIS_DIR, 'common')
39
+ if _COMMON not in sys.path:
40
+ sys.path.insert(0, _COMMON)
41
+
42
+ from common.paths import get_repo_root # noqa: E402
43
+
44
+
45
+ # ============================================================
46
+ # 路径定位
47
+ # ============================================================
48
+
49
+ def _kg_db_path(repo_root: Optional[str] = None) -> str:
50
+ rr = repo_root or str(get_repo_root())
51
+ return os.path.join(rr, 'data', 'index', 'kg.duckdb')
52
+
53
+
54
+ def _menu_routes_path(repo_root: Optional[str] = None) -> str:
55
+ rr = repo_root or str(get_repo_root())
56
+ return os.path.join(rr, 'data', 'index', 'menu-routes.json')
57
+
58
+
59
+ # ============================================================
60
+ # DuckDB 查询 (只读, 失败静默回退)
61
+ # ============================================================
62
+
63
+ def _query_traces(vue_pattern: str, repo_root: Optional[str] = None) -> List[Dict[str, Any]]:
64
+ """查 traces 表里 vue_file 匹配 pattern 的所有按钮+接口。
65
+ pattern 用 LIKE (大小写不敏感, 因为下面转 lower)。
66
+ 返回 [{vue_file, button, handler, api_fn, endpoint, verb, confidence}, ...]
67
+ 无 DuckDB / 无表 / 查询失败 → 返回 [] (静默)。
68
+ """
69
+ try:
70
+ import duckdb
71
+ except ImportError:
72
+ return []
73
+ db = _kg_db_path(repo_root)
74
+ if not os.path.isfile(db):
75
+ return []
76
+ try:
77
+ con = duckdb.connect(db, read_only=True)
78
+ # 确认 traces 表存在
79
+ tables = [r[0] for r in con.execute(
80
+ "SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
81
+ ).fetchall()]
82
+ if 'traces' not in tables:
83
+ con.close()
84
+ return []
85
+ # LIKE 匹配 (vue_file 路径里有正斜杠, pattern 用 % 通配)
86
+ pat = '%' + vue_pattern.lower() + '%'
87
+ rows = con.execute(
88
+ "SELECT vue_file, button, handler, api_fn, endpoint, verb, confidence "
89
+ "FROM traces WHERE lower(vue_file) LIKE ? "
90
+ "ORDER BY CASE confidence "
91
+ " WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 "
92
+ " WHEN 'deferred' THEN 3 ELSE 4 END, button",
93
+ [pat],
94
+ ).fetchall()
95
+ con.close()
96
+ return [
97
+ {
98
+ 'vue_file': r[0], 'button': r[1], 'handler': r[2],
99
+ 'api_fn': r[3], 'endpoint': r[4], 'verb': r[5], 'confidence': r[6],
100
+ }
101
+ for r in rows
102
+ if r[1] # button 不能为空
103
+ ]
104
+ except Exception:
105
+ return []
106
+
107
+
108
+ def _trace_stats(repo_root: Optional[str] = None) -> Optional[Dict[str, int]]:
109
+ """traces 表的置信度统计 (用于画像末尾标注覆盖率)。失败返回 None。"""
110
+ try:
111
+ import duckdb
112
+ except ImportError:
113
+ return None
114
+ db = _kg_db_path(repo_root)
115
+ if not os.path.isfile(db):
116
+ return None
117
+ try:
118
+ con = duckdb.connect(db, read_only=True)
119
+ rows = con.execute(
120
+ "SELECT confidence, COUNT(*) FROM traces GROUP BY confidence"
121
+ ).fetchall()
122
+ con.close()
123
+ stats = {r[0]: int(r[1]) for r in rows}
124
+ stats['total'] = sum(stats.values())
125
+ resolved = stats.get('high', 0) + stats.get('medium', 0) + stats.get('low', 0)
126
+ stats['resolved'] = resolved
127
+ return stats
128
+ except Exception:
129
+ return None
130
+
131
+
132
+ # ============================================================
133
+ # URL → Vue 文件映射 (走菜单路由表)
134
+ # ============================================================
135
+
136
+ def _load_menu_routes(repo_root: Optional[str] = None) -> List[Dict[str, Any]]:
137
+ """读 menu-routes.json。格式: {total, source, routes: [{name, path, component}, ...]}"""
138
+ p = _menu_routes_path(repo_root)
139
+ if not os.path.isfile(p):
140
+ return []
141
+ try:
142
+ for enc in ('utf-8', 'utf-8-sig', 'gbk'):
143
+ try:
144
+ with open(p, encoding=enc) as f:
145
+ data = json.load(f)
146
+ # 支持 {routes: [...]} 包装和裸 [...] 两种
147
+ if isinstance(data, dict):
148
+ data = data.get('routes') or []
149
+ return data if isinstance(data, list) else []
150
+ except (OSError, IOError, UnicodeDecodeError, json.JSONDecodeError):
151
+ continue
152
+ except Exception:
153
+ return []
154
+ return []
155
+
156
+
157
+ def _match_routes(keyword: str, routes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
158
+ """用关键词匹配菜单 (name/path/component 任一包含关键词, 大小写不敏感)。
159
+ 返回匹配的菜单项 [{name, path, component}], 按相关度排序。
160
+ """
161
+ kw = keyword.lower().strip()
162
+ if not kw:
163
+ return []
164
+ hits = []
165
+ for r in routes:
166
+ name = (r.get('name') or r.get('menu_name') or '').lower()
167
+ path = (r.get('path') or r.get('url') or '').lower()
168
+ comp = (r.get('component') or r.get('component_path') or '').lower()
169
+ # 完全匹配 name 优先级最高, 其次 path, 最后 component
170
+ score = 0
171
+ if kw == name:
172
+ score = 100
173
+ elif kw in name:
174
+ score = 80
175
+ elif kw in path:
176
+ score = 60
177
+ elif kw in comp:
178
+ score = 40
179
+ if score > 0:
180
+ hits.append((score, r))
181
+ hits.sort(key=lambda x: -x[0])
182
+ return [h[1] for h in hits[:8]] # 最多 8 个, 避免过多
183
+
184
+
185
+ def _route_to_vue_hint(route: Dict[str, Any]) -> str:
186
+ """从菜单项推断 Vue 文件路径关键词 (用于 traces 查询)。
187
+ component 字段常是 'veh/vehicle/vehAffair/insurance/index' 这种相对路径。
188
+ """
189
+ comp = route.get('component') or route.get('component_path') or ''
190
+ # 取 component 里最后两段 (模块名), 如 'insurance/record'
191
+ parts = [p for p in comp.replace('\\', '/').split('/') if p and p != 'index']
192
+ if len(parts) >= 2:
193
+ return '/'.join(parts[-2:])
194
+ if parts:
195
+ return parts[-1]
196
+ # fallback: 用 path 的最后一段
197
+ path = route.get('path') or route.get('url') or ''
198
+ seg = [p for p in path.split('/') if p]
199
+ return seg[-1] if seg else ''
200
+
201
+
202
+ # ============================================================
203
+ # 核心: 构建页面画像
204
+ # ============================================================
205
+
206
+ def build_page_profile(
207
+ keyword: Optional[str] = None,
208
+ url: Optional[str] = None,
209
+ repo_root: Optional[str] = None,
210
+ ) -> Dict[str, Any]:
211
+ """构建页面画像。
212
+ 输入: keyword (业务名如"保险") 或 url (/veh/insurance)。
213
+ 返回:
214
+ {
215
+ 'ok': True/False,
216
+ 'reason': '失败原因' (ok=False 时),
217
+ 'pages': [
218
+ {
219
+ 'name': '保险记录', 'url': '/veh/...', 'vue_hint': 'insurance/record',
220
+ 'buttons': [{button, handler, endpoint, verb, confidence, resolved}, ...],
221
+ 'vue_files': [实际命中的 vue 文件路径, ...]
222
+ }, ...
223
+ ],
224
+ 'stats': {total, resolved, ...} or None
225
+ }
226
+ """
227
+ rr = repo_root or str(get_repo_root())
228
+
229
+ # 1. 定位页面 (URL 直查 / 关键词查路由表)
230
+ routes = _load_menu_routes(rr)
231
+ matched_pages = [] # [{name, url, vue_hint}]
232
+
233
+ if url:
234
+ # URL 模式: 在路由表里找 path 匹配的, 找不到就用 url 自身做 vue 关键词
235
+ url_lower = url.lower()
236
+ hit = None
237
+ for r in routes:
238
+ rp = (r.get('path') or r.get('url') or '').lower()
239
+ if rp and (rp == url_lower or url_lower in rp or rp in url_lower):
240
+ hit = r
241
+ break
242
+ if hit:
243
+ matched_pages.append({
244
+ 'name': hit.get('name') or hit.get('menu_name') or url,
245
+ 'url': hit.get('path') or hit.get('url') or url,
246
+ 'vue_hint': _route_to_vue_hint(hit),
247
+ })
248
+ else:
249
+ # 路由表没命中, 用 url 最后一段做 vue 关键词
250
+ seg = [p for p in url.split('/') if p]
251
+ matched_pages.append({
252
+ 'name': url, 'url': url,
253
+ 'vue_hint': seg[-1] if seg else url.strip('/'),
254
+ })
255
+ elif keyword:
256
+ kw_hits = _match_routes(keyword, routes)
257
+ # 整词匹配不到 → 尝试关键词的前缀子串 (保险异常管理 → 保险/保险异常)
258
+ # 中文业务名常是"业务词+修饰词", 菜单名通常只含业务词主体
259
+ if not kw_hits and len(keyword) >= 3:
260
+ for sublen in (len(keyword) - 1, len(keyword) - 2, 2):
261
+ if sublen < 2:
262
+ break
263
+ sub = keyword[:sublen]
264
+ sub_hits = _match_routes(sub, routes)
265
+ if sub_hits:
266
+ kw_hits = sub_hits
267
+ break
268
+ if kw_hits:
269
+ for r in kw_hits:
270
+ matched_pages.append({
271
+ 'name': r.get('name') or r.get('menu_name') or keyword,
272
+ 'url': r.get('path') or r.get('url') or '',
273
+ 'vue_hint': _route_to_vue_hint(r),
274
+ })
275
+ else:
276
+ # 路由表没命中, 直接用关键词做 vue 关键词 (traces 表里搜)
277
+ # 注意: 中文搜 vue 英文路径会失败, 但留着兜底 (英文关键词场景)
278
+ matched_pages.append({
279
+ 'name': keyword, 'url': '', 'vue_hint': keyword,
280
+ })
281
+ else:
282
+ return {'ok': False, 'reason': '需要提供 --kw 或 --url', 'pages': [], 'stats': None}
283
+
284
+ # 2. 每个页面查 traces
285
+ pages_result = []
286
+ for pg in matched_pages:
287
+ hint = pg['vue_hint']
288
+ if not hint:
289
+ continue
290
+ traces = _query_traces(hint, rr)
291
+ if not traces:
292
+ continue
293
+ # 按 vue_file 分组 (一个 hint 可能命中多个 vue 文件, 各自列按钮)
294
+ by_vue = {}
295
+ for t in traces:
296
+ by_vue.setdefault(t['vue_file'], []).append(t)
297
+ for vue_file, btns in by_vue.items():
298
+ # 去重: 同一按钮名 + 同一 endpoint 只留一个
299
+ seen = set()
300
+ dedup = []
301
+ for b in btns:
302
+ key = (b['button'], b.get('endpoint') or '')
303
+ if key in seen:
304
+ continue
305
+ seen.add(key)
306
+ dedup.append({
307
+ 'button': b['button'],
308
+ 'handler': b['handler'],
309
+ 'endpoint': b['endpoint'],
310
+ 'verb': b['verb'],
311
+ 'confidence': b['confidence'],
312
+ 'resolved': bool(b['endpoint'] and b['endpoint'] != 'None'),
313
+ })
314
+ pages_result.append({
315
+ 'name': pg['name'],
316
+ 'url': pg['url'],
317
+ 'vue_file': vue_file,
318
+ 'buttons': dedup,
319
+ })
320
+
321
+ stats = _trace_stats(rr)
322
+
323
+ if not pages_result:
324
+ return {
325
+ 'ok': False,
326
+ 'reason': '知识图谱里没找到匹配的页面按钮 (可能页面未被索引, 或关键词不准)',
327
+ 'pages': [],
328
+ 'stats': stats,
329
+ }
330
+ return {'ok': True, 'pages': pages_result, 'stats': stats}
331
+
332
+
333
+ # ============================================================
334
+ # 渲染: Markdown 画像 (给测试人员看 / 写入 test-handoff.md)
335
+ # ============================================================
336
+
337
+ def render_profile_md(profile: Dict[str, Any]) -> str:
338
+ """把画像渲染成 Markdown。"""
339
+ if not profile.get('ok'):
340
+ lines = ['> ' + (profile.get('reason') or '无画像数据')]
341
+ stats = profile.get('stats')
342
+ if stats:
343
+ lines.append('> (知识图谱 traces 覆盖率 %.0f%%, 未覆盖的交互需现场确认)'
344
+ % (100.0 * stats.get('resolved', 0) / max(1, stats.get('total', 1))))
345
+ return '\n'.join(lines)
346
+
347
+ lines = []
348
+ total_buttons = 0
349
+ for pg in profile['pages']:
350
+ btns = pg['buttons']
351
+ total_buttons += len(btns)
352
+ lines.append('### %s' % pg['name'])
353
+ if pg.get('url'):
354
+ lines.append('路由: `%s`' % pg['url'])
355
+ # vue_file 只取 data/code/ 之后的相对路径, 更短
356
+ vf = pg.get('vue_file', '')
357
+ vf_rel = vf.split('/data/code/')[-1] if '/data/code/' in vf else vf
358
+ lines.append('Vue: `%s`' % vf_rel)
359
+ lines.append('')
360
+ resolved_btns = [b for b in btns if b['resolved']]
361
+ unresolved_btns = [b for b in btns if not b['resolved']]
362
+
363
+ if resolved_btns:
364
+ lines.append('| 按钮 | handler | 接口 | 方法 | 置信度 |')
365
+ lines.append('|------|---------|------|------|--------|')
366
+ for b in resolved_btns:
367
+ ep = b['endpoint'] or '?'
368
+ lines.append('| %s | %s | %s | %s | %s |' % (
369
+ b['button'], b['handler'] or '?', ep, (b['verb'] or '?').upper(),
370
+ b['confidence']))
371
+ lines.append('')
372
+
373
+ if unresolved_btns:
374
+ lines.append('**未解析到接口的按钮** (交互意图已知, 接口需现场确认):')
375
+ for b in unresolved_btns:
376
+ lines.append('- %s → %s (%s)' % (
377
+ b['button'], b['handler'] or '?', b['confidence']))
378
+ lines.append('')
379
+
380
+ # 测试建议 (按按钮类型自动给提示)
381
+ lines.append('**测试建议:**')
382
+ sugg = _test_suggestions(btns)
383
+ for s in sugg:
384
+ lines.append('- %s' % s)
385
+ lines.append('')
386
+
387
+ stats = profile.get('stats')
388
+ if stats:
389
+ cov = 100.0 * stats.get('resolved', 0) / max(1, stats.get('total', 1))
390
+ lines.append('> 📊 知识图谱 traces 覆盖率 %.0f%% (resolved %d/%d)。'
391
+ '未覆盖的按钮交互需现场点击确认。' % (cov, stats.get('resolved', 0), stats.get('total', 1)))
392
+ return '\n'.join(lines)
393
+
394
+
395
+ def _test_suggestions(btns: List[Dict[str, Any]]) -> List[str]:
396
+ """根据按钮文本自动生成测试建议。"""
397
+ sugg = []
398
+ btn_texts = [b['button'] for b in btns]
399
+ btn_set = set(btn_texts)
400
+ # 按常见按钮语义给建议
401
+ if any(k in btn_set for k in ('新增', '添加', '新建', '创建')):
402
+ sugg.append('新增类: fill 表单必填项 → click 新增按钮 → 断言接口返回成功 + 列表出现新记录')
403
+ if any(k in btn_set for k in ('编辑', '修改', '更新')):
404
+ sugg.append('编辑类: 选中记录 → click 编辑 → 断言表单回填 + 保存后接口 UPDATE 调用')
405
+ if any(k in btn_set for k in ('删除', '移除')):
406
+ sugg.append('删除类: click 删除 → 二次确认弹窗 → 断言接口 DELETE 调用 + 列表记录消失')
407
+ if any(k in btn_set for k in ('导出', '下载')):
408
+ sugg.append('导出类: click 导出 → 断言下载触发 (或接口 export 调用 + 文件生成)')
409
+ if any(k in btn_set for k in ('查询', '搜索', '筛选', '检索')):
410
+ sugg.append('查询类: fill 筛选条件 → click 查询 → 断言列表按条件刷新')
411
+ if any(k in btn_set for k in ('导入', '上传')):
412
+ sugg.append('导入类: upload 文件 → 断言解析结果 + 接口 import 调用')
413
+ if any(k in btn_set for k in ('提交', '保存', '确认')):
414
+ sugg.append('提交类: 填完表单 → click 提交 → 断言接口成功 + 状态流转')
415
+ # 异常场景 (通用)
416
+ if sugg:
417
+ sugg.append('异常场景: 必填项留空提交 → 断言校验提示; 接口超时/失败 → 断言错误提示')
418
+ else:
419
+ sugg.append('通用: 逐个点击按钮 → 断言对应接口被调用 + 页面反馈正确')
420
+ return sugg
421
+
422
+
423
+ # ============================================================
424
+ # CLI
425
+ # ============================================================
426
+
427
+ def main(argv=None):
428
+ import argparse
429
+ p = argparse.ArgumentParser(
430
+ prog='page_probe.py',
431
+ description='查页面按钮+接口画像 (开发→测试交接物)',
432
+ )
433
+ g = p.add_mutually_exclusive_group(required=True)
434
+ g.add_argument('--kw', help='业务关键词 (如 保险 / 排班)')
435
+ g.add_argument('--url', help='页面 URL (如 /veh/vehicle/vehAffair/insurance)')
436
+ p.add_argument('--repo', default=None, help='仓库根目录 (默认自动检测)')
437
+ args = p.parse_args(argv)
438
+
439
+ profile = build_page_profile(keyword=args.kw, url=args.url, repo_root=args.repo)
440
+
441
+ if not profile.get('ok'):
442
+ print('## 页面画像: %s' % (args.kw or args.url))
443
+ print()
444
+ print(render_profile_md(profile))
445
+ print()
446
+ sys.exit(0)
447
+
448
+ print('## 页面画像: %s' % (args.kw or args.url))
449
+ print()
450
+ print(render_profile_md(profile))
451
+ n_pages = len(profile['pages'])
452
+ n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
453
+ print()
454
+ print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
455
+ sys.exit(0)
456
+
457
+
458
+ if __name__ == '__main__':
459
+ main()
@@ -335,6 +335,13 @@ def cmd_finish(args: argparse.Namespace) -> int:
335
335
  if task_json_path.is_file():
336
336
  run_task_hooks("after_finish", task_json_path, repo_root)
337
337
 
338
+ # 🧩 测试交接画像 (可选增强): finish 后在任务目录生成 test-handoff.md,
339
+ # 列出涉及的页面/按钮/接口, 供测试人员照着写用例。失败绝不阻塞任务完成。
340
+ try:
341
+ _generate_test_handoff(task_dir, repo_root)
342
+ except Exception as e:
343
+ print("[测试交接画像] 跳过 (不阻塞): " + str(e)[:80])
344
+
338
345
  # 埋点: 任务完成反馈给 learning 引擎
339
346
  if _finish_ctx:
340
347
  try:
@@ -350,6 +357,177 @@ def cmd_finish(args: argparse.Namespace) -> int:
350
357
  return 0
351
358
 
352
359
 
360
+ def _extract_keywords(task_dir: Path) -> list:
361
+ """从任务目录名 + task.json 标题 + PRD 正文提取业务关键词。
362
+ 返回去重的关键词列表 (用于 page_probe 查页面画像)。
363
+ """
364
+ kws = []
365
+ # 1. 任务目录名: 06-11-智能排班-环卫行业 → 智能排班, 环卫行业
366
+ dirname = task_dir.name
367
+ # 去掉日期前缀 (MM-DD- 或 MM-DD-)
368
+ parts = dirname.split('-')
369
+ # 找到第一个非纯数字段开始
370
+ biz_parts = []
371
+ started = False
372
+ for p in parts:
373
+ if not started:
374
+ if p.isdigit() and len(p) <= 2:
375
+ continue # 跳过 MM, DD
376
+ started = True
377
+ if p.strip():
378
+ biz_parts.append(p.strip())
379
+ if biz_parts:
380
+ kws.append(''.join(biz_parts)) # 整体: 智能排班环卫行业
381
+ if len(biz_parts) > 1:
382
+ kws.extend(biz_parts) # 分开: 智能排班, 环卫行业
383
+
384
+ # 2. task.json 的 title 字段
385
+ title = ''
386
+ try:
387
+ tj = task_dir / FILE_TASK_JSON
388
+ if tj.is_file():
389
+ data = load_task_json(task_dir) or {}
390
+ title = data.get('title', '')
391
+ if title and title not in kws:
392
+ kws.append(title)
393
+ except Exception:
394
+ pass
395
+
396
+ # 2.5 从标题/目录名里切出短业务词 (保险异常筛选 → 保险, 异常, 筛选)
397
+ # page_probe 用 LIKE 查 vue 路径, 短词命中率更高
398
+ import re
399
+ cn_chunks = re.findall(r'[\u4e00-\u9fa5]+', (dirname + title))
400
+ short_words = set()
401
+ for chunk in cn_chunks:
402
+ # 2-4 字的连续子串都是有意义的业务词候选
403
+ if 2 <= len(chunk) <= 4:
404
+ short_words.add(chunk)
405
+ elif len(chunk) > 4:
406
+ # 长词切前4字、后4字、中间4字 (覆盖"智能排班系统"→智能排班/排班系统)
407
+ short_words.add(chunk[:4])
408
+ short_words.add(chunk[-4:])
409
+ # 去太泛的词
410
+ generic = {'功能', '系统', '管理', '模块', '页面', '需求', '设计', '实现',
411
+ '新增', '修改', '查询', '删除', '操作', '记录', '列表', '详情'}
412
+ for w in short_words:
413
+ if w not in generic and w not in kws:
414
+ kws.append(w)
415
+
416
+ # 3. PRD 正文里的业务关键词 (从标题和需求段落粗提)
417
+ try:
418
+ prd = task_dir / 'prd.md'
419
+ if prd.is_file():
420
+ content = prd.read_text(encoding='utf-8', errors='ignore')
421
+ import re
422
+ # 提取中文词组 (2-6 字连续中文)
423
+ cn_words = re.findall(r'[\u4e00-\u9fa5]{2,6}', content)
424
+ # 去停用词 + 高频词
425
+ stop = {'功能', '需求', '系统', '页面', '数据', '支持', '可以',
426
+ '进行', '操作', '显示', '一个', '这个', '实现', '业务',
427
+ '测试', '使用', '如果', '需要', '必须', '以及', '界面'}
428
+ from collections import Counter
429
+ freq = Counter(w for w in cn_words if w not in stop)
430
+ for w, _ in freq.most_common(5):
431
+ if w not in kws and len(w) >= 2:
432
+ kws.append(w)
433
+ except Exception:
434
+ pass
435
+
436
+ # 去重保序, 过滤太短的
437
+ seen = set()
438
+ result = []
439
+ for k in kws:
440
+ if k and len(k) >= 2 and k not in seen:
441
+ seen.add(k)
442
+ result.append(k)
443
+ return result[:6] # 最多 6 个, 避免查太多
444
+
445
+
446
+ def _generate_test_handoff(task_dir: Path, repo_root) -> None:
447
+ """finish 后生成 test-handoff.md (开发→测试交接画像)。
448
+ 从任务关键词查页面按钮+接口画像, 写入任务目录。无图谱/无匹配则跳过。
449
+ """
450
+ try:
451
+ import page_probe
452
+ except ImportError:
453
+ sys.path.insert(0, str(repo_root / '.qoder' / 'scripts'))
454
+ import page_probe
455
+
456
+ kws = _extract_keywords(task_dir)
457
+ if not kws:
458
+ print("[测试交接画像] 跳过: 任务无明确业务关键词")
459
+ return
460
+
461
+ # 用每个关键词查画像, 合并结果
462
+ all_pages = []
463
+ seen_vue = set()
464
+ used_kws = []
465
+ for kw in kws:
466
+ profile = page_probe.build_page_profile(keyword=kw, repo_root=str(repo_root))
467
+ if not profile.get('ok'):
468
+ continue
469
+ for pg in profile['pages']:
470
+ if pg['vue_file'] in seen_vue:
471
+ continue
472
+ seen_vue.add(pg['vue_file'])
473
+ all_pages.append(pg)
474
+ used_kws.append(kw)
475
+ if len(all_pages) >= 12: # 够多了, 停止查更多关键词
476
+ break
477
+
478
+ handoff_path = task_dir / 'test-handoff.md'
479
+ if not all_pages:
480
+ # 没匹配到页面: 写一个提示性 handoff (让测试知道开发想覆盖什么)
481
+ lines = [
482
+ '# 测试交接: %s' % task_dir.name,
483
+ '',
484
+ '> 任务完成于 %s' % datetime.now().strftime('%Y-%m-%d %H:%M'),
485
+ '> 业务关键词: %s' % ', '.join(kws),
486
+ '',
487
+ '## ⚠️ 未能自动生成页面画像',
488
+ '',
489
+ '知识图谱里没有匹配这些关键词的页面按钮记录。可能原因:',
490
+ '- 功能是全新的, 代码还没被知识图谱索引',
491
+ '- 关键词与代码里的命名不一致',
492
+ '',
493
+ '**测试建议:** 手动确认涉及的页面, 或用 `autotest.py page --kw <准确关键词>` 重试。',
494
+ ]
495
+ handoff_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
496
+ print("[测试交接画像] %s (无匹配页面, 已写提示)" % handoff_path.name)
497
+ return
498
+
499
+ # 渲染完整画像
500
+ total_btns = sum(len(pg['buttons']) for pg in all_pages)
501
+ lines = [
502
+ '# 测试交接: %s' % task_dir.name,
503
+ '',
504
+ '> 任务完成于 %s' % datetime.now().strftime('%Y-%m-%d %H:%M'),
505
+ '> 业务关键词: %s' % ', '.join(used_kws),
506
+ '> 共 %d 个页面, %d 个交互点' % (len(all_pages), total_btns),
507
+ '',
508
+ '> 💡 测试人员照此写用例, 不用自己找页面按钮。每行"按钮→接口"就是一个用例的核心断言点。',
509
+ '> 未解析到接口的按钮 (deferred/unresolved) 需现场点击确认实际调用。',
510
+ '',
511
+ ]
512
+
513
+ # 复用 page_probe 的渲染, 但按页面输出
514
+ fake_profile = {'ok': True, 'pages': all_pages, 'stats': page_probe._trace_stats(str(repo_root))}
515
+ lines.append(page_probe.render_profile_md(fake_profile))
516
+
517
+ lines.append('')
518
+ lines.append('---')
519
+ lines.append('')
520
+ lines.append('## 测试用例生成')
521
+ lines.append('拿到这份画像后, 直接说:')
522
+ lines.append('- "测一下 %s" → 走 /wl-test quick 自动生成用例' % (used_kws[0] if used_kws else '这个功能'))
523
+ lines.append('- 或 `/wl-test browser %s` → 按任务 PRD 生成回归用例' % task_dir.name)
524
+ lines.append('- 也可 `autotest.py page --kw <更精确的关键词>` 单独查某页画像')
525
+
526
+ handoff_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
527
+ print("[测试交接画像] 已生成 %s (%d 页面, %d 交互点)"
528
+ % (handoff_path.name, len(all_pages), total_btns))
529
+
530
+
353
531
  def _cleanup_orphaned_refs(archived_task_name: str, repo_root) -> int:
354
532
  """归档任务后, 清理其它任务中对它的引用 (blocked_by/blocks/children/parent)。
355
533
 
@@ -114,22 +114,80 @@ TOOLS = [
114
114
  },
115
115
  {
116
116
  "name": "create_story",
117
- "description": "在禅道创建需求(PRD发布→禅道需求)。例: create_story(product_id=1, title='品质案件催办', spec='需求详情', pri=3)",
117
+ "description": "在禅道创建需求(PRD发布→禅道需求)。权限不足会返回友好提示不报错。例: create_story(product_id=1, title='品质案件催办', spec='需求详情', pri=3, module=104)",
118
118
  "inputSchema": {
119
119
  "type": "object",
120
120
  "properties": {
121
121
  "product_id": {"type": "integer", "description": "产品ID"},
122
122
  "title": {"type": "string", "description": "需求标题"},
123
123
  "spec": {"type": "string", "description": "需求描述"},
124
- "pri": {"type": "integer", "description": "优先级 1-4(1最高)"},
125
- "category": {"type": "string", "description": "类别(feature/interface/optimization)"},
124
+ "pri": {"type": "integer", "description": "优先级 1-4(1最高, 默认3)"},
125
+ "module": {"type": "integer", "description": "模块ID(可选)"},
126
+ "category": {"type": "string", "description": "类别(feature/interface/optimization, 默认feature)"},
126
127
  },
127
128
  "required": ["product_id", "title", "spec"],
128
129
  },
129
130
  },
131
+ {
132
+ "name": "list_plans",
133
+ "description": "查某产品的计划/迭代列表(让用户选归属)。例: list_plans(product_id=1)",
134
+ "inputSchema": {
135
+ "type": "object",
136
+ "properties": {
137
+ "product_id": {"type": "integer", "description": "产品ID"},
138
+ },
139
+ "required": ["product_id"],
140
+ },
141
+ },
142
+ {
143
+ "name": "list_executions",
144
+ "description": "查活跃的执行/迭代(按周划分的后端/前端/移动端)。例: list_executions(limit=5)",
145
+ "inputSchema": {
146
+ "type": "object",
147
+ "properties": {
148
+ "limit": {"type": "integer", "description": "返回数量(默认10)"},
149
+ },
150
+ },
151
+ },
152
+ {
153
+ "name": "link_story_to_execution",
154
+ "description": "把需求关联到执行/迭代(让开发能看到)。权限不足返回提示不报错。例: link_story_to_execution(story_id=761, execution_id=73)",
155
+ "inputSchema": {
156
+ "type": "object",
157
+ "properties": {
158
+ "story_id": {"type": "integer", "description": "需求ID"},
159
+ "execution_id": {"type": "integer", "description": "执行ID"},
160
+ },
161
+ "required": ["story_id", "execution_id"],
162
+ },
163
+ },
164
+ {
165
+ "name": "assign_story",
166
+ "description": "指派需求给某人(设负责人)。权限不足返回提示不报错。例: assign_story(story_id=761, account='hupan')",
167
+ "inputSchema": {
168
+ "type": "object",
169
+ "properties": {
170
+ "story_id": {"type": "integer", "description": "需求ID"},
171
+ "account": {"type": "string", "description": "禅道账号"},
172
+ },
173
+ "required": ["story_id", "account"],
174
+ },
175
+ },
176
+ {
177
+ "name": "change_story_status",
178
+ "description": "变更需求状态(draft→reviewing→active→closed)。权限不足返回提示不报错。例: change_story_status(story_id=761, status='active')",
179
+ "inputSchema": {
180
+ "type": "object",
181
+ "properties": {
182
+ "story_id": {"type": "integer", "description": "需求ID"},
183
+ "status": {"type": "string", "description": "目标状态(draft/reviewing/active/closed)"},
184
+ },
185
+ "required": ["story_id", "status"],
186
+ },
187
+ },
130
188
  {
131
189
  "name": "list_execution_tasks",
132
- "description": "查某执行的的任务(谁在做什么)。例: list_execution_tasks(execution_id=75)",
190
+ "description": "查某执行的任务列表(谁在做什么)。例: list_execution_tasks(execution_id=73)",
133
191
  "inputSchema": {
134
192
  "type": "object",
135
193
  "properties": {
@@ -141,7 +199,7 @@ TOOLS = [
141
199
  },
142
200
  {
143
201
  "name": "get_story_detail",
144
- "description": "查需求详情(完整描述+状态)。例: get_story_detail(story_id=761)",
202
+ "description": "查需求详情(完整描述+状态+关联)。例: get_story_detail(story_id=761)",
145
203
  "inputSchema": {
146
204
  "type": "object",
147
205
  "properties": {
@@ -184,18 +242,89 @@ def _execute_tool(name, args):
184
242
 
185
243
  if name == 'create_story':
186
244
  pid = args.get('product_id', 1)
187
- r = _api('POST', '/products/%s/stories' % pid, {
245
+ payload = {
188
246
  'title': args.get('title', ''),
189
247
  'spec': args.get('spec', ''),
190
248
  'category': args.get('category', 'feature'),
191
249
  'pri': args.get('pri', 3),
192
- })
250
+ }
251
+ if args.get('module'):
252
+ payload['module'] = args['module']
253
+ r = _api('POST', '/products/%s/stories' % pid, payload)
193
254
  if r.status_code in (200, 201):
194
255
  d = r.json()
195
256
  sid = d.get('id', '?')
196
257
  return '✅ 需求已创建! ID=%s\n禅道地址: %s/story-view-%s.html' % (
197
258
  sid, ZENTAO_URL.rstrip('/'), sid)
198
- return '创建失败: %s %s' % (r.status_code, r.text[:150])
259
+ # 权限不足或字段错误, 友好返回不崩
260
+ try:
261
+ err = r.json().get('error', r.text[:100])
262
+ except Exception:
263
+ err = r.text[:100]
264
+ return '⚠️ 需求创建失败: %s\n(可能权限不足或字段缺失, 不影响其它操作)' % err
265
+
266
+ if name == 'list_plans':
267
+ pid = args.get('product_id', 1)
268
+ r = _api('GET', '/products/%s/plans' % pid)
269
+ if r.status_code != 200:
270
+ return '查询计划失败: %s' % r.text[:100]
271
+ plans = r.json().get('plans', [])
272
+ if not plans:
273
+ return '产品 %s 暂无计划。可在禅道里创建计划/迭代。' % pid
274
+ lines = ['产品 %s 的计划 (%d 个):' % (pid, len(plans))]
275
+ for p in plans:
276
+ lines.append(' [%s] %s (%s~%s)' % (
277
+ p.get('id'), str(p.get('title', ''))[:20], p.get('begin', ''), p.get('end', '')))
278
+ return '\n'.join(lines)
279
+
280
+ if name == 'list_executions':
281
+ limit = args.get('limit', 10)
282
+ r = _api('GET', '/executions', {'limit': limit})
283
+ if r.status_code != 200:
284
+ return '查询执行失败: %s' % r.text[:100]
285
+ execs = r.json().get('executions', [])
286
+ lines = ['活跃执行/迭代 (%d 个):' % len(execs)]
287
+ for ex in execs[:limit]:
288
+ lines.append(' [%s] %s (%s~%s project=%s)' % (
289
+ ex.get('id'), str(ex.get('name', ''))[:15],
290
+ ex.get('begin', ''), ex.get('end', ''), ex.get('project', '')))
291
+ return '\n'.join(lines)
292
+
293
+ if name == 'link_story_to_execution':
294
+ sid = args.get('story_id', 0)
295
+ eid = args.get('execution_id', 0)
296
+ r = _api('POST', '/executions/%s/stories' % eid, {'stories': [sid]})
297
+ if r.status_code in (200, 201):
298
+ return '✅ 需求 #%s 已关联到执行 #%s' % (sid, eid)
299
+ try:
300
+ err = r.json().get('error', r.text[:100])
301
+ except Exception:
302
+ err = r.text[:100]
303
+ return '⚠️ 关联失败: %s\n(可能权限不足, 需在禅道手动关联)' % err
304
+
305
+ if name == 'assign_story':
306
+ sid = args.get('story_id', 0)
307
+ account = args.get('account', '')
308
+ r = _api('POST', '/stories/%s/assign' % sid, {'assignedTo': account})
309
+ if r.status_code in (200, 201):
310
+ return '✅ 需求 #%s 已指派给 %s' % (sid, account)
311
+ try:
312
+ err = r.json().get('error', r.text[:100])
313
+ except Exception:
314
+ err = r.text[:100]
315
+ return '⚠️ 指派失败: %s\n(可能权限不足, 需在禅道手动指派)' % err
316
+
317
+ if name == 'change_story_status':
318
+ sid = args.get('story_id', 0)
319
+ status = args.get('status', '')
320
+ r = _api('POST', '/stories/%s/change' % sid, {'status': status})
321
+ if r.status_code in (200, 201):
322
+ return '✅ 需求 #%s 状态变更为 %s' % (sid, status)
323
+ try:
324
+ err = r.json().get('error', r.text[:100])
325
+ except Exception:
326
+ err = r.text[:100]
327
+ return '⚠️ 状态变更失败: %s\n(可能权限不足或状态转换不合法)' % err
199
328
 
200
329
  if name == 'list_execution_tasks':
201
330
  eid = args.get('execution_id', 1)
@@ -108,6 +108,20 @@ RICE = Reach × Impact × Confidence ÷ Effort
108
108
  python "$R/.qoder/scripts/team_sync.py" push
109
109
  ```
110
110
 
111
+ ## finish 后:自动生成测试交接画像(可选增强)
112
+
113
+ 用户说"完成了"/"这个任务做完了"触发 `finish` 时,**自动**在任务目录生成 **`test-handoff.md`**(开发→测试交接物),用户什么都不用做:
114
+ - 从任务目录名/标题/PRD 提取业务关键词
115
+ - 查知识图谱,列出涉及的**页面 + 按钮 + handler + 接口 + 方法 + 置信度**
116
+ - 自动给出**测试建议**(按按钮类型:新增/编辑/删除/导出…)
117
+
118
+ 测试人员拿到这份画像,照着写用例,**不用自己满仓库找页面按钮**。
119
+
120
+ > **非侵入**:无知识图谱 / 关键词没匹配到页面 → 跳过或写提示,**绝不阻塞任务完成**。
121
+ >
122
+ > 测试人员想单独查某页画像:直接说 **"查一下保险页面的按钮和接口"** 或用 **`/wl-test`**,
123
+ > AI 在后台调脚本产出画像,**用户永远不碰 python 命令**。
124
+
111
125
  ## 🧩 QoderWork 增强:待办双向同步(可选 · 无连接器则自动回退)
112
126
 
113
127
  > 依赖 **QoderWork 桌面端连接器**(Settings → Connectors,授权 Microsoft 365 待办 / OneNote,或 macOS 提醒事项)。
@@ -146,6 +146,14 @@ python "$R/.qoder/scripts/autotest.py" recall --url "/auth/login"
146
146
 
147
147
  > 图谱无数据 → 跳过,按通用模式生成。**不要再单独调 feature_overview**(信息重叠,浪费一轮)。
148
148
 
149
+ **🎯 知道页面名/URL 时,AI 先查按钮+接口画像(最精准,1 条命令拿到所有交互点):**
150
+ ```bash
151
+ python "$R/.qoder/scripts/autotest.py" page --kw 保险 # 按业务名查
152
+ python "$R/.qoder/scripts/autotest.py" page --url /veh/insurance # 按URL查
153
+ ```
154
+ (这是 AI 在 `/wl-test` 流程内部自动调用的工具,**用户只用 `/wl-test`,不碰此命令**。)
155
+ 输出每个按钮的 handler + 调用接口 + HTTP 方法 + 置信度,**直接对应到用例的 click→assert 断言点**。开发 `finish` 任务时也会自动生成 `test-handoff.md`,内容同此画像——交接场景直接读那份即可,不必重查。
156
+
149
157
  ### Step 1:AI 据用户描述 + 图谱信息生成意图式用例 JSON(**一次生成全部用例**)
150
158
  **意图+锚点格式**(A方案核心):用 `intent` 表达"做什么",用 `anchor` 精确定位(有锚点则确定性命中,无则 LLM 兜底)。
151
159
  ```json