@hupan56/wlkj 2.6.0 → 2.7.1

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 (125) hide show
  1. package/bin/cli.js +269 -11
  2. package/package.json +1 -1
  3. package/templates/qoder/agents/insight-planning.md +67 -0
  4. package/templates/qoder/agents/insight-research.md +61 -0
  5. package/templates/qoder/agents/prd-reference.md +8 -2
  6. package/templates/qoder/commands/optional/wl-insight.md +275 -0
  7. package/templates/qoder/commands/{wl-report.md → optional/wl-report.md} +13 -5
  8. package/templates/qoder/commands/{wl-spec.md → optional/wl-spec.md} +1 -1
  9. package/templates/qoder/commands/{wl-status.md → optional/wl-status.md} +28 -2
  10. package/templates/qoder/commands/wl-code.md +10 -2
  11. package/templates/qoder/commands/wl-commit.md +1 -1
  12. package/templates/qoder/commands/wl-design-draw.md +78 -0
  13. package/templates/qoder/commands/wl-design-scan.md +108 -0
  14. package/templates/qoder/commands/wl-design-spec.md +154 -0
  15. package/templates/qoder/commands/wl-design.md +32 -0
  16. package/templates/qoder/commands/wl-init.md +24 -3
  17. package/templates/qoder/commands/wl-prd-full.md +226 -0
  18. package/templates/qoder/commands/wl-prd-quick.md +134 -0
  19. package/templates/qoder/commands/wl-prd-review.md +104 -0
  20. package/templates/qoder/commands/wl-prd.md +17 -311
  21. package/templates/qoder/commands/wl-search.md +66 -30
  22. package/templates/qoder/commands/wl-task.md +290 -59
  23. package/templates/qoder/commands/wl-test.md +92 -24
  24. package/templates/qoder/config.yaml +51 -15
  25. package/templates/qoder/hooks/inject-workflow-state.py +2 -2
  26. package/templates/qoder/hooks/session-start.py +82 -51
  27. package/templates/qoder/rules/wl-pipeline.md +216 -105
  28. package/templates/qoder/scripts/__pycache__/search_index.cpython-39.pyc +0 -0
  29. package/templates/qoder/scripts/archive_prd.py +377 -0
  30. package/templates/qoder/scripts/autotest.py +1715 -0
  31. package/templates/qoder/scripts/autotest_batch.py +224 -0
  32. package/templates/qoder/scripts/autotest_run.py +297 -0
  33. package/templates/qoder/scripts/benchmark.py +210 -209
  34. package/templates/qoder/scripts/build_style_index.py +444 -4
  35. package/templates/qoder/scripts/check_carriers.py +238 -0
  36. package/templates/qoder/scripts/check_mcp.py +298 -0
  37. package/templates/qoder/scripts/check_qoderwork_consistency.py +166 -0
  38. package/templates/qoder/scripts/common/events.py +46 -0
  39. package/templates/qoder/scripts/common/extract.py +419 -0
  40. package/templates/qoder/scripts/common/graph_traverse.py +533 -0
  41. package/templates/qoder/scripts/common/paths.py +89 -0
  42. package/templates/qoder/scripts/common/pip_install.py +144 -0
  43. package/templates/qoder/scripts/common/platform_guard.py +61 -0
  44. package/templates/qoder/scripts/common/search_engine.py +205 -205
  45. package/templates/qoder/scripts/common/terms.py +57 -0
  46. package/templates/qoder/scripts/common/ts_extract.py +536 -0
  47. package/templates/qoder/scripts/context_pack.py +73 -13
  48. package/templates/qoder/scripts/enrich_prompt.py +226 -0
  49. package/templates/qoder/scripts/eval_prd.py +318 -231
  50. package/templates/qoder/scripts/export.py +487 -487
  51. package/templates/qoder/scripts/extract_api_params.py +246 -0
  52. package/templates/qoder/scripts/extract_routes.py +54 -0
  53. package/templates/qoder/scripts/extract_routes_tree.py +78 -0
  54. package/templates/qoder/scripts/fill_prototype.py +707 -0
  55. package/templates/qoder/scripts/gen_design_doc.py +394 -0
  56. package/templates/qoder/scripts/git_sync.py +27 -15
  57. package/templates/qoder/scripts/init_doctor.py +292 -40
  58. package/templates/qoder/scripts/install_qoderwork.py +366 -9
  59. package/templates/qoder/scripts/kg.py +708 -0
  60. package/templates/qoder/scripts/kg_auto_login.py +196 -0
  61. package/templates/qoder/scripts/kg_build.py +612 -0
  62. package/templates/qoder/scripts/kg_build_db.py +327 -0
  63. package/templates/qoder/scripts/kg_duckdb.py +549 -0
  64. package/templates/qoder/scripts/kg_incremental.py +393 -0
  65. package/templates/qoder/scripts/kg_link_db.py +224 -0
  66. package/templates/qoder/scripts/kg_mcp_server.py +801 -0
  67. package/templates/qoder/scripts/kg_semantic.py +150 -0
  68. package/templates/qoder/scripts/kg_test_runner.py +241 -0
  69. package/templates/qoder/scripts/lanhu_stdio_wrapper.py +119 -0
  70. package/templates/qoder/scripts/learn.py +118 -39
  71. package/templates/qoder/scripts/learn_aggregate.py +201 -0
  72. package/templates/qoder/scripts/mcp_launcher.py +359 -0
  73. package/templates/qoder/scripts/mysql_mcp_server.py +396 -0
  74. package/templates/qoder/scripts/repo_root.py +106 -0
  75. package/templates/qoder/scripts/role.py +12 -0
  76. package/templates/qoder/scripts/run_weekly_update.bat +5 -0
  77. package/templates/qoder/scripts/run_weekly_update.sh +5 -0
  78. package/templates/qoder/scripts/search_index.py +307 -60
  79. package/templates/qoder/scripts/secure-ls.js +5640 -0
  80. package/templates/qoder/scripts/setup.py +706 -641
  81. package/templates/qoder/scripts/setup_lanhu.py +963 -0
  82. package/templates/qoder/scripts/status.py +250 -11
  83. package/templates/qoder/scripts/sync_carriers.py +259 -0
  84. package/templates/qoder/scripts/syncgate.py +2 -2
  85. package/templates/qoder/scripts/task.py +75 -0
  86. package/templates/qoder/scripts/team_sync.py +60 -4
  87. package/templates/qoder/scripts/workspace_init.py +1 -1
  88. package/templates/qoder/skills/design-import/SKILL.md +226 -0
  89. package/templates/qoder/skills/design-import/figma-workflow.md +81 -0
  90. package/templates/qoder/skills/design-review/SKILL.md +82 -25
  91. package/templates/qoder/skills/prd-generator/SKILL.md +184 -60
  92. package/templates/qoder/skills/prd-review/SKILL.md +18 -1
  93. package/templates/qoder/skills/prompt-enrich/SKILL.md +90 -0
  94. package/templates/qoder/skills/prototype-generator/SKILL.md +256 -141
  95. package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
  96. package/templates/qoder/skills/spec-coder/SKILL.md +18 -1
  97. package/templates/qoder/skills/spec-generator/SKILL.md +18 -1
  98. package/templates/qoder/skills/test-generator/SKILL.md +15 -2
  99. package/templates/qoder/skills/wl-code/SKILL.md +55 -36
  100. package/templates/qoder/skills/wl-commit/SKILL.md +89 -76
  101. package/templates/qoder/skills/wl-design/SKILL.md +55 -0
  102. package/templates/qoder/skills/wl-init/SKILL.md +76 -67
  103. package/templates/qoder/skills/wl-insight/SKILL.md +201 -81
  104. package/templates/qoder/skills/wl-prd-full/SKILL.md +69 -0
  105. package/templates/qoder/skills/wl-prd-quick/SKILL.md +49 -0
  106. package/templates/qoder/skills/wl-prd-review/SKILL.md +34 -0
  107. package/templates/qoder/skills/wl-report/SKILL.md +131 -107
  108. package/templates/qoder/skills/wl-search/SKILL.md +141 -75
  109. package/templates/qoder/skills/wl-spec/SKILL.md +49 -39
  110. package/templates/qoder/skills/wl-status/SKILL.md +83 -61
  111. package/templates/qoder/skills/wl-task/SKILL.md +132 -58
  112. package/templates/qoder/skills/wl-test/SKILL.md +406 -40
  113. package/templates/qoder/templates/prototype-app.html +13 -8
  114. package/templates/qoder/templates/prototype-web.html +376 -93
  115. package/templates/root/AGENTS.md +89 -34
  116. package/templates/root/requirements.txt +21 -0
  117. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +259 -259
  118. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -186
  119. package/templates/qoder/agents/prd-planning.md +0 -57
  120. package/templates/qoder/agents/prd-research.md +0 -34
  121. package/templates/qoder/commands/wl-insight.md +0 -51
  122. package/templates/qoder/scripts/__pycache__/init_doctor.cpython-39.pyc +0 -0
  123. package/templates/qoder/scripts/__pycache__/setup.cpython-39.pyc +0 -0
  124. package/templates/qoder/scripts/common/__pycache__/developer.cpython-39.pyc +0 -0
  125. package/templates/qoder/skills/wl-prd/SKILL.md +0 -92
@@ -0,0 +1,144 @@
1
+ # -*- coding: utf-8 -*-
2
+ """pip_install.py - 带镜像兜底的 pip 安装 (跨环境下载健壮性)
3
+
4
+ 为什么需要它:
5
+ 国内网络下 pip install 连 PyPI 默认源经常超时/失败。
6
+ 新同事第一次 wlkj init 就卡在装包 → 知识图谱/MCP 全崩 → 以为工作流坏了。
7
+ 本模块: 默认源失败 → 自动切国内镜像重试 → 仍失败 → 返回清晰错误。
8
+
9
+ 策略 (用户选定: 失败时才切):
10
+ 1. 先用默认源装 (不动用户环境)
11
+ 2. 失败 → 逐个试国内镜像 (清华/阿里/腾讯), 每个带超时
12
+ 3. 仍失败 → 返回 (False, 缺失包列表, 手动命令), 不静默吞掉
13
+
14
+ 不修改用户全局 pip 配置, 只在本次安装命令里加 -i 参数。
15
+ """
16
+
17
+ import subprocess
18
+ import sys
19
+
20
+ # 国内镜像源 (按速度/稳定性排序, 清华最稳)
21
+ MIRRORS = [
22
+ ('清华', 'https://pypi.tuna.tsinghua.edu.cn/simple'),
23
+ ('阿里', 'https://mirrors.aliyun.com/pypi/simple/'),
24
+ ('腾讯', 'https://mirrors.cloud.tencent.com/pypi/simple'),
25
+ ]
26
+
27
+ # 单次安装超时 (秒), 避免卡死
28
+ INSTALL_TIMEOUT = 300
29
+
30
+
31
+ def _run_pip(args, index_url=None, timeout=INSTALL_TIMEOUT):
32
+ """跑一次 pip, 返回 (returncode, stdout, stderr)。
33
+ index_url 非 None 时加 -i 指定镜像源。
34
+ 用 trusted-host 避免某些镜像的 SSL 证书警告阻断。
35
+ """
36
+ cmd = [sys.executable, '-m', 'pip', 'install'] + args
37
+ if index_url:
38
+ cmd += ['-i', index_url,
39
+ '--trusted-host', _host_of(index_url)]
40
+ try:
41
+ r = subprocess.run(cmd, capture_output=True, text=True,
42
+ encoding='utf-8', errors='replace', timeout=timeout)
43
+ return r.returncode, r.stdout or '', r.stderr or ''
44
+ except subprocess.TimeoutExpired:
45
+ return 1, '', '安装超时 (%ds)' % timeout
46
+ except Exception as e:
47
+ return 1, '', str(e)
48
+
49
+
50
+ def _host_of(url):
51
+ """从 https://pypi.tuna.tsinghua.edu.cn/simple 提取 pypi.tuna.tsinghua.edu.cn"""
52
+ s = url.replace('https://', '').replace('http://', '')
53
+ return s.split('/')[0]
54
+
55
+
56
+ def install_with_fallback(args):
57
+ """带镜像兜底的安装。
58
+
59
+ args: pip install 的参数 (如 ['-r', 'requirements.txt'] 或 ['duckdb'])
60
+ 返回 dict:
61
+ ok: True=全部装上 / False=仍有缺失
62
+ source: 最终成功用的源 ('default' / '清华' / None)
63
+ missing: 仍缺失的包 (ok=True 时为空)
64
+ tried: 尝试过的源列表 [(name, success)]
65
+ hint: 失败时的手动命令
66
+ """
67
+ tried = []
68
+
69
+ # 1. 默认源
70
+ rc, out, err = _run_pip(args)
71
+ tried.append(('默认源(PyPI)', rc == 0))
72
+ if rc == 0:
73
+ return {'ok': True, 'source': 'default', 'missing': [], 'tried': tried,
74
+ 'hint': None}
75
+
76
+ # 2. 逐个国内镜像重试
77
+ for name, url in MIRRORS:
78
+ rc, out, err = _run_pip(args, index_url=url)
79
+ tried.append((name, rc == 0))
80
+ if rc == 0:
81
+ return {'ok': True, 'source': name, 'missing': [], 'tried': tried,
82
+ 'hint': None}
83
+
84
+ # 3. 全失败 —— 解析出哪些包没装上 (用 import 验证, 比 parse pip 输出可靠)
85
+ missing = _verify_packages(args)
86
+ hint = _build_manual_hint(missing, tried)
87
+ return {'ok': False, 'source': None, 'missing': missing,
88
+ 'tried': tried, 'hint': hint}
89
+
90
+
91
+ def _verify_packages(args):
92
+ """从 args 里提取要装的核心包, 用 import 验证真正装上了没。
93
+ 只处理 -r requirements.txt 和直接包名两种情况。
94
+ 返回仍 import 失败的 pip 包名列表。
95
+ """
96
+ # 直接包名: args 里不带 - 开头的项
97
+ pkg_names = [a for a in args if not a.startswith('-')]
98
+ if '-r' in args:
99
+ # 从 requirements.txt 读包名 (取每行第一个 token, 去 >= 等版本约束)
100
+ try:
101
+ i = args.index('-r')
102
+ req_path = args[i + 1]
103
+ with open(req_path, encoding='utf-8') as f:
104
+ for line in f:
105
+ line = line.strip()
106
+ if line and not line.startswith('#'):
107
+ pkg_names.append(line.split('>=')[0].split('==')[0]
108
+ .split('~=')[0].split('>')[0]
109
+ .split('<')[0].strip())
110
+ except (OSError, IndexError, ValueError):
111
+ pass
112
+
113
+ # 包名 → import 名映射 (名字不一样的)
114
+ import_map = {
115
+ 'PyYAML': 'yaml',
116
+ 'tree-sitter': 'tree_sitter',
117
+ 'tree-sitter-typescript': 'tree_sitter_typescript',
118
+ }
119
+ still_missing = []
120
+ for pkg in pkg_names:
121
+ mod = import_map.get(pkg, pkg.replace('-', '_'))
122
+ try:
123
+ __import__(mod)
124
+ except ImportError:
125
+ still_missing.append(pkg)
126
+ return still_missing
127
+
128
+
129
+ def _build_manual_hint(missing, tried):
130
+ """失败时给用户的手动命令。"""
131
+ src_hint = '、'.join('%s(%s)' % (n, '✓' if ok else '✗') for n, ok in tried)
132
+ lines = [
133
+ 'pip 安装失败, 尝试过的源: ' + src_hint,
134
+ '可能原因: 网络/防火墙/公司代理拦截 PyPI。',
135
+ ]
136
+ if missing:
137
+ lines.append('仍缺失: ' + ', '.join(missing))
138
+ lines.append('手动装 (用清华镜像):')
139
+ lines.append(' pip install -i https://pypi.tuna.tsinghua.edu.cn/simple '
140
+ + ' '.join(missing))
141
+ lines.append('或配全局镜像 (一劳永逸):')
142
+ lines.append(' pip config set global.index-url '
143
+ 'https://pypi.tuna.tsinghua.edu.cn/simple')
144
+ return '\n '.join(lines)
@@ -0,0 +1,61 @@
1
+ # -*- coding: utf-8 -*-
2
+ """platform_guard.py - 平台守卫 (代码级硬墙, 替代靠大写喊话)
3
+
4
+ 为什么需要它:
5
+ AGENTS.md 的 CRITICAL RULE #1 用 "DO NOT / VIOLATION" 威胁 AI 必须先问平台。
6
+ 但把正确性押在 LLM 服从性上不靠谱 —— AI 会被长上下文稀释、会忘。
7
+ 这层守卫把"建议"变成"硬墙": AI 不带 --platform 调用 PRD 链路脚本时直接 exit。
8
+
9
+ 设计:
10
+ 守卫不依赖 args.platform (因为 fill_prototype.py 的 --platform default='web',
11
+ 永远非 None 会绕过守卫)。改为查 sys.argv 是否【显式】包含 --platform。
12
+ 这样 context_pack (platform 初始 None) 和 fill_prototype (default web) 逻辑统一。
13
+
14
+ 违规时不只是 exit, 还主动上报一条 rule_violation 事件到当前 dev 的 journal,
15
+ 让 /wl-status 看板能统计"本周违规几次" —— 闭环可观测。
16
+ """
17
+
18
+ import sys
19
+
20
+
21
+ def _platform_explicitly_passed(argv=None):
22
+ """用户是否在命令行显式传了 --platform。
23
+ 查 argv 而非 args 对象, 绕开 default 值的干扰。
24
+ """
25
+ argv = argv if argv is not None else sys.argv
26
+ return '--platform' in argv
27
+
28
+
29
+ def assert_platform_selected(skill='wl-prd-full', argv=None):
30
+ """硬墙: 没显式传 --platform 就调用 PRD 链路脚本 = 违规。
31
+
32
+ 1. best-effort 上报 rule_violation 事件 (不阻塞, 失败静默)
33
+ 2. sys.exit 并提示 (硬墙)
34
+
35
+ 正常调用 (带 --platform) 直接返回, 零开销。
36
+ """
37
+ if _platform_explicitly_passed(argv):
38
+ return
39
+
40
+ # 1. 上报违规 (best-effort, 任何失败都不影响拦截)
41
+ try:
42
+ import os
43
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
44
+ from learn import record_feedback
45
+ record_feedback('rule_violation', {
46
+ 'rule': 'ask_platform_first',
47
+ 'skill': skill,
48
+ 'detail': '脚本被调用但未显式传 --platform',
49
+ })
50
+ except SystemExit:
51
+ raise
52
+ except Exception:
53
+ pass # 上报失败不影响拦截本身
54
+
55
+ # 2. 硬墙
56
+ print('【平台守卫】调用 %s 链路的脚本前必须先问用户选哪个平台 (web/app)。' % skill,
57
+ file=sys.stderr)
58
+ print('参考 AGENTS.md CRITICAL RULE #1: 先问平台, 不自动判断, 不跳过。',
59
+ file=sys.stderr)
60
+ print('已记录一次规则违规到 /wl-status 健康度看板。', file=sys.stderr)
61
+ sys.exit(2)
@@ -1,205 +1,205 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- search_engine.py - 搜索倒排索引 + mtime 缓存 (性能优化 A2)
4
-
5
- 把 search_keywords 的 O(n) 全扫描 3267 keys 降到 O(命中数)。
6
-
7
- 设计:
8
- - exact_index: {word_lower: [keyword_keys]} 精确匹配, O(1) 查找
9
- - prefix/suffix 索引用 trigram (3-gram) 加速子串匹配
10
- - 缓存序列化到 data/index/.inverted-cache.json, 含源 mtime
11
- - 只在 keyword-index.json 变化时重建
12
-
13
- 核心 API:
14
- get_inverted_index(repo_root) -> dict # 带缓存的倒排索引
15
- search_keywords_fast(words, inverted, ki) -> {kw: files} # 快速搜索
16
- """
17
-
18
- import json
19
- import os
20
- import sys
21
- from typing import Dict, List, Optional, Set, Tuple
22
-
23
- _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
24
- if _THIS_DIR not in sys.path:
25
- sys.path.insert(0, _THIS_DIR)
26
-
27
- from atomicio import safe_read_json, atomic_write_json
28
-
29
- __all__ = [
30
- "get_inverted_index",
31
- "search_keywords_fast",
32
- "build_inverted_index",
33
- "MIN_FUZZY_LEN",
34
- ]
35
-
36
- MIN_FUZZY_LEN = 4 # 与 search_index.py 保持一致
37
-
38
-
39
- def _trigrams(s: str) -> Set[str]:
40
- """生成字符串的所有 3-gram (小写)。短于 3 字符则返回 {字符串本身}。"""
41
- s = s.lower()
42
- if len(s) < 3:
43
- return {s}
44
- return {s[i:i+3] for i in range(len(s) - 2)}
45
-
46
-
47
- def build_inverted_index(keyword_index: Dict[str, list]) -> dict:
48
- """从 keyword_index 构建倒排索引。
49
-
50
- Returns:
51
- {
52
- "exact": {word_lower: [keyword_keys]}, # 精确匹配
53
- "trigram": {trigram: set(keyword_keys)}, # 3-gram → 含它的 keys
54
- "keywords": [所有 keyword_keys 排序] # 用于回退全扫描
55
- }
56
- """
57
- exact = {}
58
- trigram = {}
59
- all_keys = sorted(keyword_index.keys())
60
-
61
- for kw in all_keys:
62
- kl = kw.lower()
63
- # 精确索引
64
- exact.setdefault(kl, []).append(kw)
65
- # trigram 索引 (用于子串匹配的候选集缩小)
66
- for tg in _trigrams(kl):
67
- trigram.setdefault(tg, set()).add(kw)
68
-
69
- # set 转 list 便于 JSON 序列化
70
- trigram_serializable = {tg: sorted(keys) for tg, keys in trigram.items()}
71
-
72
- return {
73
- "exact": exact,
74
- "trigram": trigram_serializable,
75
- "keywords": all_keys,
76
- }
77
-
78
-
79
- def get_inverted_index(repo_root: str) -> Optional[dict]:
80
- """获取倒排索引 (带 mtime 缓存)。
81
-
82
- 缓存逻辑:
83
- - 读 .inverted-cache.json 的 source_mtime
84
- - 与 keyword-index.json 当前 mtime 比对
85
- - 不一致或缓存缺失则重建 + 持久化
86
-
87
- Returns:
88
- 倒排索引 dict, 或 None (keyword-index 不存在时)。
89
- """
90
- index_dir = os.path.join(repo_root, "data", "index")
91
- ki_path = os.path.join(index_dir, "keyword-index.json")
92
- cache_path = os.path.join(index_dir, ".inverted-cache.json")
93
-
94
- if not os.path.isfile(ki_path):
95
- return None
96
-
97
- try:
98
- ki_mtime = os.path.getmtime(ki_path)
99
- except OSError:
100
- return None
101
-
102
- # 尝试读缓存
103
- cached = safe_read_json(cache_path, default=None)
104
- if cached and cached.get("source_mtime") == ki_mtime:
105
- # 缓存命中 (trigram 的 set 反序列化为 list, 搜索时兼容)
106
- return cached
107
-
108
- # 缓存失效或缺失, 重建
109
- ki = safe_read_json(ki_path, default=None)
110
- if not ki:
111
- return None
112
-
113
- inverted = build_inverted_index(ki)
114
- inverted["source_mtime"] = ki_mtime
115
- inverted["built_at"] = os.path.getmtime(ki_path)
116
-
117
- # 持久化 (best-effort, 失败不阻塞搜索)
118
- try:
119
- atomic_write_json(cache_path, inverted)
120
- except Exception:
121
- pass
122
-
123
- return inverted
124
-
125
-
126
- def _candidate_keys(word: str, inverted: dict) -> List[str]:
127
- """用倒排索引找到候选 keyword keys (缩小扫描集)。
128
-
129
- 策略:
130
- - 用 trigram 交集找候选 (子串匹配的双方至少共享一个 trigram)
131
- - 精确命中必然包含在 trigram 候选里 (相同字符串共享所有 trigram)
132
- - 候选为空则回退全部 keys (兜底, 罕见)
133
-
134
- 关键: 不能在精确命中后短路 —— query "insurance" 还要匹配
135
- "insurancepolicy" 等 (query 是 kw 的子串), 所以必须返回 trigram 候选集。
136
- """
137
- wl = word.lower()
138
- tgs = _trigrams(wl)
139
- trigram_index = inverted.get("trigram", {})
140
- candidate_sets = []
141
- for tg in tgs:
142
- keys = trigram_index.get(tg)
143
- if keys:
144
- candidate_sets.append(set(keys))
145
-
146
- if not candidate_sets:
147
- # 词太短无 trigram (如 2 字符), 回退精确 + 全扫描兜底
148
- exact = inverted.get("exact", {}).get(wl)
149
- if exact:
150
- return list(exact)
151
- return inverted.get("keywords", [])
152
-
153
- # 取并集: query 含 kw 或 kw 含 query, 至少一个 trigram 重叠
154
- candidates = set()
155
- for cs in candidate_sets:
156
- candidates |= cs
157
-
158
- return sorted(candidates)
159
-
160
-
161
- def search_keywords_fast(
162
- words: List[str],
163
- inverted: Optional[dict],
164
- keyword_index: Dict[str, list],
165
- ) -> Dict[str, list]:
166
- """快速关键词搜索 (用倒排索引缩小候选集)。
167
-
168
- Args:
169
- words: 查询词列表 (已小写化、已 CN 扩展)。
170
- inverted: 倒排索引 (None 则回退到全扫描)。
171
- keyword_index: 原始 keyword-index (用于取 files)。
172
-
173
- Returns:
174
- {keyword_key: files} 匹配结果。
175
- """
176
- matches = {}
177
-
178
- if inverted is None:
179
- # 回退: 全扫描 (旧逻辑)
180
- for word in words:
181
- for kw, files in keyword_index.items():
182
- if _match(word, kw):
183
- matches[kw] = files
184
- return matches
185
-
186
- for word in words:
187
- candidates = _candidate_keys(word, inverted)
188
- for kw in candidates:
189
- files = keyword_index.get(kw)
190
- if files and _match(word, kw):
191
- matches[kw] = files
192
-
193
- return matches
194
-
195
-
196
- def _match(query_word: str, kw: str) -> bool:
197
- """与 search_index._match_keyword 一致的匹配逻辑。"""
198
- w, k = query_word.lower(), kw.lower()
199
- if w == k:
200
- return True
201
- if len(k) >= MIN_FUZZY_LEN and k in w:
202
- return True
203
- if len(w) >= MIN_FUZZY_LEN and w in k:
204
- return True
205
- return False
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ search_engine.py - 搜索倒排索引 + mtime 缓存 (性能优化 A2)
4
+
5
+ 把 search_keywords 的 O(n) 全扫描 3267 keys 降到 O(命中数)。
6
+
7
+ 设计:
8
+ - exact_index: {word_lower: [keyword_keys]} 精确匹配, O(1) 查找
9
+ - prefix/suffix 索引用 trigram (3-gram) 加速子串匹配
10
+ - 缓存序列化到 data/index/.inverted-cache.json, 含源 mtime
11
+ - 只在 code-keyword.json 变化时重建
12
+
13
+ 核心 API:
14
+ get_inverted_index(repo_root) -> dict # 带缓存的倒排索引
15
+ search_keywords_fast(words, inverted, ki) -> {kw: files} # 快速搜索
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from typing import Dict, List, Optional, Set, Tuple
22
+
23
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
24
+ if _THIS_DIR not in sys.path:
25
+ sys.path.insert(0, _THIS_DIR)
26
+
27
+ from atomicio import safe_read_json, atomic_write_json
28
+
29
+ __all__ = [
30
+ "get_inverted_index",
31
+ "search_keywords_fast",
32
+ "build_inverted_index",
33
+ "MIN_FUZZY_LEN",
34
+ ]
35
+
36
+ MIN_FUZZY_LEN = 4 # 与 search_index.py 保持一致
37
+
38
+
39
+ def _trigrams(s: str) -> Set[str]:
40
+ """生成字符串的所有 3-gram (小写)。短于 3 字符则返回 {字符串本身}。"""
41
+ s = s.lower()
42
+ if len(s) < 3:
43
+ return {s}
44
+ return {s[i:i+3] for i in range(len(s) - 2)}
45
+
46
+
47
+ def build_inverted_index(keyword_index: Dict[str, list]) -> dict:
48
+ """从 keyword_index 构建倒排索引。
49
+
50
+ Returns:
51
+ {
52
+ "exact": {word_lower: [keyword_keys]}, # 精确匹配
53
+ "trigram": {trigram: set(keyword_keys)}, # 3-gram → 含它的 keys
54
+ "keywords": [所有 keyword_keys 排序] # 用于回退全扫描
55
+ }
56
+ """
57
+ exact = {}
58
+ trigram = {}
59
+ all_keys = sorted(keyword_index.keys())
60
+
61
+ for kw in all_keys:
62
+ kl = kw.lower()
63
+ # 精确索引
64
+ exact.setdefault(kl, []).append(kw)
65
+ # trigram 索引 (用于子串匹配的候选集缩小)
66
+ for tg in _trigrams(kl):
67
+ trigram.setdefault(tg, set()).add(kw)
68
+
69
+ # set 转 list 便于 JSON 序列化
70
+ trigram_serializable = {tg: sorted(keys) for tg, keys in trigram.items()}
71
+
72
+ return {
73
+ "exact": exact,
74
+ "trigram": trigram_serializable,
75
+ "keywords": all_keys,
76
+ }
77
+
78
+
79
+ def get_inverted_index(repo_root: str) -> Optional[dict]:
80
+ """获取倒排索引 (带 mtime 缓存)。
81
+
82
+ 缓存逻辑:
83
+ - 读 .inverted-cache.json 的 source_mtime
84
+ - 与 code-keyword.json 当前 mtime 比对
85
+ - 不一致或缓存缺失则重建 + 持久化
86
+
87
+ Returns:
88
+ 倒排索引 dict, 或 None (keyword-index 不存在时)。
89
+ """
90
+ index_dir = os.path.join(repo_root, "data", "index")
91
+ ki_path = os.path.join(index_dir, "code-keyword.json")
92
+ cache_path = os.path.join(index_dir, ".inverted-cache.json")
93
+
94
+ if not os.path.isfile(ki_path):
95
+ return None
96
+
97
+ try:
98
+ ki_mtime = os.path.getmtime(ki_path)
99
+ except OSError:
100
+ return None
101
+
102
+ # 尝试读缓存
103
+ cached = safe_read_json(cache_path, default=None)
104
+ if cached and cached.get("source_mtime") == ki_mtime:
105
+ # 缓存命中 (trigram 的 set 反序列化为 list, 搜索时兼容)
106
+ return cached
107
+
108
+ # 缓存失效或缺失, 重建
109
+ ki = safe_read_json(ki_path, default=None)
110
+ if not ki:
111
+ return None
112
+
113
+ inverted = build_inverted_index(ki)
114
+ inverted["source_mtime"] = ki_mtime
115
+ inverted["built_at"] = os.path.getmtime(ki_path)
116
+
117
+ # 持久化 (best-effort, 失败不阻塞搜索)
118
+ try:
119
+ atomic_write_json(cache_path, inverted)
120
+ except Exception:
121
+ pass
122
+
123
+ return inverted
124
+
125
+
126
+ def _candidate_keys(word: str, inverted: dict) -> List[str]:
127
+ """用倒排索引找到候选 keyword keys (缩小扫描集)。
128
+
129
+ 策略:
130
+ - 用 trigram 交集找候选 (子串匹配的双方至少共享一个 trigram)
131
+ - 精确命中必然包含在 trigram 候选里 (相同字符串共享所有 trigram)
132
+ - 候选为空则回退全部 keys (兜底, 罕见)
133
+
134
+ 关键: 不能在精确命中后短路 —— query "insurance" 还要匹配
135
+ "insurancepolicy" 等 (query 是 kw 的子串), 所以必须返回 trigram 候选集。
136
+ """
137
+ wl = word.lower()
138
+ tgs = _trigrams(wl)
139
+ trigram_index = inverted.get("trigram", {})
140
+ candidate_sets = []
141
+ for tg in tgs:
142
+ keys = trigram_index.get(tg)
143
+ if keys:
144
+ candidate_sets.append(set(keys))
145
+
146
+ if not candidate_sets:
147
+ # 词太短无 trigram (如 2 字符), 回退精确 + 全扫描兜底
148
+ exact = inverted.get("exact", {}).get(wl)
149
+ if exact:
150
+ return list(exact)
151
+ return inverted.get("keywords", [])
152
+
153
+ # 取并集: query 含 kw 或 kw 含 query, 至少一个 trigram 重叠
154
+ candidates = set()
155
+ for cs in candidate_sets:
156
+ candidates |= cs
157
+
158
+ return sorted(candidates)
159
+
160
+
161
+ def search_keywords_fast(
162
+ words: List[str],
163
+ inverted: Optional[dict],
164
+ keyword_index: Dict[str, list],
165
+ ) -> Dict[str, list]:
166
+ """快速关键词搜索 (用倒排索引缩小候选集)。
167
+
168
+ Args:
169
+ words: 查询词列表 (已小写化、已 CN 扩展)。
170
+ inverted: 倒排索引 (None 则回退到全扫描)。
171
+ keyword_index: 原始 keyword-index (用于取 files)。
172
+
173
+ Returns:
174
+ {keyword_key: files} 匹配结果。
175
+ """
176
+ matches = {}
177
+
178
+ if inverted is None:
179
+ # 回退: 全扫描 (旧逻辑)
180
+ for word in words:
181
+ for kw, files in keyword_index.items():
182
+ if _match(word, kw):
183
+ matches[kw] = files
184
+ return matches
185
+
186
+ for word in words:
187
+ candidates = _candidate_keys(word, inverted)
188
+ for kw in candidates:
189
+ files = keyword_index.get(kw)
190
+ if files and _match(word, kw):
191
+ matches[kw] = files
192
+
193
+ return matches
194
+
195
+
196
+ def _match(query_word: str, kw: str) -> bool:
197
+ """与 search_index._match_keyword 一致的匹配逻辑。"""
198
+ w, k = query_word.lower(), kw.lower()
199
+ if w == k:
200
+ return True
201
+ if len(k) >= MIN_FUZZY_LEN and k in w:
202
+ return True
203
+ if len(w) >= MIN_FUZZY_LEN and w in k:
204
+ return True
205
+ return False