taxonomy-lookup.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. taxonomy-lookup.py — 实质 / 形式 外部 JSON 词表查询工具
  5. 阶段二 子 Agent B 用这个查实质/形式 路径, 避免把词表 (911 + 565 条路径) 塞进 context.
  6. 用法:
  7. taxonomy-lookup.py --dim 实质 --list-l2
  8. taxonomy-lookup.py --dim 实质 --subtree /理念/知识
  9. taxonomy-lookup.py --dim 实质 --match "近景人物特写"
  10. taxonomy-lookup.py --dim 实质 --match "年轻女性 卧室 床上" # 多 token 自动拆词聚合
  11. taxonomy-lookup.py --dim 实质 --narrow "AI生成 提示词结构" # 层级下钻 (扁平 match miss 时降级)
  12. taxonomy-lookup.py --dim 实质 --validate /理念/知识/化学
  13. 词表文件位置 (按优先级):
  14. 1. $TAXONOMY_DIR / 分类库导出_{实质|形式}_*.json (env override)
  15. 2. procedure-dsl/data/分类库导出_*.json (DSL 本地副本, 跨平台干净)
  16. 3. <agent-repo>/examples/process_pipeline/script/resource/分类库导出_*.json
  17. (项目 canonical 词表位置)
  18. 4. <agent-repo>/examples/process_pipeline/test_script/procedure_dsl_agent/data/
  19. 分类库导出_*.json (另一 agent 用的同副本)
  20. 5. ~/Downloads/分类库导出_*.json (本地下载兜底, 跨平台)
  21. """
  22. import argparse
  23. import glob
  24. import json
  25. import os
  26. import sys
  27. from collections import defaultdict
  28. from pathlib import Path
  29. DIM_PATTERNS = {
  30. '实质': '分类库导出_实质_*.json',
  31. '形式': '分类库导出_形式_*.json',
  32. }
  33. # Windows 控制台 UTF-8
  34. for _s in (sys.stdout, sys.stderr):
  35. if hasattr(_s, 'reconfigure'):
  36. try:
  37. _s.reconfigure(encoding='utf-8', errors='replace')
  38. except Exception:
  39. pass
  40. # DSL_ROOT = procedure-dsl/ (本脚本位于 procedure-dsl/spec/tools/)
  41. # 词表 bundled 在 spec/taxonomy/ 里, skill 自包含.
  42. DSL_ROOT = Path(__file__).resolve().parent.parent.parent
  43. SPEC_DIR = DSL_ROOT / 'spec'
  44. PROCESS_PIPELINE = DSL_ROOT.parents[2] # legacy fallback 用
  45. DEFAULT_SEARCH_PATHS = [
  46. os.environ.get('TAXONOMY_DIR', ''),
  47. str(SPEC_DIR / 'taxonomy'), # ← canonical: spec/ 自包含
  48. str(DSL_ROOT / 'data'), # legacy fallback
  49. str(PROCESS_PIPELINE / 'script' / 'resource'), # legacy fallback
  50. str(PROCESS_PIPELINE / 'test_script' / 'procedure_dsl_agent' / 'data'),
  51. str(Path.home() / 'Downloads'),
  52. ]
  53. def find_taxonomy_file(dim: str) -> str:
  54. pattern = DIM_PATTERNS[dim]
  55. for d in DEFAULT_SEARCH_PATHS:
  56. if not d:
  57. continue
  58. matches = sorted(glob.glob(os.path.join(d, pattern)), reverse=True)
  59. if matches:
  60. return matches[0]
  61. raise FileNotFoundError(f'未找到 {dim} 词表 (pattern: {pattern})')
  62. def load(dim: str) -> dict:
  63. path = find_taxonomy_file(dim)
  64. with open(path, 'r', encoding='utf-8') as f:
  65. return json.load(f)['data']
  66. def cmd_list_l2(data: dict):
  67. """列二级路径 + 该子树叶子数, 帮助 Agent 选 domain."""
  68. counts = defaultdict(int)
  69. for k in data.keys():
  70. parts = k.split('/')
  71. if len(parts) >= 3:
  72. counts['/'.join(parts[:3])] += 1
  73. print(f'共 {len(data)} 条路径, {len(counts)} 个二级 domain:')
  74. print()
  75. for l2, n in sorted(counts.items(), key=lambda x: -x[1]):
  76. print(f' {n:>5} {l2}')
  77. def cmd_subtree(data: dict, prefix: str, max_alias: int = 6):
  78. """返回某子树的全部叶子 + 部分 alias."""
  79. if not prefix.startswith('/'):
  80. prefix = '/' + prefix
  81. keys = sorted([k for k in data.keys() if k.startswith(prefix)])
  82. if not keys:
  83. print(f'(空) 没有路径以 {prefix} 开头')
  84. return
  85. print(f'{prefix} 下 {len(keys)} 条叶子路径:')
  86. print()
  87. for k in keys:
  88. aliases = data[k]
  89. print(f' {k}')
  90. if aliases:
  91. shown = aliases[:max_alias]
  92. tail = f' ... (+{len(aliases) - max_alias})' if len(aliases) > max_alias else ''
  93. print(f' └─ {" / ".join(shown)}{tail}')
  94. def _split_tokens(query: str) -> list:
  95. """拆 query 为有效 token: 按空白切, 过滤掉单字 (单字噪声过大).
  96. 全是单字的退化 query 会走 fallback: 用整个 query 当单 token 跑.
  97. 这样 `'特写'` 这种单 token 短查询行为跟旧版完全一致.
  98. """
  99. raw = [t for t in query.split() if t]
  100. tokens = [t for t in raw if len(t) >= 2]
  101. if not tokens and query.strip():
  102. tokens = [query.strip()]
  103. return tokens
  104. def _score_for_token(path: str, aliases: list, token: str):
  105. """单 token 打分 (原 cmd_match 内联逻辑, 抽成独立函数复用).
  106. Returns:
  107. (score: int, matched_aliases: list[str])
  108. """
  109. score = 0
  110. matched_aliases = []
  111. tlow = token.lower()
  112. # 路径名匹配 (优先级低)
  113. for part in path.split('/'):
  114. if not part:
  115. continue
  116. if token == part:
  117. score += 30
  118. elif token in part:
  119. score += 10
  120. elif tlow in part.lower():
  121. score += 5
  122. # alias 匹配 (优先级高)
  123. for alias in aliases:
  124. if token == alias:
  125. score += 50
  126. matched_aliases.append(alias)
  127. elif token in alias:
  128. score += 20
  129. matched_aliases.append(alias)
  130. elif tlow in alias.lower():
  131. score += 5
  132. matched_aliases.append(alias)
  133. return score, matched_aliases
  134. def cmd_match(data: dict, query: str, top: int = 10):
  135. """字符串匹配 (无 LLM): 多 token 拆词 → 各自打分 → 聚合排序.
  136. 单 token (无空白) 行为跟旧版完全一致.
  137. 多 token (含空白) 拆词后聚合: 每 path 累加每 token 的 score, 命中 ≥2 token
  138. 给 coverage bonus (避免单 token 大量重复打高分压死多 token 弱命中).
  139. """
  140. tokens = _split_tokens(query)
  141. if not tokens:
  142. print(f'(空 query)')
  143. return
  144. if len(tokens) == 1:
  145. # 单 token: 原行为
  146. candidates = []
  147. single = tokens[0]
  148. for path, aliases in data.items():
  149. score, matched = _score_for_token(path, aliases, single)
  150. if score > 0:
  151. candidates.append((score, path, matched or aliases[:3], [single]))
  152. candidates.sort(key=lambda x: (-x[0], len(x[1])))
  153. _print_candidates(query, candidates, top, tokens=tokens)
  154. return
  155. # 多 token: 聚合
  156. agg = {} # path -> {score, matched_aliases, matched_tokens, aliases}
  157. for tok in tokens:
  158. for path, aliases in data.items():
  159. s, m = _score_for_token(path, aliases, tok)
  160. if s > 0:
  161. entry = agg.setdefault(path, {
  162. 'score': 0,
  163. 'matched_aliases': [],
  164. 'matched_tokens': set(),
  165. 'aliases': aliases,
  166. })
  167. entry['score'] += s
  168. for a in m:
  169. if a not in entry['matched_aliases']:
  170. entry['matched_aliases'].append(a)
  171. entry['matched_tokens'].add(tok)
  172. candidates = []
  173. for path, info in agg.items():
  174. cov = len(info['matched_tokens'])
  175. score = info['score']
  176. if cov >= 2:
  177. score = int(score * (1 + 0.3 * (cov - 1))) # 多 token 覆盖 bonus
  178. ms = info['matched_aliases'] or info['aliases'][:3]
  179. candidates.append((score, path, ms, sorted(info['matched_tokens'])))
  180. candidates.sort(key=lambda x: (-x[0], len(x[1])))
  181. _print_candidates(query, candidates, top, tokens=tokens)
  182. def _print_candidates(query: str, candidates: list, top: int, tokens: list = None):
  183. """match 结果统一打印."""
  184. if not candidates:
  185. prefix = f'(无匹配) 查询: {query!r}'
  186. if tokens and len(tokens) > 1:
  187. prefix += f' 拆词={tokens}'
  188. print(prefix)
  189. print(' 提示: 试 --narrow <query> 走层级下钻 (按子树整体语义打分, 容忍 alias 完全没覆盖).')
  190. return
  191. header = f'查询: {query!r} — 共 {len(candidates)} 候选, top {min(top, len(candidates))}:'
  192. if tokens and len(tokens) > 1:
  193. header = f'查询: {query!r} (拆 {len(tokens)} token: {tokens}) — 共 {len(candidates)} 候选, top {min(top, len(candidates))}:'
  194. print(header)
  195. print()
  196. for score, path, ms, toks in candidates[:top]:
  197. ax = ' / '.join(ms[:5])
  198. tok_info = f' [命中 token: {",".join(toks)}]' if toks and len(toks) > 1 else ''
  199. print(f' [score {score:>3}] {path}{tok_info}')
  200. if ax:
  201. print(f' └─ {ax}')
  202. def cmd_narrow(data: dict, query: str, depth: int = 3, top_per_level: int = 3):
  203. """层级化下钻: 从 L2 起按子树整体语义打分, 每层选 top-K 下钻直到 leaf 或 depth 上限.
  204. 用于扁平 --match 完全 miss 时的兜底: alias 没覆盖但路径段语义沾边的 case
  205. (e.g. query 'AI生成 提示词' 在 alias 里可能无, 但 `/理念/方法/生成式` 路径段语义对).
  206. 打分: 子树内所有 path + alias 文本里 token 命中次数 (hits) + 命中的不同 token 数 (cov).
  207. 排序: 先按 cov 高优先 (覆盖多 token 的子树更对路), 再按 hits.
  208. """
  209. tokens = _split_tokens(query)
  210. if not tokens:
  211. print('(空 query)')
  212. return
  213. def score_subtree(prefix: str):
  214. """返回 (hits, covered_token_count) — prefix 子树整体语义信号强度."""
  215. hits = 0
  216. covered = set()
  217. for path, aliases in data.items():
  218. if not (path == prefix or path.startswith(prefix + '/')):
  219. continue
  220. text = (path + ' ' + ' '.join(aliases)).lower()
  221. for tok in tokens:
  222. t = tok.lower()
  223. if t in text:
  224. hits += text.count(t)
  225. covered.add(tok)
  226. return hits, len(covered)
  227. def n_descendants(prefix: str) -> int:
  228. return sum(1 for k in data if k == prefix or k.startswith(prefix + '/'))
  229. print(f'层级下钻: query={query!r} tokens={tokens} depth={depth} top_per_level={top_per_level}')
  230. print()
  231. # 起点: 所有 L2 (路径前两段)
  232. l2_set = sorted({'/' + '/'.join(k.split('/')[1:3]) for k in data if k.count('/') >= 2})
  233. scored = [(p, *score_subtree(p)) for p in l2_set]
  234. scored = [c for c in scored if c[1] > 0]
  235. scored.sort(key=lambda x: (-x[2], -x[1])) # 先 covered 后 hits
  236. current = scored[:top_per_level]
  237. if not current:
  238. print('[L2] 所有 L2 子树都无任何 token 命中. 词表可能无相关语义, 建议换 query.')
  239. return
  240. print(f'[L2] 候选 {len(scored)} 个, 取 top {len(current)}:')
  241. for prefix, hits, cov in current:
  242. leaf_mark = ' ★叶子' if prefix in data else ''
  243. print(f' [hits {hits:>4} / cov {cov}/{len(tokens)}] {prefix}{leaf_mark} ({n_descendants(prefix)} 子节点)')
  244. print()
  245. # 逐层下钻
  246. for level_i in range(depth):
  247. next_set = set()
  248. for prefix, _, _ in current:
  249. for k in data:
  250. if k.startswith(prefix + '/'):
  251. rest = k[len(prefix) + 1:].split('/')
  252. if rest[0]:
  253. next_set.add(prefix + '/' + rest[0])
  254. if not next_set:
  255. print(f'[L{3 + level_i}] (已到叶子层, 停止下钻)')
  256. break
  257. next_scored = [(p, *score_subtree(p)) for p in next_set]
  258. next_scored = [c for c in next_scored if c[1] > 0]
  259. next_scored.sort(key=lambda x: (-x[2], -x[1]))
  260. if not next_scored:
  261. print(f'[L{3 + level_i}] (无任何子节点命中, 停止下钻)')
  262. break
  263. current = next_scored[:top_per_level]
  264. print(f'[L{3 + level_i}] 候选 {len(next_scored)} 个, 取 top {len(current)}:')
  265. for prefix, hits, cov in current:
  266. leaf_mark = ' ★叶子' if prefix in data else ''
  267. print(f' [hits {hits:>4} / cov {cov}/{len(tokens)}] {prefix}{leaf_mark} ({n_descendants(prefix)} 子节点)')
  268. print()
  269. print('提示: 末层 top 候选若仍非叶子, 用 --subtree <path> 列叶子, 再 --validate <leaf> 确认.')
  270. def cmd_validate(data: dict, path: str) -> bool:
  271. """校验路径是否在词表中; 路径不存在但是 ancestor 时报 PARTIAL."""
  272. if not path.startswith('/'):
  273. path = '/' + path
  274. if path in data:
  275. n = len(data[path])
  276. print(f'OK {path} ({n} aliases)')
  277. return True
  278. descendants = [k for k in data.keys() if k.startswith(path + '/')]
  279. if descendants:
  280. print(f'PARTIAL {path} 不是叶子, 是 ancestor — 含 {len(descendants)} 条后代路径')
  281. for d in descendants[:5]:
  282. print(f' · {d}')
  283. if len(descendants) > 5:
  284. print(f' ... (+{len(descendants) - 5})')
  285. return False
  286. print(f'MISS {path} (词表中不存在)')
  287. return False
  288. def main():
  289. p = argparse.ArgumentParser(
  290. description='实质/形式 外部 JSON 词表查询',
  291. formatter_class=argparse.RawDescriptionHelpFormatter,
  292. epilog=__doc__,
  293. )
  294. p.add_argument('--dim', required=True, choices=['实质', '形式'],
  295. help='词表维度')
  296. g = p.add_mutually_exclusive_group(required=True)
  297. g.add_argument('--list-l2', action='store_true',
  298. help='列二级路径 + 路径数')
  299. g.add_argument('--subtree', metavar='PATH',
  300. help='返回某子树的全部叶子 + alias')
  301. g.add_argument('--match', metavar='QUERY',
  302. help='匹配候选路径 (多 token 自动拆词聚合; 单 token 行为同旧版)')
  303. g.add_argument('--narrow', metavar='QUERY',
  304. help='层级化下钻 (按子树语义信号打分逐层选 top-K, 兜底 --match miss)')
  305. g.add_argument('--validate', metavar='PATH',
  306. help='校验路径是否在词表中')
  307. p.add_argument('--top', type=int, default=10,
  308. help='match 输出 top N (default: 10)')
  309. p.add_argument('--max-alias', type=int, default=6,
  310. help='subtree 每条路径展示 alias 数 (default: 6)')
  311. p.add_argument('--depth', type=int, default=3,
  312. help='narrow 下钻层数 (default: 3, 即 L2→L3→L4→L5)')
  313. p.add_argument('--top-per-level', type=int, default=3,
  314. help='narrow 每层保留候选数 (default: 3)')
  315. args = p.parse_args()
  316. try:
  317. data = load(args.dim)
  318. except FileNotFoundError as e:
  319. print(f'ERROR: {e}', file=sys.stderr)
  320. sys.exit(2)
  321. if args.list_l2:
  322. cmd_list_l2(data)
  323. elif args.subtree:
  324. cmd_subtree(data, args.subtree, max_alias=args.max_alias)
  325. elif args.match:
  326. cmd_match(data, args.match, top=args.top)
  327. elif args.narrow:
  328. cmd_narrow(data, args.narrow, depth=args.depth, top_per_level=args.top_per_level)
  329. elif args.validate:
  330. sys.exit(0 if cmd_validate(data, args.validate) else 1)
  331. if __name__ == '__main__':
  332. main()