@hupan56/wlkj 2.7.12 → 3.1.2

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.
Files changed (253) hide show
  1. package/bin/cli.js +1435 -1107
  2. package/package.json +29 -29
  3. package/templates/qoder/agents/insight-planning.md +1 -1
  4. package/templates/qoder/agents/insight-research.md +28 -15
  5. package/templates/qoder/commands/optional/wl-insight.md +161 -161
  6. package/templates/qoder/commands/optional/wl-report.md +20 -39
  7. package/templates/qoder/commands/optional/wl-status.md +4 -4
  8. package/templates/qoder/commands/wl-code.md +2 -2
  9. package/templates/qoder/commands/wl-design.md +6 -6
  10. package/templates/qoder/commands/wl-init.md +3 -3
  11. package/templates/qoder/commands/wl-prd.md +26 -13
  12. package/templates/qoder/commands/wl-req.md +43 -0
  13. package/templates/qoder/commands/wl-search.md +78 -47
  14. package/templates/qoder/commands/wl-task.md +343 -40
  15. package/templates/qoder/commands/wl-test.md +43 -17
  16. package/templates/qoder/config.yaml +52 -14
  17. package/templates/qoder/hooks/post-tool-use.py +181 -0
  18. package/templates/qoder/hooks/session-start.py +94 -23
  19. package/templates/qoder/hooks/stop-eval.py +207 -0
  20. package/templates/qoder/hooks/user-prompt-submit.py +139 -0
  21. package/templates/qoder/rules/wl-pipeline.md +85 -49
  22. package/templates/qoder/scripts/README.md +102 -0
  23. package/templates/qoder/scripts/capability/__init__.py +26 -0
  24. package/templates/qoder/scripts/capability/__main__.py +72 -0
  25. package/templates/qoder/scripts/capability/adapters/__init__.py +29 -0
  26. package/templates/qoder/scripts/capability/adapters/cli.py +316 -0
  27. package/templates/qoder/scripts/capability/adapters/mcp.py +334 -0
  28. package/templates/qoder/scripts/capability/adapters/qw.py +271 -0
  29. package/templates/qoder/scripts/capability/caps/__init__.py +27 -0
  30. package/templates/qoder/scripts/capability/caps/context.py +36 -0
  31. package/templates/qoder/scripts/capability/caps/cron.py +50 -0
  32. package/templates/qoder/scripts/capability/caps/identity.py +43 -0
  33. package/templates/qoder/scripts/capability/caps/memory.py +71 -0
  34. package/templates/qoder/scripts/capability/caps/notify.py +41 -0
  35. package/templates/qoder/scripts/capability/caps/present.py +48 -0
  36. package/templates/qoder/scripts/capability/caps/repo.py +31 -0
  37. package/templates/qoder/scripts/capability/caps/sandbox.py +43 -0
  38. package/templates/qoder/scripts/capability/chain.py +92 -0
  39. package/templates/qoder/scripts/capability/memory_chain.py +41 -0
  40. package/templates/qoder/scripts/capability/registry.py +246 -0
  41. package/templates/qoder/scripts/capability/registry_mcp.py +250 -0
  42. package/templates/qoder/scripts/capability/smoke_test.py +211 -0
  43. package/templates/qoder/scripts/capability/smoke_test_report.json +94 -0
  44. package/templates/qoder/scripts/deployment/setup/__init__.py +11 -0
  45. package/templates/qoder/scripts/deployment/setup/carriers.py +669 -0
  46. package/templates/qoder/scripts/deployment/setup/carriers_verify.py +148 -0
  47. package/templates/qoder/scripts/{init_doctor.py → deployment/setup/init_doctor.py} +100 -39
  48. package/templates/qoder/scripts/{install_qoderwork.py → deployment/setup/install_qoderwork.py} +48 -30
  49. package/templates/qoder/scripts/{platform_doctor.py → deployment/setup/platform_doctor.py} +271 -259
  50. package/templates/qoder/scripts/{repo_root.py → deployment/setup/repo_root.py} +9 -2
  51. package/templates/qoder/scripts/{setup.py → deployment/setup/setup.py} +143 -14
  52. package/templates/qoder/scripts/{setup_lanhu.py → deployment/setup/setup_lanhu.py} +979 -963
  53. package/templates/qoder/scripts/domain/design/__init__.py +0 -0
  54. package/templates/qoder/scripts/{fill_prototype.py → domain/design/fill_prototype.py} +17 -5
  55. package/templates/qoder/scripts/{gen_design_doc.py → domain/design/gen_design_doc.py} +406 -394
  56. package/templates/qoder/scripts/domain/kg/__init__.py +11 -0
  57. package/templates/qoder/scripts/domain/kg/build/__init__.py +0 -0
  58. package/templates/qoder/scripts/domain/kg/build/build_entity_registry.py +201 -0
  59. package/templates/qoder/scripts/domain/kg/build/build_relations.py +132 -0
  60. package/templates/qoder/scripts/{build_style_index.py → domain/kg/build/build_style_index.py} +50 -15
  61. package/templates/qoder/scripts/domain/kg/build/build_workflows.py +149 -0
  62. package/templates/qoder/scripts/{kg_build.py → domain/kg/build/kg_build.py} +685 -612
  63. package/templates/qoder/scripts/{kg_build_db.py → domain/kg/build/kg_build_db.py} +338 -327
  64. package/templates/qoder/scripts/{kg_incremental.py → domain/kg/build/kg_incremental.py} +425 -393
  65. package/templates/qoder/scripts/{learn_aggregate.py → domain/kg/build/learn_aggregate.py} +212 -201
  66. package/templates/qoder/scripts/domain/kg/extract/__init__.py +0 -0
  67. package/templates/qoder/scripts/{common → domain/kg/extract}/extract.py +430 -419
  68. package/templates/qoder/scripts/domain/kg/extract/test_extract.py +115 -0
  69. package/templates/qoder/scripts/{common → domain/kg/extract}/ts_extract.py +614 -536
  70. package/templates/qoder/scripts/domain/kg/graph/__init__.py +0 -0
  71. package/templates/qoder/scripts/{kg_link_db.py → domain/kg/graph/kg_link_db.py} +235 -224
  72. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +683 -0
  73. package/templates/qoder/scripts/{kg.py → domain/kg/kg.py} +871 -708
  74. package/templates/qoder/scripts/domain/kg/kg_capabilities.py +182 -0
  75. package/templates/qoder/scripts/domain/kg/search/__init__.py +0 -0
  76. package/templates/qoder/scripts/{context_pack.py → domain/kg/search/context_pack.py} +45 -17
  77. package/templates/qoder/scripts/{enrich_prompt.py → domain/kg/search/enrich_prompt.py} +238 -226
  78. package/templates/qoder/scripts/domain/kg/search/prefetch.py +384 -0
  79. package/templates/qoder/scripts/{common → domain/kg/search}/search_engine.py +207 -205
  80. package/templates/qoder/scripts/{search_index.py → domain/kg/search/search_index.py} +210 -88
  81. package/templates/qoder/scripts/domain/kg/server/__init__.py +0 -0
  82. package/templates/qoder/scripts/domain/kg/server/kgd.py +549 -0
  83. package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
  84. package/templates/qoder/scripts/domain/kg/storage/__init__.py +0 -0
  85. package/templates/qoder/scripts/{kg_duckdb.py → domain/kg/storage/kg_duckdb.py} +70 -45
  86. package/templates/qoder/scripts/domain/learning/__init__.py +0 -0
  87. package/templates/qoder/scripts/{learn.py → domain/learning/learn.py} +157 -146
  88. package/templates/qoder/scripts/domain/report/__init__.py +11 -0
  89. package/templates/qoder/scripts/{add_session.py → domain/report/add_session.py} +256 -244
  90. package/templates/qoder/scripts/{export.py → domain/report/export.py} +72 -4
  91. package/templates/qoder/scripts/{report.py → domain/report/report.py} +292 -281
  92. package/templates/qoder/scripts/domain/report/report_snapshot.py +261 -0
  93. package/templates/qoder/scripts/domain/report/role.py +39 -0
  94. package/templates/qoder/scripts/{status.py → domain/report/status.py} +737 -628
  95. package/templates/qoder/scripts/domain/report/status_snapshot.py +191 -0
  96. package/templates/qoder/scripts/domain/requirement/__init__.py +0 -0
  97. package/templates/qoder/scripts/{archive_prd.py → domain/requirement/archive_prd.py} +389 -377
  98. package/templates/qoder/scripts/domain/requirement/req.py +228 -0
  99. package/templates/qoder/scripts/domain/task/__init__.py +11 -0
  100. package/templates/qoder/scripts/{git_sync.py → domain/task/git_sync.py} +90 -50
  101. package/templates/qoder/scripts/{syncgate.py → domain/task/syncgate.py} +18 -7
  102. package/templates/qoder/scripts/domain/task/task.py +229 -0
  103. package/templates/qoder/scripts/domain/task/task_lifecycle.py +606 -0
  104. package/templates/qoder/scripts/domain/task/task_query.py +162 -0
  105. package/templates/qoder/scripts/domain/task/task_relations.py +425 -0
  106. package/templates/qoder/scripts/{team_sync.py → domain/task/team_sync.py} +101 -23
  107. package/templates/qoder/scripts/domain/task/zentao_sync.py +370 -0
  108. package/templates/qoder/scripts/foundation/__init__.py +0 -0
  109. package/templates/qoder/scripts/foundation/bootstrap.py +145 -0
  110. package/templates/qoder/scripts/foundation/core/__init__.py +0 -0
  111. package/templates/qoder/scripts/foundation/core/cmd_registry.py +112 -0
  112. package/templates/qoder/scripts/foundation/core/config.py +187 -0
  113. package/templates/qoder/scripts/{common → foundation/core}/paths.py +706 -400
  114. package/templates/qoder/scripts/foundation/data/__init__.py +0 -0
  115. package/templates/qoder/scripts/foundation/data/contract.py +317 -0
  116. package/templates/qoder/scripts/{common → foundation/data}/events.py +2 -2
  117. package/templates/qoder/scripts/foundation/data/result.py +223 -0
  118. package/templates/qoder/scripts/{common → foundation/data}/task_utils.py +427 -392
  119. package/templates/qoder/scripts/foundation/identity/__init__.py +0 -0
  120. package/templates/qoder/scripts/foundation/identity/check_publish.py +103 -0
  121. package/templates/qoder/scripts/{common → foundation/identity}/developer.py +239 -238
  122. package/templates/qoder/scripts/foundation/identity/guard.py +159 -0
  123. package/templates/qoder/scripts/{common → foundation/identity}/identity.py +132 -9
  124. package/templates/qoder/scripts/foundation/identity/roles.py +60 -0
  125. package/templates/qoder/scripts/foundation/integrations/__init__.py +0 -0
  126. package/templates/qoder/scripts/{common → foundation/integrations}/active_task.py +30 -15
  127. package/templates/qoder/scripts/{common → foundation/integrations}/feishu.py +11 -10
  128. package/templates/qoder/scripts/{common → foundation/integrations}/terms.py +3 -3
  129. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +220 -0
  130. package/templates/qoder/scripts/foundation/io/__init__.py +0 -0
  131. package/templates/qoder/scripts/{common → foundation/io}/filelock.py +1 -1
  132. package/templates/qoder/scripts/{common → foundation/io}/reqid.py +1 -1
  133. package/templates/qoder/scripts/foundation/protocol/__init__.py +0 -0
  134. package/templates/qoder/scripts/foundation/protocol/duckdb_conn.py +39 -0
  135. package/templates/qoder/scripts/foundation/protocol/mcp_base.py +268 -0
  136. package/templates/qoder/scripts/foundation/utils/__init__.py +0 -0
  137. package/templates/qoder/scripts/{common → foundation/utils}/eval_api.py +12 -1
  138. package/templates/qoder/scripts/{common → foundation/utils}/pip_install.py +23 -6
  139. package/templates/qoder/scripts/{common → foundation/utils}/platform_guard.py +1 -1
  140. package/templates/qoder/scripts/orchestration/__init__.py +0 -0
  141. package/templates/qoder/scripts/orchestration/wlkj.py +185 -0
  142. package/templates/qoder/scripts/protocol/__init__.py +0 -0
  143. package/templates/qoder/scripts/protocol/browser/README.md +207 -0
  144. package/templates/qoder/scripts/protocol/browser/SKILL.md +265 -0
  145. package/templates/qoder/scripts/protocol/browser/config.env +9 -0
  146. package/templates/qoder/scripts/protocol/browser/references/cdp-api.md +110 -0
  147. package/templates/qoder/scripts/protocol/browser/references/migration-2.5.3.md +72 -0
  148. package/templates/qoder/scripts/protocol/browser/references/site-patterns/.gitkeep +0 -0
  149. package/templates/qoder/scripts/protocol/browser/scripts/browser-discovery.mjs +138 -0
  150. package/templates/qoder/scripts/protocol/browser/scripts/cdp-proxy.mjs +672 -0
  151. package/templates/qoder/scripts/protocol/browser/scripts/check-deps.mjs +206 -0
  152. package/templates/qoder/scripts/protocol/browser/scripts/find-url.mjs +253 -0
  153. package/templates/qoder/scripts/protocol/browser/scripts/match-site.mjs +46 -0
  154. package/templates/qoder/scripts/protocol/browser/templates/config.env.template +9 -0
  155. package/templates/qoder/scripts/protocol/mcp/__init__.py +11 -0
  156. package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +478 -0
  157. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → protocol/mcp/lanhu_stdio_wrapper.py} +131 -119
  158. package/templates/qoder/scripts/protocol/mcp/mcp_doctor.py +548 -0
  159. package/templates/qoder/scripts/protocol/mcp/mcp_launcher.py +600 -0
  160. package/templates/qoder/scripts/protocol/mcp/mysql_mcp_server.py +461 -0
  161. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1920 -0
  162. package/templates/qoder/scripts/protocol/transports/__init__.py +56 -0
  163. package/templates/qoder/scripts/protocol/transports/base.py +87 -0
  164. package/templates/qoder/scripts/protocol/transports/cli.py +132 -0
  165. package/templates/qoder/scripts/protocol/transports/http.py +141 -0
  166. package/templates/qoder/scripts/protocol/transports/stdio.py +293 -0
  167. package/templates/qoder/scripts/run_weekly_update.bat +27 -18
  168. package/templates/qoder/scripts/run_weekly_update.sh +22 -13
  169. package/templates/qoder/scripts/validation/__init__.py +0 -0
  170. package/templates/qoder/scripts/validation/eval/__init__.py +0 -0
  171. package/templates/qoder/scripts/validation/eval/perf-report-2026-06-24.md +139 -0
  172. package/templates/qoder/scripts/validation/eval/qwork_harness.py +617 -0
  173. package/templates/qoder/scripts/validation/eval/report-commands.md +220 -0
  174. package/templates/qoder/scripts/validation/eval/transcript_timing.py +386 -0
  175. package/templates/qoder/scripts/validation/metrics/__init__.py +0 -0
  176. package/templates/qoder/scripts/{eval_prd.py → validation/metrics/eval_prd.py} +84 -15
  177. package/templates/qoder/scripts/validation/metrics/usability_score.py +750 -0
  178. package/templates/qoder/scripts/validation/test/__init__.py +11 -0
  179. package/templates/qoder/scripts/validation/test/assertion_gen.py +551 -0
  180. package/templates/qoder/scripts/{autotest.py → validation/test/autotest.py} +1234 -1751
  181. package/templates/qoder/scripts/validation/test/autotest_auth.py +109 -0
  182. package/templates/qoder/scripts/{autotest_batch.py → validation/test/autotest_batch.py} +255 -224
  183. package/templates/qoder/scripts/validation/test/autotest_data.py +680 -0
  184. package/templates/qoder/scripts/validation/test/autotest_opencli.py +646 -0
  185. package/templates/qoder/scripts/{autotest_run.py → validation/test/autotest_run.py} +323 -297
  186. package/templates/qoder/scripts/validation/test/autotest_webaccess.py +571 -0
  187. package/templates/qoder/scripts/{benchmark.py → validation/test/benchmark.py} +17 -5
  188. package/templates/qoder/scripts/{kg_auto_login.py → validation/test/kg_auto_login.py} +208 -196
  189. package/templates/qoder/scripts/{kg_test_runner.py → validation/test/kg_test_runner.py} +13 -2
  190. package/templates/qoder/scripts/{page_probe.py → validation/test/page_probe.py} +470 -459
  191. package/templates/qoder/scripts/validation/test/smoke_all_commands.py +170 -0
  192. package/templates/qoder/settings.json +23 -2
  193. package/templates/qoder/skills/design-import/SKILL.md +235 -226
  194. package/templates/qoder/skills/design-review/SKILL.md +91 -82
  195. package/templates/qoder/skills/prd-generator/SKILL.md +38 -24
  196. package/templates/qoder/skills/prd-review/SKILL.md +8 -6
  197. package/templates/qoder/skills/prototype-generator/SKILL.md +296 -247
  198. package/templates/qoder/skills/spec-coder/SKILL.md +24 -15
  199. package/templates/qoder/skills/spec-generator/SKILL.md +24 -12
  200. package/templates/qoder/skills/test-generator/SKILL.md +9 -7
  201. package/templates/qoder/skills/wl-code/SKILL.md +25 -13
  202. package/templates/qoder/skills/wl-commit/SKILL.md +10 -9
  203. package/templates/qoder/skills/wl-design/SKILL.md +7 -5
  204. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  205. package/templates/qoder/skills/wl-insight/SKILL.md +25 -12
  206. package/templates/qoder/skills/wl-prd-full/SKILL.md +59 -8
  207. package/templates/qoder/skills/wl-prd-quick/SKILL.md +7 -7
  208. package/templates/qoder/skills/wl-prd-review/SKILL.md +18 -6
  209. package/templates/qoder/skills/wl-report/SKILL.md +27 -25
  210. package/templates/qoder/skills/wl-search/SKILL.md +151 -80
  211. package/templates/qoder/skills/wl-spec/SKILL.md +9 -7
  212. package/templates/qoder/skills/wl-status/SKILL.md +50 -18
  213. package/templates/qoder/skills/wl-task/SKILL.md +94 -12
  214. package/templates/qoder/skills/wl-test/SKILL.md +154 -45
  215. package/templates/qoder/templates/prd-full-template.md +7 -0
  216. package/templates/root/AGENTS.md +275 -237
  217. package/templates/root/requirements.txt +3 -0
  218. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +3 -3
  219. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +2 -2
  220. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  221. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  222. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  223. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  224. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  225. package/templates/qoder/scripts/check_carriers.py +0 -238
  226. package/templates/qoder/scripts/check_mcp.py +0 -298
  227. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  228. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  229. package/templates/qoder/scripts/collect_prds.py +0 -31
  230. package/templates/qoder/scripts/common/mentions.py +0 -134
  231. package/templates/qoder/scripts/common/utf8.py +0 -38
  232. package/templates/qoder/scripts/extract_api_params.py +0 -246
  233. package/templates/qoder/scripts/extract_routes.py +0 -54
  234. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  235. package/templates/qoder/scripts/handoff.py +0 -22
  236. package/templates/qoder/scripts/init_developer.py +0 -76
  237. package/templates/qoder/scripts/kg_mcp_server.py +0 -801
  238. package/templates/qoder/scripts/kg_semantic.py +0 -150
  239. package/templates/qoder/scripts/mcp_launcher.py +0 -414
  240. package/templates/qoder/scripts/mysql_mcp_server.py +0 -396
  241. package/templates/qoder/scripts/parse_prds.py +0 -33
  242. package/templates/qoder/scripts/role.py +0 -51
  243. package/templates/qoder/scripts/sync_carriers.py +0 -259
  244. package/templates/qoder/scripts/task.py +0 -1261
  245. package/templates/qoder/scripts/workspace_init.py +0 -102
  246. package/templates/qoder/scripts/zentao_mcp_server.py +0 -424
  247. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  248. /package/templates/qoder/scripts/{common → deployment}/__init__.py +0 -0
  249. /package/templates/qoder/{skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp → scripts/domain/__init__.py} +0 -0
  250. /package/templates/qoder/scripts/{common → domain/kg/graph}/graph_traverse.py +0 -0
  251. /package/templates/qoder/scripts/{common → domain/kg/server}/repowiki.py +0 -0
  252. /package/templates/qoder/scripts/{common → foundation/io}/atomicio.py +0 -0
  253. /package/templates/qoder/scripts/{secure-ls.js → validation/test/secure-ls.js} +0 -0
@@ -1,459 +1,470 @@
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()
1
+ # -*- coding: utf-8 -*-
2
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
3
+ import os as _o, sys as _s
4
+ _f = _o.path.abspath(__file__)
5
+ for _ in range(10):
6
+ _f = _o.path.dirname(_f)
7
+ _cp = _o.path.join(_f, 'foundation')
8
+ if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
9
+ break
10
+ if _cp not in _s.path: _s.path.insert(0, _cp)
11
+ from bootstrap import setup; setup()
12
+
13
+ """
14
+ page_probe.py - 页面按钮+接口画像 (开发→测试交接物)
15
+
16
+ DuckDB 知识图谱的 traces 表查"某个页面有哪些按钮、点按钮调哪个接口",
17
+ 输出一份测试人员能照着写用例的画像。解决痛点:开发写完代码交接给测试时,
18
+ 测试不用再满仓库找页面/按钮/接口。
19
+
20
+ 数据源: data/index/kg.duckdb traces 表
21
+ (vue_file, button, handler, api_fn, endpoint, verb, confidence)
22
+ URL→Vue 映射: data/index/menu-routes.json (sys_menu 真实路由)
23
+
24
+ 非侵入: 无 DuckDB / 无 traces 表 → 静默回退, 只 print 提示, 不报错。
25
+
26
+ Usage (作为库):
27
+ from page_probe import build_page_profile, render_profile_md
28
+ md = render_profile_md(build_page_profile(keyword='保险'))
29
+
30
+ Usage (CLI):
31
+ python .qoder/scripts/page_probe.py --kw 保险
32
+ python .qoder/scripts/page_probe.py --url /veh/vehicle/vehAffair/insurance
33
+ """
34
+
35
+ import json
36
+ import os
37
+ import sys
38
+ from typing import Optional, List, Dict, Any
39
+
40
+ if sys.platform == 'win32':
41
+ try:
42
+ sys.stdout.reconfigure(encoding='utf-8')
43
+ except (AttributeError, IOError):
44
+ pass
45
+
46
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
47
+ if _THIS_DIR not in sys.path:
48
+ sys.path.insert(0, _THIS_DIR)
49
+ _COMMON = os.path.join(_THIS_DIR, 'foundation')
50
+ if _COMMON not in sys.path:
51
+ sys.path.insert(0, _COMMON)
52
+
53
+ from foundation.core.paths import get_repo_root # noqa: E402
54
+
55
+
56
+ # ============================================================
57
+ # 路径定位
58
+ # ============================================================
59
+
60
+ def _kg_db_path(repo_root: Optional[str] = None) -> str:
61
+ rr = repo_root or str(get_repo_root())
62
+ return os.path.join(rr, 'data', 'index', 'kg.duckdb')
63
+
64
+
65
+ def _menu_routes_path(repo_root: Optional[str] = None) -> str:
66
+ rr = repo_root or str(get_repo_root())
67
+ return os.path.join(rr, 'data', 'index', 'menu-routes.json')
68
+
69
+
70
+ # ============================================================
71
+ # DuckDB 查询 (只读, 失败静默回退)
72
+ # ============================================================
73
+
74
+ def _query_traces(vue_pattern: str, repo_root: Optional[str] = None) -> List[Dict[str, Any]]:
75
+ """查 traces 表里 vue_file 匹配 pattern 的所有按钮+接口。
76
+ pattern 用 LIKE (大小写不敏感, 因为下面转 lower)。
77
+ 返回 [{vue_file, button, handler, api_fn, endpoint, verb, confidence}, ...]
78
+ DuckDB / 无表 / 查询失败 → 返回 [] (静默)。
79
+ """
80
+ try:
81
+ import duckdb
82
+ except ImportError:
83
+ return []
84
+ db = _kg_db_path(repo_root)
85
+ if not os.path.isfile(db):
86
+ return []
87
+ try:
88
+ con = duckdb.connect(db, read_only=True)
89
+ # 确认 traces 表存在
90
+ tables = [r[0] for r in con.execute(
91
+ "SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
92
+ ).fetchall()]
93
+ if 'traces' not in tables:
94
+ con.close()
95
+ return []
96
+ # LIKE 匹配 (vue_file 路径里有正斜杠, pattern 用 % 通配)
97
+ pat = '%' + vue_pattern.lower() + '%'
98
+ rows = con.execute(
99
+ "SELECT vue_file, button, handler, api_fn, endpoint, verb, confidence "
100
+ "FROM traces WHERE lower(vue_file) LIKE ? "
101
+ "ORDER BY CASE confidence "
102
+ " WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 "
103
+ " WHEN 'deferred' THEN 3 ELSE 4 END, button",
104
+ [pat],
105
+ ).fetchall()
106
+ con.close()
107
+ return [
108
+ {
109
+ 'vue_file': r[0], 'button': r[1], 'handler': r[2],
110
+ 'api_fn': r[3], 'endpoint': r[4], 'verb': r[5], 'confidence': r[6],
111
+ }
112
+ for r in rows
113
+ if r[1] # button 不能为空
114
+ ]
115
+ except Exception:
116
+ return []
117
+
118
+
119
+ def _trace_stats(repo_root: Optional[str] = None) -> Optional[Dict[str, int]]:
120
+ """traces 表的置信度统计 (用于画像末尾标注覆盖率)。失败返回 None。"""
121
+ try:
122
+ import duckdb
123
+ except ImportError:
124
+ return None
125
+ db = _kg_db_path(repo_root)
126
+ if not os.path.isfile(db):
127
+ return None
128
+ try:
129
+ con = duckdb.connect(db, read_only=True)
130
+ rows = con.execute(
131
+ "SELECT confidence, COUNT(*) FROM traces GROUP BY confidence"
132
+ ).fetchall()
133
+ con.close()
134
+ stats = {r[0]: int(r[1]) for r in rows}
135
+ stats['total'] = sum(stats.values())
136
+ resolved = stats.get('high', 0) + stats.get('medium', 0) + stats.get('low', 0)
137
+ stats['resolved'] = resolved
138
+ return stats
139
+ except Exception:
140
+ return None
141
+
142
+
143
+ # ============================================================
144
+ # URL Vue 文件映射 (走菜单路由表)
145
+ # ============================================================
146
+
147
+ def _load_menu_routes(repo_root: Optional[str] = None) -> List[Dict[str, Any]]:
148
+ """读 menu-routes.json。格式: {total, source, routes: [{name, path, component}, ...]}"""
149
+ p = _menu_routes_path(repo_root)
150
+ if not os.path.isfile(p):
151
+ return []
152
+ try:
153
+ for enc in ('utf-8', 'utf-8-sig', 'gbk'):
154
+ try:
155
+ with open(p, encoding=enc) as f:
156
+ data = json.load(f)
157
+ # 支持 {routes: [...]} 包装和裸 [...] 两种
158
+ if isinstance(data, dict):
159
+ data = data.get('routes') or []
160
+ return data if isinstance(data, list) else []
161
+ except (OSError, IOError, UnicodeDecodeError, json.JSONDecodeError):
162
+ continue
163
+ except Exception:
164
+ return []
165
+ return []
166
+
167
+
168
+ def _match_routes(keyword: str, routes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
169
+ """用关键词匹配菜单 (name/path/component 任一包含关键词, 大小写不敏感)。
170
+ 返回匹配的菜单项 [{name, path, component}], 按相关度排序。
171
+ """
172
+ kw = keyword.lower().strip()
173
+ if not kw:
174
+ return []
175
+ hits = []
176
+ for r in routes:
177
+ name = (r.get('name') or r.get('menu_name') or '').lower()
178
+ path = (r.get('path') or r.get('url') or '').lower()
179
+ comp = (r.get('component') or r.get('component_path') or '').lower()
180
+ # 完全匹配 name 优先级最高, 其次 path, 最后 component
181
+ score = 0
182
+ if kw == name:
183
+ score = 100
184
+ elif kw in name:
185
+ score = 80
186
+ elif kw in path:
187
+ score = 60
188
+ elif kw in comp:
189
+ score = 40
190
+ if score > 0:
191
+ hits.append((score, r))
192
+ hits.sort(key=lambda x: -x[0])
193
+ return [h[1] for h in hits[:8]] # 最多 8 个, 避免过多
194
+
195
+
196
+ def _route_to_vue_hint(route: Dict[str, Any]) -> str:
197
+ """从菜单项推断 Vue 文件路径关键词 (用于 traces 查询)
198
+ component 字段常是 'veh/vehicle/vehAffair/insurance/index' 这种相对路径。
199
+ """
200
+ comp = route.get('component') or route.get('component_path') or ''
201
+ # 取 component 里最后两段 (模块名), 如 'insurance/record'
202
+ parts = [p for p in comp.replace('\\', '/').split('/') if p and p != 'index']
203
+ if len(parts) >= 2:
204
+ return '/'.join(parts[-2:])
205
+ if parts:
206
+ return parts[-1]
207
+ # fallback: path 的最后一段
208
+ path = route.get('path') or route.get('url') or ''
209
+ seg = [p for p in path.split('/') if p]
210
+ return seg[-1] if seg else ''
211
+
212
+
213
+ # ============================================================
214
+ # 核心: 构建页面画像
215
+ # ============================================================
216
+
217
+ def build_page_profile(
218
+ keyword: Optional[str] = None,
219
+ url: Optional[str] = None,
220
+ repo_root: Optional[str] = None,
221
+ ) -> Dict[str, Any]:
222
+ """构建页面画像。
223
+ 输入: keyword (业务名如"保险") 或 url (/veh/insurance)。
224
+ 返回:
225
+ {
226
+ 'ok': True/False,
227
+ 'reason': '失败原因' (ok=False ),
228
+ 'pages': [
229
+ {
230
+ 'name': '保险记录', 'url': '/veh/...', 'vue_hint': 'insurance/record',
231
+ 'buttons': [{button, handler, endpoint, verb, confidence, resolved}, ...],
232
+ 'vue_files': [实际命中的 vue 文件路径, ...]
233
+ }, ...
234
+ ],
235
+ 'stats': {total, resolved, ...} or None
236
+ }
237
+ """
238
+ rr = repo_root or str(get_repo_root())
239
+
240
+ # 1. 定位页面 (URL 直查 / 关键词查路由表)
241
+ routes = _load_menu_routes(rr)
242
+ matched_pages = [] # [{name, url, vue_hint}]
243
+
244
+ if url:
245
+ # URL 模式: 在路由表里找 path 匹配的, 找不到就用 url 自身做 vue 关键词
246
+ url_lower = url.lower()
247
+ hit = None
248
+ for r in routes:
249
+ rp = (r.get('path') or r.get('url') or '').lower()
250
+ if rp and (rp == url_lower or url_lower in rp or rp in url_lower):
251
+ hit = r
252
+ break
253
+ if hit:
254
+ matched_pages.append({
255
+ 'name': hit.get('name') or hit.get('menu_name') or url,
256
+ 'url': hit.get('path') or hit.get('url') or url,
257
+ 'vue_hint': _route_to_vue_hint(hit),
258
+ })
259
+ else:
260
+ # 路由表没命中, url 最后一段做 vue 关键词
261
+ seg = [p for p in url.split('/') if p]
262
+ matched_pages.append({
263
+ 'name': url, 'url': url,
264
+ 'vue_hint': seg[-1] if seg else url.strip('/'),
265
+ })
266
+ elif keyword:
267
+ kw_hits = _match_routes(keyword, routes)
268
+ # 整词匹配不到 → 尝试关键词的前缀子串 (保险异常管理 → 保险/保险异常)
269
+ # 中文业务名常是"业务词+修饰词", 菜单名通常只含业务词主体
270
+ if not kw_hits and len(keyword) >= 3:
271
+ for sublen in (len(keyword) - 1, len(keyword) - 2, 2):
272
+ if sublen < 2:
273
+ break
274
+ sub = keyword[:sublen]
275
+ sub_hits = _match_routes(sub, routes)
276
+ if sub_hits:
277
+ kw_hits = sub_hits
278
+ break
279
+ if kw_hits:
280
+ for r in kw_hits:
281
+ matched_pages.append({
282
+ 'name': r.get('name') or r.get('menu_name') or keyword,
283
+ 'url': r.get('path') or r.get('url') or '',
284
+ 'vue_hint': _route_to_vue_hint(r),
285
+ })
286
+ else:
287
+ # 路由表没命中, 直接用关键词做 vue 关键词 (traces 表里搜)
288
+ # 注意: 中文搜 vue 英文路径会失败, 但留着兜底 (英文关键词场景)
289
+ matched_pages.append({
290
+ 'name': keyword, 'url': '', 'vue_hint': keyword,
291
+ })
292
+ else:
293
+ return {'ok': False, 'reason': '需要提供 --kw --url', 'pages': [], 'stats': None}
294
+
295
+ # 2. 每个页面查 traces
296
+ pages_result = []
297
+ for pg in matched_pages:
298
+ hint = pg['vue_hint']
299
+ if not hint:
300
+ continue
301
+ traces = _query_traces(hint, rr)
302
+ if not traces:
303
+ continue
304
+ # 按 vue_file 分组 (一个 hint 可能命中多个 vue 文件, 各自列按钮)
305
+ by_vue = {}
306
+ for t in traces:
307
+ by_vue.setdefault(t['vue_file'], []).append(t)
308
+ for vue_file, btns in by_vue.items():
309
+ # 去重: 同一按钮名 + 同一 endpoint 只留一个
310
+ seen = set()
311
+ dedup = []
312
+ for b in btns:
313
+ key = (b['button'], b.get('endpoint') or '')
314
+ if key in seen:
315
+ continue
316
+ seen.add(key)
317
+ dedup.append({
318
+ 'button': b['button'],
319
+ 'handler': b['handler'],
320
+ 'endpoint': b['endpoint'],
321
+ 'verb': b['verb'],
322
+ 'confidence': b['confidence'],
323
+ 'resolved': bool(b['endpoint'] and b['endpoint'] != 'None'),
324
+ })
325
+ pages_result.append({
326
+ 'name': pg['name'],
327
+ 'url': pg['url'],
328
+ 'vue_file': vue_file,
329
+ 'buttons': dedup,
330
+ })
331
+
332
+ stats = _trace_stats(rr)
333
+
334
+ if not pages_result:
335
+ return {
336
+ 'ok': False,
337
+ 'reason': '知识图谱里没找到匹配的页面按钮 (可能页面未被索引, 或关键词不准)',
338
+ 'pages': [],
339
+ 'stats': stats,
340
+ }
341
+ return {'ok': True, 'pages': pages_result, 'stats': stats}
342
+
343
+
344
+ # ============================================================
345
+ # 渲染: Markdown 画像 (给测试人员看 / 写入 test-handoff.md)
346
+ # ============================================================
347
+
348
+ def render_profile_md(profile: Dict[str, Any]) -> str:
349
+ """把画像渲染成 Markdown。"""
350
+ if not profile.get('ok'):
351
+ lines = ['> ' + (profile.get('reason') or '无画像数据')]
352
+ stats = profile.get('stats')
353
+ if stats:
354
+ lines.append('> (知识图谱 traces 覆盖率 %.0f%%, 未覆盖的交互需现场确认)'
355
+ % (100.0 * stats.get('resolved', 0) / max(1, stats.get('total', 1))))
356
+ return '\n'.join(lines)
357
+
358
+ lines = []
359
+ total_buttons = 0
360
+ for pg in profile['pages']:
361
+ btns = pg['buttons']
362
+ total_buttons += len(btns)
363
+ lines.append('### %s' % pg['name'])
364
+ if pg.get('url'):
365
+ lines.append('路由: `%s`' % pg['url'])
366
+ # vue_file 只取 data/code/ 之后的相对路径, 更短
367
+ vf = pg.get('vue_file', '')
368
+ vf_rel = vf.split('/data/code/')[-1] if '/data/code/' in vf else vf
369
+ lines.append('Vue: `%s`' % vf_rel)
370
+ lines.append('')
371
+ resolved_btns = [b for b in btns if b['resolved']]
372
+ unresolved_btns = [b for b in btns if not b['resolved']]
373
+
374
+ if resolved_btns:
375
+ lines.append('| 按钮 | handler | 接口 | 方法 | 置信度 |')
376
+ lines.append('|------|---------|------|------|--------|')
377
+ for b in resolved_btns:
378
+ ep = b['endpoint'] or '?'
379
+ lines.append('| %s | %s | %s | %s | %s |' % (
380
+ b['button'], b['handler'] or '?', ep, (b['verb'] or '?').upper(),
381
+ b['confidence']))
382
+ lines.append('')
383
+
384
+ if unresolved_btns:
385
+ lines.append('**未解析到接口的按钮** (交互意图已知, 接口需现场确认):')
386
+ for b in unresolved_btns:
387
+ lines.append('- %s → %s (%s)' % (
388
+ b['button'], b['handler'] or '?', b['confidence']))
389
+ lines.append('')
390
+
391
+ # 测试建议 (按按钮类型自动给提示)
392
+ lines.append('**测试建议:**')
393
+ sugg = _test_suggestions(btns)
394
+ for s in sugg:
395
+ lines.append('- %s' % s)
396
+ lines.append('')
397
+
398
+ stats = profile.get('stats')
399
+ if stats:
400
+ cov = 100.0 * stats.get('resolved', 0) / max(1, stats.get('total', 1))
401
+ lines.append('> 📊 知识图谱 traces 覆盖率 %.0f%% (resolved %d/%d)。'
402
+ '未覆盖的按钮交互需现场点击确认。' % (cov, stats.get('resolved', 0), stats.get('total', 1)))
403
+ return '\n'.join(lines)
404
+
405
+
406
+ def _test_suggestions(btns: List[Dict[str, Any]]) -> List[str]:
407
+ """根据按钮文本自动生成测试建议。"""
408
+ sugg = []
409
+ btn_texts = [b['button'] for b in btns]
410
+ btn_set = set(btn_texts)
411
+ # 按常见按钮语义给建议
412
+ if any(k in btn_set for k in ('新增', '添加', '新建', '创建')):
413
+ sugg.append('新增类: fill 表单必填项 click 新增按钮 断言接口返回成功 + 列表出现新记录')
414
+ if any(k in btn_set for k in ('编辑', '修改', '更新')):
415
+ sugg.append('编辑类: 选中记录 → click 编辑 → 断言表单回填 + 保存后接口 UPDATE 调用')
416
+ if any(k in btn_set for k in ('删除', '移除')):
417
+ sugg.append('删除类: click 删除 二次确认弹窗断言接口 DELETE 调用 + 列表记录消失')
418
+ if any(k in btn_set for k in ('导出', '下载')):
419
+ sugg.append('导出类: click 导出 断言下载触发 (或接口 export 调用 + 文件生成)')
420
+ if any(k in btn_set for k in ('查询', '搜索', '筛选', '检索')):
421
+ sugg.append('查询类: fill 筛选条件 → click 查询 → 断言列表按条件刷新')
422
+ if any(k in btn_set for k in ('导入', '上传')):
423
+ sugg.append('导入类: upload 文件 → 断言解析结果 + 接口 import 调用')
424
+ if any(k in btn_set for k in ('提交', '保存', '确认')):
425
+ sugg.append('提交类: 填完表单 → click 提交 → 断言接口成功 + 状态流转')
426
+ # 异常场景 (通用)
427
+ if sugg:
428
+ sugg.append('异常场景: 必填项留空提交 → 断言校验提示; 接口超时/失败 → 断言错误提示')
429
+ else:
430
+ sugg.append('通用: 逐个点击按钮 → 断言对应接口被调用 + 页面反馈正确')
431
+ return sugg
432
+
433
+
434
+ # ============================================================
435
+ # CLI
436
+ # ============================================================
437
+
438
+ def main(argv=None):
439
+ import argparse
440
+ p = argparse.ArgumentParser(
441
+ prog='page_probe.py',
442
+ description='查页面按钮+接口画像 (开发→测试交接物)',
443
+ )
444
+ g = p.add_mutually_exclusive_group(required=True)
445
+ g.add_argument('--kw', help='业务关键词 (如 保险 / 排班)')
446
+ g.add_argument('--url', help='页面 URL (如 /veh/vehicle/vehAffair/insurance)')
447
+ p.add_argument('--repo', default=None, help='仓库根目录 (默认自动检测)')
448
+ args = p.parse_args(argv)
449
+
450
+ profile = build_page_profile(keyword=args.kw, url=args.url, repo_root=args.repo)
451
+
452
+ if not profile.get('ok'):
453
+ print('## 页面画像: %s' % (args.kw or args.url))
454
+ print()
455
+ print(render_profile_md(profile))
456
+ print()
457
+ sys.exit(0)
458
+
459
+ print('## 页面画像: %s' % (args.kw or args.url))
460
+ print()
461
+ print(render_profile_md(profile))
462
+ n_pages = len(profile['pages'])
463
+ n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
464
+ print()
465
+ print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
466
+ sys.exit(0)
467
+
468
+
469
+ if __name__ == '__main__':
470
+ main()