@hupan56/wlkj 3.1.31 → 3.2.0

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 (70) hide show
  1. package/package.json +1 -1
  2. package/templates/qoder/agents/insight-planning.md +67 -67
  3. package/templates/qoder/agents/prd-reference.md +47 -47
  4. package/templates/qoder/commands/optional/wl-insight.md +276 -276
  5. package/templates/qoder/commands/optional/wl-spec.md +10 -2
  6. package/templates/qoder/commands/wl-code.md +63 -4
  7. package/templates/qoder/commands/wl-design.md +1 -1
  8. package/templates/qoder/commands/wl-prd.md +87 -13
  9. package/templates/qoder/commands/wl-req.md +10 -3
  10. package/templates/qoder/commands/wl-task.md +613 -613
  11. package/templates/qoder/commands/wl-test.md +2 -2
  12. package/templates/qoder/contracts/CHANGELOG.md +418 -0
  13. package/templates/qoder/contracts/README.md +180 -0
  14. package/templates/qoder/contracts/code.md +82 -0
  15. package/templates/qoder/contracts/commit.md +86 -0
  16. package/templates/qoder/contracts/contract-header.md +76 -0
  17. package/templates/qoder/contracts/design.md +106 -0
  18. package/templates/qoder/contracts/fallback.md +126 -0
  19. package/templates/qoder/contracts/isolation.md +119 -0
  20. package/templates/qoder/contracts/prd.md +118 -0
  21. package/templates/qoder/contracts/schemas/design-spec.schema.json +46 -0
  22. package/templates/qoder/contracts/schemas/prd.schema.json +36 -0
  23. package/templates/qoder/contracts/schemas/test-cases.schema.json +40 -0
  24. package/templates/qoder/contracts/spec.md +112 -0
  25. package/templates/qoder/contracts/task.md +125 -0
  26. package/templates/qoder/contracts/test.md +112 -0
  27. package/templates/qoder/hooks/post-tool-use.py +20 -0
  28. package/templates/qoder/hooks/stop-eval.py +47 -0
  29. package/templates/qoder/rules/wl-pipeline.md +37 -0
  30. package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +11 -0
  31. package/templates/qoder/scripts/deployment/setup/setup.py +70 -0
  32. package/templates/qoder/scripts/deployment/setup/wlkj_shim.py +104 -0
  33. package/templates/qoder/scripts/domain/deployment/deploy_to_test.py +298 -0
  34. package/templates/qoder/scripts/domain/kg/build/kg_build.py +241 -22
  35. package/templates/qoder/scripts/domain/kg/build/kg_incremental.py +108 -3
  36. package/templates/qoder/scripts/domain/kg/build/kg_signatures.py +169 -0
  37. package/templates/qoder/scripts/domain/kg/extract/ts_extract.py +111 -0
  38. package/templates/qoder/scripts/domain/kg/storage/kg_duckdb.py +43 -0
  39. package/templates/qoder/scripts/domain/requirement/req.py +134 -28
  40. package/templates/qoder/scripts/domain/task/zentao_panel.py +688 -53
  41. package/templates/qoder/scripts/foundation/core/paths.py +102 -0
  42. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +23 -10
  43. package/templates/qoder/scripts/validation/eval/qwork_harness.py +1 -1
  44. package/templates/qoder/scripts/validation/eval/report-commands.md +2 -2
  45. package/templates/qoder/settings.json +27 -9
  46. package/templates/qoder/skills/design-import/SKILL.md +3 -3
  47. package/templates/qoder/skills/design-review/SKILL.md +1 -1
  48. package/templates/qoder/skills/prd-generator/SKILL.md +4 -4
  49. package/templates/qoder/skills/prd-review/SKILL.md +1 -1
  50. package/templates/qoder/skills/prototype-generator/SKILL.md +3 -3
  51. package/templates/qoder/skills/spec-coder/SKILL.md +1 -1
  52. package/templates/qoder/skills/spec-generator/SKILL.md +80 -23
  53. package/templates/qoder/skills/test-generator/SKILL.md +1 -1
  54. package/templates/qoder/skills/wl-code/SKILL.md +13 -1
  55. package/templates/qoder/skills/wl-commit/SKILL.md +1 -1
  56. package/templates/qoder/skills/wl-design/SKILL.md +6 -6
  57. package/templates/qoder/skills/wl-init/SKILL.md +2 -2
  58. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  59. package/templates/qoder/skills/wl-prd/SKILL.md +60 -0
  60. package/templates/qoder/skills/wl-report/SKILL.md +2 -2
  61. package/templates/qoder/skills/wl-search/SKILL.md +1 -1
  62. package/templates/qoder/skills/wl-spec/SKILL.md +2 -2
  63. package/templates/qoder/skills/wl-status/SKILL.md +2 -2
  64. package/templates/qoder/skills/wl-task/SKILL.md +3 -3
  65. package/templates/qoder/skills/wl-test/SKILL.md +2 -2
  66. package/templates/qoder/templates/spec-template.md +124 -0
  67. package/templates/root/AGENTS.md +32 -4
  68. package/templates/qoder/skills/wl-prd-full/SKILL.md +0 -121
  69. package/templates/qoder/skills/wl-prd-quick/SKILL.md +0 -50
  70. package/templates/qoder/skills/wl-prd-review/SKILL.md +0 -47
@@ -107,17 +107,23 @@ def _paged_list(client, path, params, list_key, page_size=100, max_pages=20):
107
107
 
108
108
 
109
109
  def _collect_my_tasks(client, my_account, status_filter=None):
110
- """扫所有执行, 收集指派给我的任务。返回 (任务列表, None) 或 (None, 错误)。"""
110
+ """扫所有执行, 收集指派给我的任务。返回 (任务列表, None) 或 (None, 错误)。
111
+
112
+ 性能: 用线程池并行请求各 execution (61 个执行串行要 ~15s, 并行后 ~2-3s)。
113
+ ZentaoClient 内部 token 有锁但 HTTP 请求本身是 IO, 线程并行安全。
114
+ """
115
+ from concurrent.futures import ThreadPoolExecutor
111
116
  execs, _ = _paged_list(client, '/executions', None, 'executions')
112
117
  if execs is None:
113
118
  return None, '查询执行列表失败, 稍后重试。'
114
- all_t = []
115
- for e in execs:
119
+
120
+ def fetch_one(e):
116
121
  eid = e.get('id')
117
122
  if eid is None:
118
- continue
123
+ return []
119
124
  ename = str(e.get('name', ''))[:20]
120
125
  tasks, _ = _paged_list(client, '/executions/%s/tasks' % eid, None, 'tasks')
126
+ out = []
121
127
  for t in tasks:
122
128
  if not isinstance(t, dict):
123
129
  continue
@@ -128,49 +134,89 @@ def _collect_my_tasks(client, my_account, status_filter=None):
128
134
  t2 = dict(t)
129
135
  t2['execution'] = eid
130
136
  t2['execution_name'] = ename
131
- all_t.append(t2)
137
+ out.append(t2)
138
+ return out
139
+
140
+ all_t = []
141
+ with ThreadPoolExecutor(max_workers=10) as pool:
142
+ for batch in pool.map(fetch_one, execs):
143
+ all_t.extend(batch)
132
144
  return all_t, None
133
145
 
134
146
 
147
+ def _scan_products(client):
148
+ """获取要扫描的产品 ID 列表。
149
+
150
+ 关键修复: 禅道 /products 接口实测会漏产品 (团队踩坑: product=3 "ICS 2.0" 真实存在
151
+ 且含 33 个 Bug, 但 /products 列表不返回它 → 逐产品扫描会漏掉全部这些 Bug)。
152
+ 所以这里采用「列表 + 连续 ID 补扫」双保险:
153
+ 1. 先取 /products 列表 (官方口径);
154
+ 2. 再补扫 1..MAX_PRODUCT_ID 范围里列表没返回但真实存在的 (用 /products/{id} 探活)。
155
+ """
156
+ listed, _ = _paged_list(client, '/products', None, 'products')
157
+ listed_ids = set(p.get('id') for p in listed if isinstance(p, dict) and p.get('id') is not None)
158
+ # 补扫: 列表没覆盖的连续 ID 段 (团队产品数实测 < 20, 给到 30 留余量)
159
+ MAX_PID = 30
160
+ for pid in range(1, MAX_PID + 1):
161
+ if pid in listed_ids:
162
+ continue
163
+ r = client.api('GET', '/products/%s' % pid)
164
+ if r.status_code == 200 and r.json().get('id'): # 真实存在
165
+ listed_ids.add(pid)
166
+ return sorted(listed_ids)
167
+
168
+
135
169
  def _collect_my_bugs(client, my_account):
136
- """扫活跃产品, 收集我的 Bug (指派给我 OR 我提的)。"""
137
- prods, _ = _paged_list(client, '/products', None, 'products')
138
- mine = []
139
- for p in prods:
140
- pid = p.get('id')
170
+ """扫所有产品 (含 /products 列表漏掉的), 收集**指派给我**的 Bug (并行)。
171
+
172
+ v0.5 修复: 旧版只扫 /products 列表返回的产品, 漏掉了 product=3 (列表不返回但含
173
+ 3 个我的 Bug)。改用 _scan_products 双保险覆盖所有真实产品。
174
+ """
175
+ from concurrent.futures import ThreadPoolExecutor
176
+ pids = _scan_products(client)
177
+
178
+ def fetch_one(pid):
141
179
  bugs, _ = _paged_list(client, '/products/%s/bugs' % pid, None, 'bugs')
180
+ out = []
142
181
  for b in bugs:
143
182
  if not isinstance(b, dict):
144
183
  continue
145
- asg = ZentaoClient.assignee_account(b)
146
- opn = ZentaoClient.openedby_account(b)
147
- why = '指派给我' if asg == my_account else ('我提的' if opn == my_account else None)
148
- if why:
149
- b2 = dict(b)
150
- b2['product_id'] = pid
151
- b2['_why'] = why
152
- mine.append(b2)
184
+ if ZentaoClient.assignee_account(b) != my_account:
185
+ continue
186
+ b2 = dict(b)
187
+ b2['product_id'] = pid
188
+ out.append(b2)
189
+ return out
190
+
191
+ mine = []
192
+ with ThreadPoolExecutor(max_workers=10) as pool:
193
+ for batch in pool.map(fetch_one, pids):
194
+ mine.extend(batch)
153
195
  return mine, None
154
196
 
155
197
 
156
198
  def _collect_my_stories(client, my_account):
157
- """扫活跃产品, 收集我的需求 (指派给我 OR 我提的)。"""
158
- prods, _ = _paged_list(client, '/products', None, 'products')
159
- mine = []
160
- for p in prods:
161
- pid = p.get('id')
199
+ """扫所有产品 ( /products 列表漏掉的), 收集**指派给我**的需求 (并行)。"""
200
+ from concurrent.futures import ThreadPoolExecutor
201
+ pids = _scan_products(client)
202
+
203
+ def fetch_one(pid):
162
204
  sts, _ = _paged_list(client, '/products/%s/stories' % pid, None, 'stories')
205
+ out = []
163
206
  for s in sts:
164
207
  if not isinstance(s, dict):
165
208
  continue
166
- asg = ZentaoClient.assignee_account(s)
167
- opn = ZentaoClient.openedby_account(s)
168
- why = '指派给我' if asg == my_account else ('我提的' if opn == my_account else None)
169
- if why:
170
- s2 = dict(s)
171
- s2['product_id'] = pid
172
- s2['_why'] = why
173
- mine.append(s2)
209
+ if ZentaoClient.assignee_account(s) != my_account:
210
+ continue
211
+ s2 = dict(s)
212
+ s2['product_id'] = pid
213
+ out.append(s2)
214
+ return out
215
+
216
+ mine = []
217
+ with ThreadPoolExecutor(max_workers=10) as pool:
218
+ for batch in pool.map(fetch_one, pids):
219
+ mine.extend(batch)
174
220
  return mine, None
175
221
 
176
222
 
@@ -178,6 +224,73 @@ def _collect_my_stories(client, my_account):
178
224
  # 归一化: 把禅道原始 dict 裁成面板需要的精简字段 (减体积 + 隐藏内部字段)
179
225
  # ============================================================
180
226
 
227
+ def _detect_code_committed(item_id, title_keywords):
228
+ """检测代码是否已提交 (git log 搜任务ID或标题关键词)。"""
229
+ import subprocess
230
+ try:
231
+ # 搜任务ID 或 标题前几个关键词
232
+ patterns = ['#%s' % item_id] + [k for k in title_keywords if len(k) >= 2][:2]
233
+ for p in patterns:
234
+ r = subprocess.run(
235
+ ['git', 'log', '--oneline', '--all', '-1', '--grep=%s' % p],
236
+ capture_output=True, text=True, timeout=5,
237
+ cwd=str(_SCRIPTS.parent) if hasattr(_SCRIPTS, 'parent') else None)
238
+ if r.returncode == 0 and r.stdout.strip():
239
+ return True
240
+ except Exception:
241
+ pass
242
+ return False
243
+
244
+
245
+ def _detect_progress(client, kind, item_id, raw):
246
+ """检测工作流进度: PRD / Spec / 代码 三节点。
247
+
248
+ 返回 {'prd': bool, 'spec': str('none'|'draft'|'confirmed'), 'code': bool}
249
+ - PRD: task 看关联需求spec非空; bug 看steps非空; story 看自身spec非空
250
+ - Spec: find_spec 找到文件, 读 status
251
+ - 代码: git log 搜任务ID/关键词
252
+ """
253
+ import re as _re
254
+ from foundation.core.paths import find_spec
255
+ progress = {'prd': False, 'spec': 'none', 'code': False}
256
+
257
+ # PRD 检测
258
+ try:
259
+ if kind == 'task':
260
+ sid = raw.get('story')
261
+ if sid:
262
+ sr = client.api('GET', '/stories/%s' % sid)
263
+ if sr.status_code == 200:
264
+ spec = _clean_html(sr.json().get('spec', ''))
265
+ progress['prd'] = bool(spec.strip())
266
+ elif kind == 'bug':
267
+ steps = _clean_html(raw.get('steps', ''))
268
+ progress['prd'] = bool(steps.strip())
269
+ elif kind == 'story':
270
+ spec = _clean_html(raw.get('spec', ''))
271
+ progress['prd'] = bool(spec.strip())
272
+ except Exception:
273
+ pass
274
+
275
+ # Spec 检测 (find_spec 需要 REQ-ID, 但禅道任务没有 REQ-ID, 用任务ID兜底)
276
+ try:
277
+ # 禅道任务没有 REQ-ID, find_spec 找不到 → 先查当前任务目录的 spec.md
278
+ # (大多数情况 spec 在任务目录, 不在全局 specs/)
279
+ hits = find_spec() # 查当前任务目录
280
+ if hits:
281
+ progress['spec'] = hits[0].get('status', 'draft')
282
+ except Exception:
283
+ pass
284
+
285
+ # 代码检测 (git log)
286
+ title = str(raw.get('name', '') or raw.get('title', ''))
287
+ keywords = _re.split(r'[^\u4e00-\u9fa5a-zA-Z0-9]+', title)
288
+ keywords = [k for k in keywords if len(k) >= 2][:3]
289
+ progress['code'] = _detect_code_committed(item_id, keywords)
290
+
291
+ return progress
292
+
293
+
181
294
  def _norm_task(t):
182
295
  return {
183
296
  'id': t.get('id'),
@@ -190,6 +303,7 @@ def _norm_task(t):
190
303
  'deadline': t.get('deadline'),
191
304
  'execution': t.get('execution'),
192
305
  'execution_name': t.get('execution_name', ''),
306
+ 'story': t.get('story'), # 关联需求ID (进度检测用)
193
307
  }
194
308
 
195
309
 
@@ -200,7 +314,6 @@ def _norm_bug(b):
200
314
  'status': b.get('status', ''),
201
315
  'severity': b.get('severity'),
202
316
  'pri': b.get('pri'),
203
- 'why': b.get('_why', ''), # 指派给我 / 我提的
204
317
  }
205
318
 
206
319
 
@@ -210,7 +323,6 @@ def _norm_story(s):
210
323
  'title': str(s.get('title', '')),
211
324
  'status': s.get('status', ''),
212
325
  'pri': s.get('pri'),
213
- 'why': s.get('_why', ''),
214
326
  }
215
327
 
216
328
 
@@ -268,25 +380,80 @@ def fetch_workbench(include_closed=False, kind=None):
268
380
  if not my_account:
269
381
  return {'ok': False, 'error': err}, 3
270
382
 
271
- # ④ 收集 (按 kind 决定取哪几域)
383
+ # ④ 收集 (三域并行, 按 kind 决定取哪几域)
384
+ from concurrent.futures import ThreadPoolExecutor
272
385
  tasks, bugs, stories = [], [], []
273
- if kind in (None, 'tasks'):
274
- tasks, e = _collect_my_tasks(client, my_account)
386
+
387
+ def _gather():
388
+ jobs = {}
389
+ if kind in (None, 'tasks'):
390
+ jobs['tasks'] = lambda: _collect_my_tasks(client, my_account)
391
+ if kind in (None, 'bugs'):
392
+ jobs['bugs'] = lambda: _collect_my_bugs(client, my_account)
393
+ if kind in (None, 'stories'):
394
+ jobs['stories'] = lambda: _collect_my_stories(client, my_account)
395
+ results = {}
396
+ with ThreadPoolExecutor(max_workers=3) as pool:
397
+ futs = {pool.submit(fn): k for k, fn in jobs.items()}
398
+ for fut in futs:
399
+ results[futs[fut]] = fut.result()
400
+ return results
401
+
402
+ res = _gather()
403
+ if 'tasks' in res:
404
+ tasks, e = res['tasks']
275
405
  if e:
276
406
  return {'ok': False, 'error': e}, 5
277
- if kind in (None, 'bugs'):
278
- bugs, _ = _collect_my_bugs(client, my_account)
279
- if kind in (None, 'stories'):
280
- stories, _ = _collect_my_stories(client, my_account)
407
+ if 'bugs' in res:
408
+ bugs, _ = res['bugs']
409
+ if 'stories' in res:
410
+ stories, _ = res['stories']
281
411
 
282
- # ⑤ 过滤 + 归一
412
+ # ⑤ 过滤 + 归一 + 进度检测
283
413
  tasks, bugs, stories = _filter_live(tasks, bugs, stories, include_closed)
414
+
415
+ # 进度检测 (并行, 只对前12个检测避免过多请求)
416
+ from concurrent.futures import ThreadPoolExecutor
417
+
418
+ def _add_progress(kind, items, raws):
419
+ if not items:
420
+ return
421
+ # raws 和 items 按顺序对应 (都来自同一批过滤后数据)
422
+ def detect_one(idx):
423
+ try:
424
+ p = _detect_progress(client, kind, items[idx]['id'], raws[idx])
425
+ items[idx]['progress'] = p
426
+ except Exception:
427
+ items[idx]['progress'] = {'prd': False, 'spec': 'none', 'code': False}
428
+ with ThreadPoolExecutor(max_workers=6) as pool:
429
+ list(pool.map(detect_one, range(min(len(items), 12))))
430
+ # 超过12个的不检测, 给默认值
431
+ for i in range(12, len(items)):
432
+ items[i]['progress'] = {'prd': False, 'spec': 'none', 'code': False}
433
+
434
+ norm_tasks = [_norm_task(t) for t in tasks]
435
+ norm_bugs = [_norm_bug(b) for b in bugs]
436
+ norm_stories = [_norm_story(s) for s in stories]
437
+ _add_progress('task', norm_tasks, tasks)
438
+ _add_progress('bug', norm_bugs, bugs)
439
+
440
+ # 当前任务 (工作流的 .current-task 机制)
441
+ current_task = None
442
+ try:
443
+ from foundation.core.paths import get_current_task
444
+ ct = get_current_task()
445
+ if ct:
446
+ current_task = str(ct)
447
+ except Exception:
448
+ pass
449
+
284
450
  data = {
285
451
  'account': my_account,
286
452
  'developer': developer,
287
- 'tasks': [_norm_task(t) for t in tasks],
288
- 'bugs': [_norm_bug(b) for b in bugs],
289
- 'stories': [_norm_story(s) for s in stories],
453
+ 'current_task': current_task,
454
+ 'tasks': norm_tasks,
455
+ 'bugs': norm_bugs,
456
+ 'stories': norm_stories,
290
457
  }
291
458
  meta = {
292
459
  'fetched_at': datetime.now().isoformat(timespec='seconds'),
@@ -354,7 +521,7 @@ def _do_action(client, my_account, action, item_id, params):
354
521
  if guard:
355
522
  return {'ok': False, 'error': guard}, 4
356
523
 
357
- # ② 拼 payload (只取该操作关心的字段, 缺省给合理默认)
524
+ # ② 拼 payload (只取该操作关心的字段)
358
525
  payload = {}
359
526
  for pkey, fkey in fields.items():
360
527
  v = params.get(fkey)
@@ -363,11 +530,35 @@ def _do_action(client, my_account, action, item_id, params):
363
530
  payload[pkey] = float(v) if fkey in ('left', 'consumed') else v
364
531
  except (ValueError, TypeError):
365
532
  payload[pkey] = v
366
- # finish 强制 left=0 (完成=没剩余工时); log_effort 的 consumed 必填兜底
367
- if action == 'finish_task' and 'left' not in payload:
368
- payload['left'] = 0
369
- if action == 'resolve_bug' and 'resolution' not in payload:
533
+
534
+ # 各操作的必填字段兜底 (实测禅道校验)
535
+ today = datetime.now().strftime('%Y-%m-%d')
536
+ if action == 'finish_task':
537
+ # 实测: finish 必须带 finishedDate ("实际完成"日期), 否则 400
538
+ payload.setdefault('left', 0)
539
+ payload.setdefault('finishedDate', today)
540
+ elif action == 'start_task':
541
+ # 实测禅道: start 不允许 "总计消耗=0 且 预计剩余=0" (报 400)。
542
+ # 策略: 用户填了 left 就用; 没填则查任务的 estimate(预计工时) 兜底;
543
+ # estimate 也没有 → 返回友好错误让用户填, 不传 0 撞 400。
544
+ if 'left' not in payload or payload.get('left') == 0:
545
+ # 查任务详情拿 estimate
546
+ try:
547
+ td = client.api('GET', '/tasks/%s' % item_id).json()
548
+ est = td.get('estimate') or 0
549
+ except Exception:
550
+ est = 0
551
+ if est and est > 0:
552
+ payload['left'] = float(est)
553
+ elif not payload.get('consumed'):
554
+ return {'ok': False, 'error': '开始任务需要填剩余工时(禅道不允许剩余=0)。请在弹框里填一个预估工时,如 8。'}, 3
555
+ elif action == 'log_effort' and 'consumed' not in payload:
556
+ return {'ok': False, 'error': '记工时必须填消耗工时。'}, 3
557
+ elif action == 'resolve_bug' and 'resolution' not in payload:
370
558
  payload['resolution'] = 'fixed'
559
+ elif action == 'confirm_bug':
560
+ # 确认Bug 部分禅道版本要 assignedTo, 确保认领到自己头上
561
+ payload.setdefault('assignedTo', my_account)
371
562
 
372
563
  # ③ 调禅道
373
564
  r = client.api(method, path, payload)
@@ -400,12 +591,413 @@ def perform_action(action, item_id, params=None):
400
591
  return _do_action(client, my_account, action, item_id, params)
401
592
 
402
593
 
594
+ # ============================================================
595
+ # 提示词增强 (v0.6): 发AI前用知识图谱拉相关代码/API/字段
596
+ # 策略: 调 context_pack.py 拿结构化上下文 → 拼进提示词。
597
+ # 查不到/超时 → 静默降级返回基础提示词 (永远不阻塞「发AI」)。
598
+ # ============================================================
599
+
600
+ # 停用词: 任务标题里这些词不该拿去查知识图谱 (噪音太大)
601
+ _STOPWORDS = set('的 了 和 与 或 及 及 以 在 为 对 从 给 把 被 让 用 : : ( ) ( ) - — , , . 。 / 新增 修改 优化 问题 异常 无法 不能 报错'.split())
602
+
603
+
604
+ def _extract_keywords(title):
605
+ """从任务/Bug 标题提取业务关键词。
606
+
607
+ 策略: 按 、,。:等分隔符切块 → 去停用词 → 长串按2-4字滑窗拆出业务词。
608
+ 例: "车辆年检、保险延期终止申请关联异常生成优化"
609
+ → ['车辆年检','年检','保险','延期','终止','申请','关联','异常','生成']
610
+ """
611
+ if not title:
612
+ return []
613
+ import re
614
+ # 去掉 #id、数字编号、【】
615
+ clean = re.sub(r'#\d+|【.*?】|\d{4,}', ' ', title)
616
+ # 按标点/符号切分成短语块
617
+ chunks = re.split(r'[^\u4e00-\u9fa5a-zA-Z0-9]+', clean)
618
+ kws = []
619
+ for chunk in chunks:
620
+ chunk = chunk.strip()
621
+ if not chunk or chunk in _STOPWORDS or chunk.isdigit():
622
+ continue
623
+ if len(chunk) <= 4:
624
+ # 短块直接用 (如 "保险" "年检")
625
+ kws.append(chunk)
626
+ elif re.match(r'^[\u4e00-\u9fa5]+$', chunk):
627
+ # 纯中文长块: 2-4字滑窗拆出所有业务词 (如"保险延期终止"→保险/险延/延期/期终/终止)
628
+ # 但只保留 2-4 字的常见业务词长度, 去掉无意义组合
629
+ for size in (2, 3, 4):
630
+ for i in range(len(chunk) - size + 1):
631
+ w = chunk[i:i+size]
632
+ if w not in _STOPWORDS:
633
+ kws.append(w)
634
+ else:
635
+ kws.append(chunk)
636
+ # 去重保序
637
+ seen, out = set(), []
638
+ for k in kws:
639
+ if k not in seen:
640
+ seen.add(k)
641
+ out.append(k)
642
+ return out[:6] # 多取一些, 合并查询时去重
643
+
644
+
645
+ # 噪音文件: 这些通用文件命中了也不该推荐 (路由表/测试入口/通用配置, 不是业务实现)
646
+ _NOISE_PATTERNS = [
647
+ '/router/routes/', 'module/mList/', 'module/ips/main.vue',
648
+ '/common/core/exception/', 'enum', 'constant.java', 'core.ts',
649
+ 'Application.java', 'main.vue', 'List.vue', 'index.vue',
650
+ ]
651
+
652
+
653
+ def _is_noise(filepath):
654
+ """判断代码文件是否是噪音 (路由/测试/通用文件, 非业务实现)。"""
655
+ fp = filepath.lower()
656
+ return any(p.lower() in fp for p in _NOISE_PATTERNS)
657
+
658
+
659
+ def _guess_platform(scene_name):
660
+ """从执行名/场景名推断 platform。
661
+ execution 名形如 '20260702后端'/'20260702前端'/'20260702移动端'。
662
+ 返回 'web'/'app' 或 None(后端代码不按端过滤)。
663
+ """
664
+ if not scene_name:
665
+ return None
666
+ if '前端' in scene_name or 'PC' in scene_name or '管理端' in scene_name:
667
+ return 'web'
668
+ if '移动端' in scene_name or 'APP' in scene_name.upper() or 'H5' in scene_name.upper():
669
+ return 'app'
670
+ return None # 后端: 知识图谱不过滤 platform (fywl-ics 后端代码)
671
+
672
+
673
+ def _query_kg_context(keyword, platform=None):
674
+ """调 context_pack.py 拉单个关键词的上下文。带超时, 失败返回空dict。
675
+ platform: 'web'/'app' 过滤代码库 (前端=fywl-ui, 移动端=Carmg-H5); None=不过滤(含后端)。
676
+ """
677
+ import subprocess
678
+ script = os.path.join(_SCRIPTS, 'domain', 'kg', 'search', 'context_pack.py')
679
+ if not os.path.isfile(script):
680
+ return {}
681
+ cmd = ['python', script, keyword]
682
+ if platform in ('web', 'app'):
683
+ cmd += ['--platform', platform]
684
+ try:
685
+ r = subprocess.run(
686
+ cmd, capture_output=True, text=True, timeout=15, encoding='utf-8', errors='replace')
687
+ return _parse_context_pack(r.stdout, keyword)
688
+ except subprocess.TimeoutExpired:
689
+ return {} # 知识图谱查询超时, 静默降级
690
+ except Exception:
691
+ return {}
692
+
693
+
694
+ def _parse_context_pack(text, keyword):
695
+ """从 context_pack 的文本输出里提取代码/API/字段三类信息 (精简版)。"""
696
+ if not text:
697
+ return {}
698
+ import re
699
+ result = {'code_files': [], 'apis': [], 'fields': []}
700
+ lines = text.split('\n')
701
+ section = None
702
+ for line in lines:
703
+ # 识别段落标题
704
+ if '相关代码文件' in line:
705
+ section = 'code'; continue
706
+ elif '相关 API' in line:
707
+ section = 'api'; continue
708
+ elif '相关字段' in line:
709
+ section = 'field'; continue
710
+ elif line.startswith('## '):
711
+ section = None; continue
712
+ # 提取条目
713
+ if not section:
714
+ continue
715
+ m = re.match(r'-\s+(?:\[[^\]]*\]\s*)?([^\s(]+)', line)
716
+ if not m:
717
+ continue
718
+ item = m.group(1).strip()
719
+ if section == 'code' and ('/' in item or '\\' in item):
720
+ if not _is_noise(item): # 过滤路由/测试/通用文件噪音
721
+ result['code_files'].append(item)
722
+ elif section == 'api' and item.startswith('/'):
723
+ if not _is_noise(item):
724
+ result['apis'].append(item)
725
+ elif section == 'field' and '->' in line:
726
+ result['fields'].append(line.strip('- ').strip())
727
+ # 精简: 代码取前8, API取前6, 字段取前6
728
+ result['code_files'] = result['code_files'][:8]
729
+ result['apis'] = result['apis'][:6]
730
+ result['fields'] = result['fields'][:6]
731
+ return result
732
+
733
+
734
+ def _query_kg_prefetch(title, platform=None):
735
+ """调 kg.py prefetch 一次性多词合并搜索 (知识图谱升级后的能力)。
736
+
737
+ 比 _query_kg_context(单关键词) 强: prefetch 自动拆词+合并+按多词命中排序。
738
+ 输入完整标题, 一次拿到最全最准的上下文。
739
+ """
740
+ import subprocess
741
+ script = os.path.join(_SCRIPTS, 'domain', 'kg', 'kg.py')
742
+ if not os.path.isfile(script):
743
+ return {}
744
+ cmd = ['python', script, 'prefetch', title]
745
+ if platform in ('web', 'app'):
746
+ cmd += ['--platform', platform]
747
+ try:
748
+ r = subprocess.run(
749
+ cmd, capture_output=True, text=True, timeout=20, encoding='utf-8', errors='replace')
750
+ return _parse_prefetch(r.stdout)
751
+ except subprocess.TimeoutExpired:
752
+ return {}
753
+ except Exception:
754
+ return {}
755
+
756
+
757
+ def _parse_prefetch(text):
758
+ """从 kg.py prefetch 的输出提取代码/API/字段 (复用 _parse_context_pack 逻辑)。
759
+
760
+ prefetch 输出格式和 context_pack 基本一致 (都有"相关代码文件/相关API/相关字段"段),
761
+ 只是 prefetch 带多词命中次数标注 (如 "4词命中")。
762
+ """
763
+ return _parse_context_pack(text, 'prefetch')
764
+
765
+
766
+ def _clean_html(s):
767
+ """去 HTML 标签 + 实体, 压缩空白。禅道详情字段是富文本 HTML。"""
768
+ if not s:
769
+ return ''
770
+ import re
771
+ s = re.sub(r'<br\s*/?>|</p>|</div>', '\n', s, flags=re.I)
772
+ s = re.sub(r'<[^>]+>', '', s)
773
+ s = re.sub(r'&nbsp;', ' ', s)
774
+ s = re.sub(r'&amp;', '&', s)
775
+ s = re.sub(r'&lt;', '<', s)
776
+ s = re.sub(r'&gt;', '>', s)
777
+ s = re.sub(r'[ \t]+', ' ', s)
778
+ s = re.sub(r'\n{3,}', '\n\n', s)
779
+ return s.strip()
780
+
781
+
782
+ def _fetch_zentao_detail(client, kind, item_id, max_len=600):
783
+ """从禅道拉任务/Bug/需求的详情 + 关联信息, 用于润色提示词。
784
+
785
+ 返回 dict:
786
+ - detail: 自身描述 (任务desc / Bug的steps重现步骤 / 需求spec)
787
+ - story_title: 关联需求标题 (任务/bug 常挂在 story 下)
788
+ - story_spec: 关联需求规格 (业务背景, 可能很详细)
789
+ - scene: 场景名 (所属执行/产品)
790
+ 任何一项查不到就留空, 不报错。
791
+ """
792
+ import re
793
+ out = {'detail': '', 'story_id': 0, 'story_title': '', 'story_spec': '', 'scene': ''}
794
+
795
+ # 自身详情
796
+ path = '/%ss/%s' % (kind, item_id)
797
+ r = client.api('GET', path)
798
+ if r.status_code != 200:
799
+ return out
800
+ data = r.json()
801
+
802
+ # 取详情正文 (不同 kind 字段名不同)
803
+ if kind == 'bug':
804
+ raw = data.get('steps', '')
805
+ elif kind == 'story':
806
+ raw = data.get('spec', '') or data.get('title', '')
807
+ else: # task
808
+ raw = data.get('desc', '')
809
+ detail = _clean_html(raw)
810
+ if detail and len(detail) > max_len:
811
+ detail = detail[:max_len] + '…'
812
+ out['detail'] = detail
813
+
814
+ # 场景名: execution 名 (任务) / product 名 (bug/story)
815
+ story_id = data.get('story') or 0
816
+
817
+ if kind == 'task':
818
+ eid = data.get('execution')
819
+ if eid:
820
+ er = client.api('GET', '/executions/%s' % eid)
821
+ if er.status_code == 200:
822
+ out['scene'] = str(er.json().get('name', ''))
823
+ else:
824
+ pid = data.get('product')
825
+ if pid:
826
+ pr = client.api('GET', '/products/%s' % pid)
827
+ if pr.status_code == 200:
828
+ out['scene'] = str(pr.json().get('name', ''))
829
+
830
+ # 关联需求 (业务背景, 最有价值的上下文)
831
+ if story_id:
832
+ out['story_id'] = story_id
833
+ sr = client.api('GET', '/stories/%s' % story_id)
834
+ if sr.status_code == 200:
835
+ sd = sr.json()
836
+ out['story_title'] = str(sd.get('title', ''))
837
+ spec = _clean_html(sd.get('spec', ''))
838
+ if spec and len(spec) > max_len:
839
+ spec = spec[:max_len] + '…'
840
+ out['story_spec'] = spec
841
+
842
+ return out
843
+
844
+
845
+ def enrich_prompt(kind, item_id, title, client=None):
846
+ """构建增强提示词: 禅道详情(最高优先) + 知识图谱(代码定位)。
847
+
848
+ 上下文优先级 (逐层取, 有就用, 没有就跳过):
849
+ 1. 禅道详情: 任务desc / Bug重现steps / 关联需求spec (最准, 但常为空)
850
+ 2. 场景名: 所属执行/产品
851
+ 3. 知识图谱: 相关代码/API/字段 (代码定位用)
852
+
853
+ client 传入则拉禅道详情; 不传(None)则只做知识图谱增强。
854
+ 任何环节失败 → 静默跳过, 不阻塞。
855
+ """
856
+ kind_label = kind if kind in ('任务', 'Bug', '需求') else '任务'
857
+ # 禅道内部 kind (task/bug/story)
858
+ zt_kind = 'bug' if kind_label == 'Bug' else ('story' if kind_label == '需求' else 'task')
859
+
860
+ # 角色化任务描述 (替代原来的"帮我推进禅道任务"废话)
861
+ if zt_kind == 'bug':
862
+ role_task = '定位这个 Bug 的根因并给出修复方案'
863
+ elif zt_kind == 'story':
864
+ role_task = '拆解这个需求并给出实现方案'
865
+ else:
866
+ role_task = '梳理这个任务的实现思路并定位代码'
867
+
868
+ # ── ① 先拉禅道详情 (拿 scene 推断 platform) ──
869
+ detail_added = False
870
+ scene_name = ''
871
+ story_block = [] # 关联需求内容 (暂存, 后面拼)
872
+ detail_block = [] # 重现步骤/desc (暂存)
873
+ if client:
874
+ try:
875
+ d = _fetch_zentao_detail(client, zt_kind, item_id)
876
+ scene_name = d.get('scene', '')
877
+ if d['story_title']:
878
+ story_block.append('## 关联需求 #%s' % d['story_id'])
879
+ story_block.append(d['story_title'])
880
+ if d['story_spec']:
881
+ story_block.append(d['story_spec'])
882
+ detail_added = True
883
+ if d['detail']:
884
+ lbl = '重现步骤' if zt_kind == 'bug' else ('任务说明' if zt_kind == 'task' else '需求规格')
885
+ detail_block.append('## %s' % lbl)
886
+ detail_block.append(d['detail'])
887
+ detail_added = True
888
+ except Exception:
889
+ pass
890
+
891
+ # 用 scene 推断 platform (前端/移动端/后端)
892
+ platform = _guess_platform(scene_name)
893
+ platform_label = {'web': '前端(Web)', 'app': '移动端(APP)'}.get(platform, '后端/全栈')
894
+
895
+ # 构建 lines (此时 scene/platform 都已确定)
896
+ lines = [
897
+ '## 任务',
898
+ '%s「%s」(禅道%s #%s, %s方向)' % (role_task, title or '', kind_label, item_id, platform_label),
899
+ ]
900
+ if scene_name:
901
+ lines.append('- 迭代/场景:%s' % scene_name)
902
+
903
+ # 拼入禅道详情
904
+ if story_block:
905
+ lines.append('')
906
+ lines.extend(story_block)
907
+ if detail_block:
908
+ lines.append('')
909
+ lines.extend(detail_block)
910
+
911
+ # 输出要求 (替代啰嗦的"请帮我", 给AI明确的结构化产出指令)
912
+ lines.append('')
913
+ lines.append('## 请按以下结构输出')
914
+ if zt_kind == 'bug':
915
+ lines.append('1. **根因分析**:结合重现步骤/关联需求,判断是数据问题、逻辑分支、还是接口/权限')
916
+ lines.append('2. **影响范围**:列出受影响的接口、表、页面(用下面的知识图谱代码定位)')
917
+ lines.append('3. **修复方案**:具体改哪些文件、改什么,给出关键代码片段')
918
+ lines.append('4. **回归点**:修复后需要验证哪些场景,避免引入新问题')
919
+ elif zt_kind == 'story':
920
+ lines.append('1. **需求拆解**:把需求拆成可独立实现的子功能点')
921
+ lines.append('2. **技术方案**:涉及哪些接口/表/页面,新增还是改造(参考下方代码)')
922
+ lines.append('3. **实现步骤**:按依赖顺序列出开发步骤')
923
+ lines.append('4. **风险点**:可能影响现有功能的地方')
924
+ else:
925
+ lines.append('1. **实现思路**:这个任务该怎么实现,用到哪些现有代码')
926
+ lines.append('2. **代码定位**:在下方知识图谱命中的文件里,具体改哪个类/方法')
927
+ lines.append('3. **实现步骤**:按顺序的开发步骤')
928
+ lines.append('4. **自检点**:完成后要验证什么')
929
+
930
+ # ── ② 知识图谱增强 (代码定位) ──
931
+ # v0.18: 用 prefetch 替代多次 context_pack —— 一次多词合并查询, 更快更准。
932
+ kws = _extract_keywords(title)
933
+ if not kws:
934
+ return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': detail_added, 'keyword': ''}}
935
+
936
+ # prefetch 一次性多词搜索 (知识图谱升级后的能力)
937
+ ctx = _query_kg_prefetch(title, platform)
938
+ if not ctx or not (ctx.get('code_files') or ctx.get('apis') or ctx.get('fields')):
939
+ # prefetch 失败 → 回退到旧的多词 context_pack
940
+ from concurrent.futures import ThreadPoolExecutor
941
+ all_code, all_api, all_field = {}, [], []
942
+ with ThreadPoolExecutor(max_workers=4) as pool:
943
+ results = list(pool.map(lambda k: (k, _query_kg_context(k, platform)), kws[:4]))
944
+ for kw, r in results:
945
+ for f in r.get('code_files', []):
946
+ all_code[f] = all_code.get(f, 0) + 1
947
+ all_api.extend(r.get('apis', []))
948
+ all_field.extend(r.get('fields', []))
949
+ else:
950
+ # prefetch 成功: 它已经按多词命中次数排好序了
951
+ all_code = {f: 1 for f in ctx.get('code_files', [])}
952
+ all_api = ctx.get('apis', [])
953
+ all_field = ctx.get('fields', [])
954
+
955
+ # 代码文件按命中次数排序 (次数多的排前面), API/字段去重
956
+ ranked_code = sorted(all_code.items(), key=lambda x: -x[1])[:8]
957
+ code_list = [f for f, _ in ranked_code]
958
+ seen = set()
959
+ api_list = [a for a in all_api if not (a in seen or seen.add(a))][:6]
960
+ # 字段过滤: 去掉明显跨业务域的假勤/薪资字段 (除非任务本身就是这些)
961
+ _IRRELEVANT_FIELDS = ('产检假', '年假', '年薪', '病假', '事假', '调休', '婚假', '丧假', '陪产假')
962
+ field_candidates = [fld for fld in all_field
963
+ if not (fld in seen or seen.add(fld))
964
+ and not any(ir in fld for ir in _IRRELEVANT_FIELDS)]
965
+ field_list = field_candidates[:6]
966
+
967
+ if not (code_list or api_list or field_list):
968
+ return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': detail_added, 'keyword': ','.join(kws[:3])}}
969
+
970
+ # 拼知识图谱上下文 (标注平台 + 多词命中)
971
+ lines.append('')
972
+ lines.append('## 知识图谱参考 (%s方向, 关键词: %s)' % (platform_label, ','.join(kws[:3])))
973
+ lines.append('> 以下是代码库中与该任务相关的文件/接口/字段,用于快速定位实现位置。')
974
+ if code_list:
975
+ lines.append('')
976
+ lines.append('**相关代码文件**:')
977
+ for f, cnt in ranked_code:
978
+ mark = ' ←多词命中' if cnt >= 2 else ''
979
+ lines.append('- `%s`%s' % (f, mark))
980
+ if api_list:
981
+ lines.append('')
982
+ lines.append('**相关接口**:')
983
+ for a in api_list:
984
+ lines.append('- `%s`' % a)
985
+ if field_list:
986
+ lines.append('')
987
+ lines.append('**相关字段**:')
988
+ for fld in field_list:
989
+ lines.append('- %s' % fld)
990
+ lines.append('')
991
+ lines.append('请先阅读上述代码文件理解现有实现,再给出改动方案,不要脱离现有架构另起炉灶。')
992
+ used_kws = ','.join(kws[:3])
993
+ return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': True, 'keyword': used_kws,
994
+ 'counts': {'code': len(code_list), 'api': len(api_list), 'field': len(field_list)}}}
995
+
996
+
403
997
  def main():
404
998
  parser = argparse.ArgumentParser(description='禅道任务面板数据接口 (输出 JSON 供 VS Code 扩展消费)')
405
999
  parser.add_argument('--json', action='store_true', help='JSON 输出 (面板默认用这个)')
406
1000
  parser.add_argument('--include-closed', action='store_true', help='包含已关闭/取消的历史项 (默认只看活动项)')
407
- parser.add_argument('--kind', choices=['tasks', 'bugs', 'stories'],
408
- help='只取某一域 (默认三域全取)')
409
1001
  parser.add_argument('--pretty', action='store_true', help='JSON 美化缩进 (默认紧凑, 面板解析更快)')
410
1002
  # 写操作子命令 (v0.3)
411
1003
  parser.add_argument('--action', choices=['start_task', 'pause_task', 'finish_task',
@@ -416,10 +1008,51 @@ def main():
416
1008
  parser.add_argument('--consumed', help='消耗工时 (finish/log_effort)')
417
1009
  parser.add_argument('--resolution', help='Bug 解决方式 (resolve_bug): fixed/bydesign/duplicate/notrepro/postpone')
418
1010
  parser.add_argument('--comment', help='Bug 解决备注 (resolve_bug)')
1011
+ # 提示词增强 (v0.6)
1012
+ parser.add_argument('--enrich', action='store_true', help='构建带知识图谱上下文的提示词 (发AI用)')
1013
+ parser.add_argument('--title', help='任务/Bug 标题 (enrich 用)')
1014
+ parser.add_argument('--kind-label', default='任务', help='类型标签 (enrich/detail 用): 任务/Bug/需求')
1015
+ # 详情查看 (v0.9)
1016
+ parser.add_argument('--detail', action='store_true', help='拉取任务/Bug/需求的完整详情 (卡片点击用)')
1017
+ parser.add_argument('--kind', choices=['tasks', 'bugs', 'stories', 'task', 'bug', 'story'],
1018
+ help='只取某一域 (默认三域全取); detail/enrich 用单数 task/bug/story 指定类型')
419
1019
  args = parser.parse_args()
420
1020
 
1021
+ # 详情查看分支: 拉禅道详情展示
1022
+ if args.detail:
1023
+ zt_kind = args.kind if args.kind in ('task', 'bug', 'story') else 'task'
1024
+ client = _zentao_creds()
1025
+ d = {}
1026
+ if client:
1027
+ try:
1028
+ ok, _ = client.credentials_ok()
1029
+ if ok and client.check_intranet()[0]:
1030
+ d = _fetch_zentao_detail(client, zt_kind, args.id or 0, max_len=1500)
1031
+ # 给 detail_label
1032
+ d['detail_label'] = {'bug': '重现步骤', 'task': '任务说明', 'story': '需求规格'}.get(zt_kind, '详情')
1033
+ d['title'] = args.title or d.get('story_title', '')
1034
+ except Exception as e:
1035
+ print(json.dumps({'ok': False, 'error': '拉取详情失败: %s' % e})); sys.exit(3)
1036
+ result = {'ok': True, 'data': d}
1037
+ code = 0
1038
+
1039
+ # 提示词增强分支: 禅道详情 + 知识图谱
1040
+ elif args.enrich:
1041
+ kind_label = args.kind_label if args.kind_label in ('任务', 'Bug', '需求') else '任务'
1042
+ # 尝试用禅道 client 拉详情 (失败不影响, enrich_prompt 内部静默降级)
1043
+ client = _zentao_creds()
1044
+ if client:
1045
+ ok, _ = client.credentials_ok()
1046
+ if ok:
1047
+ ok_inet, _ = client.check_intranet()
1048
+ if not ok_inet:
1049
+ client = None # 内网不通就不拉禅道详情, 只做知识图谱
1050
+ else:
1051
+ client = None
1052
+ result = enrich_prompt(kind_label, args.id or 0, args.title or '', client=client)
1053
+ code = 0
421
1054
  # 写操作分支
422
- if args.action:
1055
+ elif args.action:
423
1056
  if not args.id:
424
1057
  result = {'ok': False, 'error': '写操作必须提供 --id'}
425
1058
  code = 3
@@ -428,7 +1061,9 @@ def main():
428
1061
  'resolution': args.resolution, 'comment': args.comment}
429
1062
  result, code = perform_action(args.action, args.id, params)
430
1063
  else:
431
- result, code = fetch_workbench(include_closed=args.include_closed, kind=args.kind)
1064
+ # workbench: kind 期望 tasks/bugs/stories (单数形式转复数)
1065
+ wb_kind = args.kind if args.kind in ('tasks', 'bugs', 'stories') else None
1066
+ result, code = fetch_workbench(include_closed=args.include_closed, kind=wb_kind)
432
1067
 
433
1068
  if args.json or not sys.stdout.isatty():
434
1069
  indent = 2 if args.pretty else None