@hupan56/wlkj 2.7.6 → 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 +38 -21
- package/package.json +1 -1
- package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/autotest.py +36 -0
- package/templates/qoder/scripts/install_qoderwork.py +77 -13
- package/templates/qoder/scripts/mcp_launcher.py +26 -8
- package/templates/qoder/scripts/page_probe.py +459 -0
- package/templates/qoder/scripts/task.py +178 -0
- package/templates/qoder/skills/wl-task/SKILL.md +14 -0
- package/templates/qoder/skills/wl-test/SKILL.md +8 -0
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 第
|
|
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 已生成 (
|
|
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.
|
|
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
|
-
// ===
|
|
591
|
+
// === 5. 写版本戳 ===
|
|
564
592
|
writeEngineVersion(cwd);
|
|
565
593
|
|
|
566
|
-
// ===
|
|
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.
|
|
673
|
-
|
|
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
|
-
// ===
|
|
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
|
Binary file
|
|
@@ -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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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(
|
|
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
|
-
#
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 未装。跑
|
|
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
|
-
|
|
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: 重跑
|
|
389
|
+
_log(' 方式2: 重跑 npx @hupan56/wlkj update (刷新 .repo-root)')
|
|
382
390
|
_log(' 方式3: 在仓库目录内运行')
|
|
383
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
|
|
@@ -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
|