codebee 0.1.65 → 0.1.67

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 (116) hide show
  1. package/CHANGELOG.md +532 -513
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/acceptance.py +100 -0
  5. package/app/core/aiflavor.py +197 -197
  6. package/app/core/artifacts.py +148 -148
  7. package/app/core/assets.py +110 -0
  8. package/app/core/attachments.py +623 -532
  9. package/app/core/automation.py +758 -758
  10. package/app/core/backup.py +470 -470
  11. package/app/core/beekeeper.py +183 -183
  12. package/app/core/board.py +209 -209
  13. package/app/core/bookmeta.py +930 -759
  14. package/app/core/bookmeta_catalog.py +180 -180
  15. package/app/core/branching.py +137 -137
  16. package/app/core/builtin_agent.py +1234 -1191
  17. package/app/core/capability.py +144 -144
  18. package/app/core/catalog.py +23 -24
  19. package/app/core/chaptersafe.py +32 -32
  20. package/app/core/cleanup.py +303 -303
  21. package/app/core/compaction.py +201 -186
  22. package/app/core/covergen.py +356 -356
  23. package/app/core/defectretro.py +43 -0
  24. package/app/core/diagnostics.py +114 -114
  25. package/app/core/dispatch.py +173 -173
  26. package/app/core/dispatch_log.py +113 -113
  27. package/app/core/env_scrub.py +85 -85
  28. package/app/core/error_codes.py +220 -65
  29. package/app/core/errorlog.py +137 -137
  30. package/app/core/evaluation.py +174 -0
  31. package/app/core/failure_notify.py +290 -0
  32. package/app/core/flows.py +401 -355
  33. package/app/core/goal_service.py +167 -167
  34. package/app/core/health.py +374 -342
  35. package/app/core/history.py +38 -38
  36. package/app/core/hooks.py +162 -156
  37. package/app/core/jobs.py +1050 -1022
  38. package/app/core/knowledge.py +56 -5
  39. package/app/core/manager.py +2122 -1996
  40. package/app/core/market.py +362 -362
  41. package/app/core/market_remote.py +190 -92
  42. package/app/core/mocks.py +64 -64
  43. package/app/core/modelhub.py +3546 -3332
  44. package/app/core/notify.py +106 -106
  45. package/app/core/operations.py +228 -0
  46. package/app/core/paihang.py +138 -109
  47. package/app/core/paths.py +2 -2
  48. package/app/core/pipeline.py +4808 -4494
  49. package/app/core/planner.py +37 -32
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/preview.py +293 -293
  52. package/app/core/publish/__init__.py +7 -7
  53. package/app/core/publish/auto.py +367 -367
  54. package/app/core/publish/browser.py +539 -533
  55. package/app/core/publish/fanqie.py +159 -128
  56. package/app/core/publish/flow.py +440 -406
  57. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  58. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  59. package/app/core/publish/ledger.py +203 -198
  60. package/app/core/publish/manager.py +692 -590
  61. package/app/core/publish/qimao.py +141 -141
  62. package/app/core/publish/ws.py +139 -139
  63. package/app/core/redact.py +38 -38
  64. package/app/core/repeat_guard.py +129 -124
  65. package/app/core/revisions.py +51 -0
  66. package/app/core/router.py +119 -81
  67. package/app/core/runner.py +2065 -2042
  68. package/app/core/selfupdate.py +384 -384
  69. package/app/core/session_log.py +161 -161
  70. package/app/core/sessions.py +312 -312
  71. package/app/core/settings.py +149 -149
  72. package/app/core/settings_schema.py +262 -262
  73. package/app/core/share_page.py +114 -114
  74. package/app/core/skill_scan.py +114 -106
  75. package/app/core/skillpacks/fanqie-novel.md +80 -80
  76. package/app/core/skillpacks/market/character-bible.md +66 -66
  77. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  78. package/app/core/skillpacks/market/git-workflow.md +57 -57
  79. package/app/core/skillpacks/market/release-notes.md +72 -72
  80. package/app/core/skillpacks/market/weekly-report.md +71 -71
  81. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  82. package/app/core/skills.py +315 -73
  83. package/app/core/step_runner.py +132 -132
  84. package/app/core/store.py +230 -11
  85. package/app/core/task_compile.py +199 -199
  86. package/app/core/telemetry.py +292 -292
  87. package/app/core/tlsctx.py +64 -64
  88. package/app/core/token_meter.py +147 -147
  89. package/app/core/upstream.py +41 -0
  90. package/app/core/usage.py +114 -3
  91. package/app/core/volumes.py +389 -355
  92. package/app/core/wxdigest.py +858 -858
  93. package/app/core/zentao.py +2465 -2447
  94. package/app/main.py +2957 -2832
  95. package/app/pick_dialog.py +110 -110
  96. package/app/ui/app.js +14384 -13977
  97. package/app/ui/artdiff.js +74 -0
  98. package/app/ui/board.css +20 -41
  99. package/app/ui/board.html +162 -162
  100. package/app/ui/board.js +305 -305
  101. package/app/ui/i18n.js +2766 -2711
  102. package/app/ui/icons/bee.svg +79 -79
  103. package/app/ui/index.html +7 -1
  104. package/app/ui/manifest.json +16 -16
  105. package/app/ui/qrcode.js +2297 -2297
  106. package/app/ui/style.css +6179 -6125
  107. package/bin/tutti.js +147 -147
  108. package/package.json +46 -41
  109. package/tools/wx_reader.py +261 -261
  110. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  111. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  112. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  113. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  114. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  115. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  116. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -25,7 +25,7 @@ import threading
25
25
  import time
26
26
  from pathlib import Path
27
27
 
28
- from . import paths
28
+ from . import paths, revisions
29
29
 
30
30
  _LOCK = threading.RLock()
31
31
  _FILE = paths.DATA_DIR / "skills.json"
@@ -189,6 +189,8 @@ def _load_user_pack(path):
189
189
  "user": True,
190
190
  }
191
191
  pack["body"] = body
192
+ pack.update({k: revisions.make_revision("packrev", body, source=key)[k]
193
+ for k in ("revision_id", "content_sha256")})
192
194
  with _LOCK:
193
195
  _user_pack_cache[key] = (mt, pack)
194
196
  return pack
@@ -265,6 +267,38 @@ def list_packs():
265
267
  return out
266
268
 
267
269
 
270
+ def revision_for_task(task) -> dict:
271
+ """Return the skill inputs selected for a task as a compact revision set."""
272
+ scope = str((task or {}).get("type") or "*")
273
+ packs = []
274
+ for pack in all_packs():
275
+ if scope not in (pack.get("scopes") or ["*"]) and "*" not in (pack.get("scopes") or []):
276
+ continue
277
+ if not _pack_enabled(pack.get("id")):
278
+ continue
279
+ body = pack_text(pack)
280
+ rev = revisions.make_revision("packrev", body, source=pack.get("id") or "")
281
+ packs.append({"id": pack.get("id") or "", "revision_id": rev["revision_id"],
282
+ "content_sha256": rev["content_sha256"]})
283
+ lessons = []
284
+ for lesson in list_lessons(scope=scope, only_enabled=True):
285
+ current_digest = revisions.content_sha256(lesson.get("content") or "")
286
+ stored_digest = str(lesson.get("content_sha256") or "")
287
+ drift = bool(stored_digest and stored_digest != current_digest)
288
+ lessons.append({"id": lesson.get("id") or "",
289
+ "revision_id": (lesson.get("revision_id")
290
+ if stored_digest == current_digest else
291
+ revisions.revision_id("skrev", current_digest)) or
292
+ revisions.revision_id("skrev", current_digest),
293
+ "content_sha256": current_digest,
294
+ "revision_drift": drift})
295
+ payload = {"scope": scope, "packs": packs, "lessons": lessons}
296
+ digest = revisions.stable_digest(payload)
297
+ return {"revision_id": revisions.revision_id("skills", digest),
298
+ "content_sha256": digest, "scope": scope,
299
+ "packs": packs, "lessons": lessons}
300
+
301
+
268
302
  def _pack_enabled(pid):
269
303
  with _LOCK:
270
304
  st = (_load().get("packs") or {}).get(pid) or {}
@@ -285,6 +319,25 @@ def _lesson_id(scope, title):
285
319
  return "sk-" + h
286
320
 
287
321
 
322
+ _KARMA_PRIOR = 1.0 # 加性平滑伪计数:无证据时折到中性值,不奖励也不惩罚新条目
323
+
324
+
325
+ def _karma(item):
326
+ """这条教训**可信吗**:未失守率的平滑估计 (hits-lost+1)/(hits+2)。
327
+
328
+ 分母是注入次数 hits,不是 won+lost。只看胜负次数会把「注入 1175 次、真实归因
329
+ 失守 3 次」(失守率 0.26%)排在「注入 4 次、失守 1 次」(25%)之后——那是把
330
+ 证据量当成了噪声。加 1/加 2 平滑让无证据的新条目落在中性 0.5,样本越多越接近
331
+ 真实比率。刻意不用 Wilson 置信下界:那会把「注入 2 次零失守」打到 0.34,比从未
332
+ 注入的还低,等于惩罚正面证据。
333
+ """
334
+ hits = max(0, int(item.get("hits") or 0))
335
+ lost = max(0, int(item.get("lost") or 0))
336
+ if lost > hits:
337
+ lost = hits # 台账被手工改坏时不失守倒挂
338
+ return (hits - lost + _KARMA_PRIOR) / (hits + 2 * _KARMA_PRIOR)
339
+
340
+
288
341
  def list_lessons(scope=None, only_enabled=False, category=None):
289
342
  with _LOCK:
290
343
  items = list((_load().get("lessons") or []))
@@ -296,9 +349,9 @@ def list_lessons(scope=None, only_enabled=False, category=None):
296
349
  if only_enabled:
297
350
  items = [x for x in items if x.get("enabled", True)]
298
351
  # karma 感知排序(UI 列表用;注入排序在 relevance_top 不走这里):
299
- # 有效教训(won-lost 高)排最前——经验库页首屏即「真实帮上忙的」,
300
- # 其次按注入热度;失守多的自然沉底(不隐藏——降权不删除)。
301
- items.sort(key=lambda x: (-(int(x.get("won") or 0) - int(x.get("lost") or 0)),
352
+ # 首键是可信度下界(注入多且很少失守的居首),被点名归因过成功的(won)作次键
353
+ # 提权,再按注入热度;失守多的自然沉底(不隐藏——降权不删除)。
354
+ items.sort(key=lambda x: (-_karma(x), -int(x.get("won") or 0),
302
355
  -int(x.get("hits") or 0), -int(x.get("seen") or 1),
303
356
  x.get("created_at") or ""))
304
357
  return items
@@ -345,7 +398,16 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
345
398
  break
346
399
  if hit is not None:
347
400
  it = hit
401
+ old_content = str(it.get("content") or "")
402
+ new_rev = revisions.make_revision("skrev", content, source=source)
403
+ if old_content and old_content != content:
404
+ it.setdefault("revisions", []).append(
405
+ revisions.revision_record(
406
+ "skrev", old_content, source=it.get("source") or "",
407
+ extra={"content": old_content}))
408
+ it["revisions"] = it["revisions"][-5:]
348
409
  it["content"] = content
410
+ it.update({k: new_rev[k] for k in ("revision_id", "content_sha256")})
349
411
  it["seen"] = int(it.get("seen") or 1) + 1
350
412
  it["updated_at"] = _now()
351
413
  # 合并时不降级已有分类:除非本次归到了明确类别,或该条原本没有分类
@@ -355,6 +417,7 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
355
417
  it["source"] = source
356
418
  _save(data)
357
419
  return it
420
+ rev = revisions.make_revision("skrev", content, source=source)
358
421
  it = {"id": lid, "scope": scope, "title": title, "content": content,
359
422
  "source": source, "hits": 0, "seen": 1, "enabled": True,
360
423
  "category": cat,
@@ -362,6 +425,7 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
362
425
  # 稳定 token(procedure=程序性做法 / lesson=规避性教训);
363
426
  # 展示层 view() 翻译成「做法/教训」,持久层不用中文防改文案伤数据
364
427
  "kind": ("procedure" if title.startswith("做法:") else "lesson")}
428
+ it.update({k: rev[k] for k in ("revision_id", "content_sha256")})
365
429
  items.append(it)
366
430
  _save(data)
367
431
  return it
@@ -423,17 +487,64 @@ def relevance_top(lessons, task, limit):
423
487
  return lessons[:limit]
424
488
 
425
489
  # 使用反馈闭环(pmb「量化记忆真实帮助」+ tradememory「按结局加权召回」):
426
- # 相关性优先;同分比 outcome 胜负(注入后任务过审 +1 / 未过 -1),再比 hits。
490
+ # 相关性优先;同分比可信度下界,再比归因成功的次数,最后比注入热度。
427
491
  def rank(x):
428
492
  grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
429
493
  overlap = -len(probe & grams)
430
- karma = int(x.get("won") or 0) - int(x.get("lost") or 0)
431
- lid = x.get("id") or ""
432
- return (overlap, -karma, -int(x.get("hits") or 0), lid)
494
+ return (overlap, -_karma(x), -int(x.get("won") or 0),
495
+ -int(x.get("hits") or 0), x.get("id") or "")
433
496
 
434
497
  return sorted(lessons, key=rank)[:limit]
435
498
 
436
499
 
500
+ def evolve_lessons(min_hits=6, min_karma=0.75, min_won=1, max_items=10):
501
+ """教训半自动晋升技能包(ECC /evolve 借鉴):把被任务面反复验证的教训
502
+ (注入 ≥min_hits、可信度下界 ≥min_karma、成功归因 ≥min_won)聚成一份
503
+ 用户包**草稿**写入 data/skillpacks/evolve-<时间戳>.md。
504
+
505
+ 草稿默认停用(packs 状态写 enabled=False)——晋升必须过人工审阅这道闸:
506
+ 技能库页审后启用/改 scopes/编辑正文;不自动生效,避免聚合噪音直接进
507
+ 全量任务注入。返回 (文件名, 收录条数);无可晋升教训返回 (None, 0)。"""
508
+ with _LOCK:
509
+ items = list((_load().get("lessons") or []))
510
+ picked = [x for x in items
511
+ if x.get("enabled", True)
512
+ and int(x.get("hits") or 0) >= min_hits
513
+ and int(x.get("won") or 0) >= min_won
514
+ and _karma(x) >= min_karma]
515
+ picked.sort(key=lambda x: (-_karma(x), -int(x.get("hits") or 0)))
516
+ picked = picked[:max_items]
517
+ if not picked:
518
+ return None, 0
519
+ scopes = sorted({str(x.get("scope") or "*") for x in picked})
520
+ stamp = time.strftime("%Y%m%d-%H%M%S")
521
+ fname = "evolve-%s.md" % stamp
522
+ lines = ["# 晋升自项目教训(evolve 草稿,待人工审阅启用)", "",
523
+ "以下教训经任务面反复验证聚合而成;逐条确认后可编辑删改,"
524
+ "确认无误再在技能库页启用本包。", ""]
525
+ for x in picked:
526
+ lines.append("- **%s**(可信度 %.0f%%,注入 %d 次/成功归因 %d 次):%s"
527
+ % (x.get("title") or x.get("id"),
528
+ _karma(x) * 100, int(x.get("hits") or 0),
529
+ int(x.get("won") or 0), x.get("content") or ""))
530
+ fm = ["---",
531
+ "name: 教训晋升草稿·%s" % time.strftime("%Y-%m-%d %H:%M"),
532
+ "scopes:"] + [" - %s" % s for s in scopes] + [
533
+ "note: evolve 自动聚合的高可信教训草稿(默认停用,审阅后启用)",
534
+ "---", ""]
535
+ d = _user_pack_dir()
536
+ d.mkdir(parents=True, exist_ok=True)
537
+ (d / fname).write_text("\n".join(fm + lines) + "\n", encoding="utf-8")
538
+ # 草稿停用态:pid 与 _load_user_pack 的 path.stem 哈希口径一致
539
+ stem = fname[:-3] if fname.endswith(".md") else fname
540
+ pid = "user-" + hashlib.sha256(stem.encode("utf-8")).hexdigest()[:10]
541
+ with _LOCK:
542
+ data = _load()
543
+ data.setdefault("packs", {})[pid] = {"enabled": False, "evolved": True}
544
+ _save(data)
545
+ return fname, len(picked)
546
+
547
+
437
548
  # 运行级注入登记(outcome 加权用):run_id → 注入的教训 id 列表。
438
549
  # 容量有界防泄漏;run 收尾 learn_from_run 时消费清除。
439
550
  _INJECTED = {}
@@ -486,10 +597,12 @@ def block_for(task, scope_override=None, *, stable_order=False, run_id=None):
486
597
  lessons.sort(key=lambda x: x.get("id") or "")
487
598
  lines = []
488
599
  for x in lessons:
489
- lines.append("- **%s**:%s" % (x["title"], x["content"]))
600
+ # 编号可见:复盘官据此点名「哪条已沉淀教训没防住本次问题」,
601
+ # 归因才能落到单条而不是整批(见 note_outcome)。
602
+ lines.append("- [%s] **%s**:%s" % (x["id"], x["title"], x["content"]))
490
603
  used.append(x["id"])
491
604
  lesson_ids.append(x["id"])
492
- lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n"
605
+ lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避;方括号内为教训编号)】\n"
493
606
  + "\n".join(lines))
494
607
 
495
608
  if not parts and not lesson_part:
@@ -510,29 +623,48 @@ def block_for(task, scope_override=None, *, stable_order=False, run_id=None):
510
623
  with _LOCK:
511
624
  if len(_INJECTED) >= _INJECTED_MAX:
512
625
  _INJECTED.clear() # 有界兜底:登记超量整体作废(丢信号不丢内存)
513
- _INJECTED[str(run_id)] = list(lesson_ids)
626
+ rid = str(run_id)
627
+ # 并集而非覆盖:一个 run 里大纲步与评审步都会注入,覆盖会让
628
+ # 先注入的那批教训永远拿不到归因。
629
+ _INJECTED[rid] = sorted(set(_INJECTED.get(rid) or ()) | set(lesson_ids))
514
630
  return text, used
515
631
 
516
632
 
517
- def note_outcome(run_id, passed):
518
- """run 收尾回写注入教训的胜负(tradememory outcome 加权)。
633
+ def note_outcome(run_id, passed, penalize=None, reward=None):
634
+ """按**单条归因结果**回写胜负(outcome 加权 · 精确归因版)。
635
+
636
+ `passed` 取 `_run_outcome` 的三态结论:True → 清算 reward,False → 清算
637
+ penalize,**None → 只消费登记,一律不回写**(本次没有可归因的质量结局)。
638
+ 两个集合都只在本 run 登记过的 id 内生效。幂等:登记消费即清除。
519
639
 
520
- passed=True → won+1(这条教训在场时任务过审);False → lost+1。
521
- 只清算登记在案的教训;幂等(同一 run 消费后清除登记)。"""
640
+ 未被点名的注入教训**不回写**。旧实现给整批 top-8 统一记 +/-1:一次没过就把
641
+ 七八条不相干的教训一起打成负分,`list_lessons` 按 karma 排序后好教训集体沉底、
642
+ UI 首屏再也看不到。归因缺失即本次无信号,宁可不落账——稀疏而正确的胜负优于
643
+ 稠密而失真。
644
+ """
522
645
  rid = str(run_id or "")
523
646
  if not rid:
524
647
  return
525
648
  with _LOCK:
526
- ids = _INJECTED.pop(rid, None)
527
- if not ids:
649
+ registered = {str(i) for i in (_INJECTED.pop(rid, None) or [])}
650
+ if not registered or passed is None:
651
+ return
652
+ src = reward if passed else penalize
653
+ targets = set()
654
+ for i in (src or []):
655
+ if str(i) in registered:
656
+ targets.add(str(i))
657
+ if not targets:
528
658
  return
659
+ key = "won" if passed else "lost"
529
660
  data = _load()
530
- idset = set(ids)
661
+ changed = False
531
662
  for it in (data.get("lessons") or []):
532
- if it.get("id") in idset:
533
- key = "won" if passed else "lost"
663
+ if it.get("id") in targets:
534
664
  it[key] = int(it.get(key) or 0) + 1
535
- _save(data)
665
+ changed = True
666
+ if changed:
667
+ _save(data)
536
668
 
537
669
 
538
670
  def bump_hits(ids):
@@ -558,7 +690,9 @@ LEARN_PROMPT = """你是编排系统的复盘官。下面是刚结束的一次
558
690
  记忆借鉴:任务一次通过且分数高时,把「这次做对了什么」提炼成可复用步骤,title
559
691
  以「做法:」开头,如「做法:先列评分点再逐条应答」)。
560
692
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
561
- {"lessons": [{"title": "≤14 字的归类(教训直接写;有效做法以「做法:」开头)", "category": "问题分类", "content": "下次必须怎么做/避免什么(≤120 字,具体可执行)"}]}
693
+ {"lessons": [{"title": "≤14 字的归类(教训直接写;有效做法以「做法:」开头)", "category": "问题分类", "content": "下次必须怎么做/避免什么(≤120 字,具体可执行)", "violates": ["sk-xxxxxx"]}]}
694
+ violates:本次问题**戳穿了下面「本次已注入的历史教训」中的哪几条**(那几条已在提示词里却没防住),
695
+ 只能填下面列出的编号原值,不确定或没有对应就填 []。
562
696
  category 必须从以下固定枚举中选一个(贴合评审维度,不要自创类别):
563
697
  __CATEGORIES__
564
698
  最多 5 条,只保留反复出现或影响过稿/验收的关键项;一次通过的高分运行优先提炼「做法」;没有值得沉淀的就返回空数组。
@@ -569,6 +703,9 @@ __TYPE__
569
703
  ## 任务目标(摘要)
570
704
  __GOAL__
571
705
 
706
+ ## 本次已注入的历史教训(编号 + 标题;violates 只能引用这里的编号)
707
+ __INJECTED__
708
+
572
709
  ## 本次结论
573
710
  __VERDICT__
574
711
 
@@ -610,6 +747,69 @@ def _collect_issues(run):
610
747
  return issues[:30]
611
748
 
612
749
 
750
+ _DIM_SEP = re.compile(r"[、,,/;;\s\.\-—]+")
751
+
752
+
753
+ def _issue_cats(issue):
754
+ """把一条评审问题归到闭集分类集合。dim 可多选(「钩子、爽点」),
755
+ 逐段归一化;都命中不了再退到 note 正文关键词(与沉淀路径同一套度量)。"""
756
+ out = set()
757
+ for part in _DIM_SEP.split(str(issue.get("dim") or "")):
758
+ c = _normalize_category(part, part)
759
+ if c:
760
+ out.add(c)
761
+ if not out:
762
+ c = _category_from(issue.get("note"))
763
+ if c:
764
+ out.add(c)
765
+ return out
766
+
767
+
768
+ def _lesson_cats(lesson):
769
+ """教训侧的类别集合:category 枚举优先,退到 dim,再退到标题关键词。
770
+ 刻意不扫正文——长文本首个关键词命中即胜,归因会退化成噪声匹配。"""
771
+ c = (_normalize_category(lesson.get("category"), lesson.get("dim"))
772
+ or _category_from(lesson.get("title")))
773
+ return {c} if c else set()
774
+
775
+
776
+ def _collect_strengths(run):
777
+ """本次达标(≥阈值)的维度:过审 run 的正向归因依据——教训守的维度
778
+ 这次分数站得住,才记一次 won。"""
779
+ v = run.get("verdict") or {}
780
+ thr = float(v.get("threshold") or 7.0)
781
+ good = {}
782
+ for c in (v.get("chapter_scores") or []):
783
+ for d, s in (c.get("means") or {}).items():
784
+ if float(s) >= thr:
785
+ good[d] = good.get(d, 0) + 1
786
+ for d, s in (v.get("global_scores") or {}).items():
787
+ if float(s) >= thr:
788
+ good[d] = good.get(d, 0) + 1
789
+ return [{"dim": d} for d in sorted(good, key=lambda k: (-good[k], k))]
790
+
791
+
792
+ def _attribute(registered, by_id, issues, strengths):
793
+ """整批 +/- → 单条归因(A 路:类别匹配,零模型依赖)。
794
+ 返回 (penalize, reward):与本次**问题**同类别的教训挨罚;与本次**达标维度**
795
+ 同类别且不属于问题类别的教训记功。归不了类的教训两侧都不动。"""
796
+ icats, scats = set(), set()
797
+ for i in issues:
798
+ icats |= _issue_cats(i)
799
+ for s in strengths:
800
+ scats |= _issue_cats(s)
801
+ penalize, reward = [], []
802
+ for rid in registered:
803
+ cats = _lesson_cats(by_id.get(rid) or {})
804
+ if not cats:
805
+ continue
806
+ if cats & icats:
807
+ penalize.append(rid)
808
+ elif cats & scats:
809
+ reward.append(rid)
810
+ return penalize, reward
811
+
812
+
613
813
  def _fallback_lessons(task, run):
614
814
  """无编排者时的确定性兜底:按维度把反复出现的问题聚成教训。"""
615
815
  v = run.get("verdict") or {}
@@ -634,65 +834,107 @@ def _fallback_lessons(task, run):
634
834
  return out[:5]
635
835
 
636
836
 
837
+ def _run_outcome(run):
838
+ """把评审结论折成三态结局:True 过审 / False 未过审 / None 无从判定。
839
+
840
+ 旧实现是 `bool(verdict.get("pass"))`,把「没有结论」直接判成「质量不通过」。
841
+ 实跑 110 个 run 里 75 个根本没有 verdict(61 failed / 14 done / 4 running /
842
+ 1 cancelled / 1 timeout),每一个都在给整批注入教训白记一笔 lost。基础设施类
843
+ 失败(换路耗尽、超时、取消)不是质量信号,这点统一执行标准里也写了:取消不得
844
+ 进入任何惩罚。判不出来就返回 None,宁可这次不归因。
845
+ """
846
+ if (run.get("status") or "") != "done":
847
+ return None
848
+ v = run.get("verdict") or {}
849
+ if not v:
850
+ return None
851
+ for key in ("pass", "publishable"): # 单章评审用 pass,连载用 publishable
852
+ val = v.get(key)
853
+ if isinstance(val, bool):
854
+ return val
855
+ if isinstance(v.get("verify_pass"), bool) or isinstance(v.get("review_pass"), bool):
856
+ return bool(v.get("verify_pass")) and bool(v.get("review_pass"))
857
+ gp = v.get("global_pass")
858
+ return gp if isinstance(gp, bool) else None
859
+
860
+
637
861
  def learn_from_run(run_id, use_orchestrator=True):
638
- """运行结束后自动总结教训并沉淀。返回写入条数。"""
862
+ """运行结束后自动总结教训并沉淀。返回写入条数。
863
+
864
+ 胜负清算放在 finally:无论是否沉淀出新教训、编排者是否可用,本 run 的注入登记
865
+ 都必须被消费掉(旧实现把落账放最前就是为了「一定清算」,改成精确归因后同样不能漏,
866
+ 且登记不消费会一直泄漏内存)。
867
+ """
639
868
  from . import store
640
869
  run = store.get_run(run_id)
641
870
  if not run:
642
871
  return 0
643
- # outcome 加权(tradememory):本 run 注入过的教训按结局记胜负——
644
- # 失败 run 说明在场教训没防住这个问题(lost+1),过审则 won+1。
645
- # 放最前:无论后续是否沉淀新教训,胜负都要落账。
646
- try:
647
- verdict = run.get("verdict") or {}
648
- note_outcome(run_id, bool(verdict.get("pass")))
649
- except Exception:
650
- pass
651
- task = store.get_task(run.get("task_id")) if run.get("task_id") else None
652
- if not task:
653
- return 0
654
- # mock 运行不沉淀(没有真实评审信号)
655
- if all((s.get("agent") or "").startswith("mock") for s in (run.get("steps") or [])):
656
- return 0
657
- v = run.get("verdict") or {}
658
- if not v:
659
- return 0
872
+ verdict = run.get("verdict") or {}
873
+ outcome = _run_outcome(run)
874
+ registered = list(_INJECTED.get(str(run_id)) or [])
875
+ by_id = {x.get("id"): x for x in list_lessons()} if registered else {}
660
876
  issues = _collect_issues(run)
661
- lessons = []
662
- if use_orchestrator:
877
+ cited = set() # B 路归因:复盘官点名「已注入却没防住本次问题」的教训编号
878
+ try:
879
+ task = store.get_task(run.get("task_id")) if run.get("task_id") else None
880
+ if not task:
881
+ return 0
882
+ # mock 运行不沉淀(没有真实评审信号)
883
+ if all((s.get("agent") or "").startswith("mock") for s in (run.get("steps") or [])):
884
+ return 0
885
+ v = verdict
886
+ if not v:
887
+ return 0
888
+ lessons = []
889
+ if use_orchestrator:
890
+ try:
891
+ from . import modelhub, runner
892
+ orch = modelhub.resolve_orchestrator()
893
+ if orch:
894
+ prov, model = orch
895
+ verdict_txt = json.dumps({k: v[k] for k in v if k not in ("route", "chapter_scores")},
896
+ ensure_ascii=False)[:1200]
897
+ injected_txt = "\n".join(
898
+ "- [%s] %s" % (lid, (by_id.get(lid) or {}).get("title", ""))
899
+ for lid in registered) or "(本次未注入历史教训,violates 一律填 [])"
900
+ prompt = (LEARN_PROMPT.replace("__CATEGORIES__", "、".join(LESSON_CATEGORIES))
901
+ .replace("__TYPE__", str(task.get("type")))
902
+ .replace("__GOAL__", (task.get("goal") or "")[:600])
903
+ .replace("__INJECTED__", injected_txt)
904
+ .replace("__VERDICT__", verdict_txt)
905
+ .replace("__ISSUES__",
906
+ "\n".join("- %s" % i.get("note", "") for i in issues)[:3000] or "(无)"))
907
+ # 推理模型的思考会吞掉全部预算:max_tokens 给足才有正文可解析
908
+ res = modelhub.chat(prov["id"], model, prompt, max_tokens=8000, timeout=300)
909
+ if res.get("ok"):
910
+ data = runner.extract_json(res.get("text") or "")
911
+ raw = (data or {}).get("lessons") if isinstance(data, dict) else None
912
+ if isinstance(raw, list):
913
+ for x in raw[:5]:
914
+ if isinstance(x, dict) and x.get("title") and x.get("content"):
915
+ lessons.append({"title": str(x["title"]), "content": str(x["content"]),
916
+ "category": x.get("category")})
917
+ for lid in (x.get("violates") or [])[:MAX_LESSONS_INJECT]:
918
+ cited.add(str(lid).strip())
919
+ except Exception:
920
+ lessons = []
921
+ if not lessons:
922
+ lessons = _fallback_lessons(task, run)
923
+ n = 0
924
+ for x in lessons:
925
+ if upsert_lesson(task.get("type") or "*", x["title"], x["content"], source=run_id,
926
+ category=x.get("category"), dim=x.get("dim")):
927
+ n += 1
928
+ return n
929
+ finally:
663
930
  try:
664
- from . import modelhub, runner
665
- orch = modelhub.resolve_orchestrator()
666
- if orch:
667
- prov, model = orch
668
- verdict_txt = json.dumps({k: v[k] for k in v if k not in ("route", "chapter_scores")},
669
- ensure_ascii=False)[:1200]
670
- prompt = (LEARN_PROMPT.replace("__CATEGORIES__", "、".join(LESSON_CATEGORIES))
671
- .replace("__TYPE__", str(task.get("type")))
672
- .replace("__GOAL__", (task.get("goal") or "")[:600])
673
- .replace("__VERDICT__", verdict_txt)
674
- .replace("__ISSUES__",
675
- "\n".join("- %s" % i.get("note", "") for i in issues)[:3000] or "(无)"))
676
- # 推理模型的思考会吞掉全部预算:max_tokens 给足才有正文可解析
677
- res = modelhub.chat(prov["id"], model, prompt, max_tokens=8000, timeout=300)
678
- if res.get("ok"):
679
- data = runner.extract_json(res.get("text") or "")
680
- raw = (data or {}).get("lessons") if isinstance(data, dict) else None
681
- if isinstance(raw, list):
682
- for x in raw[:5]:
683
- if isinstance(x, dict) and x.get("title") and x.get("content"):
684
- lessons.append({"title": str(x["title"]), "content": str(x["content"]),
685
- "category": x.get("category")})
931
+ # A 路(类别匹配)+ B 路(模型点名)取并集;两者都落空时本次不落账。
932
+ penalize, reward = _attribute(registered, by_id, issues, _collect_strengths(run))
933
+ named = set(penalize) | (cited & set(registered)) # 编造/已挤出 top-k 的编号不认账
934
+ note_outcome(run_id, outcome, penalize=sorted(named),
935
+ reward=[i for i in reward if i not in named])
686
936
  except Exception:
687
- lessons = []
688
- if not lessons:
689
- lessons = _fallback_lessons(task, run)
690
- n = 0
691
- for x in lessons:
692
- if upsert_lesson(task.get("type") or "*", x["title"], x["content"], source=run_id,
693
- category=x.get("category"), dim=x.get("dim")):
694
- n += 1
695
- return n
937
+ pass
696
938
 
697
939
 
698
940
  def learn_async(run_id):