codebee 0.1.65 → 0.1.66

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 (110) hide show
  1. package/CHANGELOG.md +520 -513
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/aiflavor.py +197 -197
  5. package/app/core/artifacts.py +148 -148
  6. package/app/core/attachments.py +532 -532
  7. package/app/core/automation.py +758 -758
  8. package/app/core/backup.py +470 -470
  9. package/app/core/beekeeper.py +183 -183
  10. package/app/core/board.py +209 -209
  11. package/app/core/bookmeta.py +796 -759
  12. package/app/core/bookmeta_catalog.py +180 -180
  13. package/app/core/branching.py +137 -137
  14. package/app/core/builtin_agent.py +1234 -1191
  15. package/app/core/capability.py +144 -144
  16. package/app/core/catalog.py +23 -24
  17. package/app/core/chaptersafe.py +32 -32
  18. package/app/core/cleanup.py +303 -303
  19. package/app/core/compaction.py +201 -186
  20. package/app/core/covergen.py +356 -356
  21. package/app/core/diagnostics.py +114 -114
  22. package/app/core/dispatch.py +173 -173
  23. package/app/core/dispatch_log.py +113 -113
  24. package/app/core/env_scrub.py +85 -85
  25. package/app/core/error_codes.py +220 -65
  26. package/app/core/errorlog.py +137 -137
  27. package/app/core/evaluation.py +174 -0
  28. package/app/core/failure_notify.py +290 -0
  29. package/app/core/flows.py +355 -355
  30. package/app/core/goal_service.py +167 -167
  31. package/app/core/health.py +374 -342
  32. package/app/core/history.py +38 -38
  33. package/app/core/hooks.py +162 -156
  34. package/app/core/jobs.py +1034 -1022
  35. package/app/core/knowledge.py +6 -2
  36. package/app/core/manager.py +2115 -1996
  37. package/app/core/market.py +362 -362
  38. package/app/core/market_remote.py +66 -66
  39. package/app/core/mocks.py +64 -64
  40. package/app/core/modelhub.py +3517 -3332
  41. package/app/core/notify.py +106 -106
  42. package/app/core/paihang.py +109 -109
  43. package/app/core/paths.py +2 -2
  44. package/app/core/pipeline.py +4614 -4494
  45. package/app/core/planner.py +33 -31
  46. package/app/core/portguard.py +111 -111
  47. package/app/core/preview.py +293 -293
  48. package/app/core/publish/__init__.py +7 -7
  49. package/app/core/publish/auto.py +367 -367
  50. package/app/core/publish/browser.py +533 -533
  51. package/app/core/publish/fanqie.py +128 -128
  52. package/app/core/publish/flow.py +432 -406
  53. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  54. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +623 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +129 -124
  61. package/app/core/router.py +119 -81
  62. package/app/core/runner.py +2050 -2042
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +114 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/skills.py +221 -72
  78. package/app/core/step_runner.py +132 -132
  79. package/app/core/store.py +23 -0
  80. package/app/core/task_compile.py +199 -199
  81. package/app/core/telemetry.py +292 -292
  82. package/app/core/tlsctx.py +64 -64
  83. package/app/core/token_meter.py +147 -147
  84. package/app/core/upstream.py +41 -0
  85. package/app/core/usage.py +114 -3
  86. package/app/core/volumes.py +355 -355
  87. package/app/core/wxdigest.py +858 -858
  88. package/app/core/zentao.py +2447 -2447
  89. package/app/main.py +2838 -2832
  90. package/app/pick_dialog.py +110 -110
  91. package/app/ui/app.js +13994 -13977
  92. package/app/ui/board.css +20 -41
  93. package/app/ui/board.html +162 -162
  94. package/app/ui/board.js +305 -305
  95. package/app/ui/i18n.js +2716 -2711
  96. package/app/ui/icons/bee.svg +79 -79
  97. package/app/ui/index.html +1 -1
  98. package/app/ui/manifest.json +16 -16
  99. package/app/ui/qrcode.js +2297 -2297
  100. package/app/ui/style.css +6107 -6125
  101. package/bin/tutti.js +147 -147
  102. package/package.json +46 -41
  103. package/tools/wx_reader.py +261 -261
  104. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  105. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  106. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  107. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  108. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  109. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  110. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -285,6 +285,25 @@ def _lesson_id(scope, title):
285
285
  return "sk-" + h
286
286
 
287
287
 
288
+ _KARMA_PRIOR = 1.0 # 加性平滑伪计数:无证据时折到中性值,不奖励也不惩罚新条目
289
+
290
+
291
+ def _karma(item):
292
+ """这条教训**可信吗**:未失守率的平滑估计 (hits-lost+1)/(hits+2)。
293
+
294
+ 分母是注入次数 hits,不是 won+lost。只看胜负次数会把「注入 1175 次、真实归因
295
+ 失守 3 次」(失守率 0.26%)排在「注入 4 次、失守 1 次」(25%)之后——那是把
296
+ 证据量当成了噪声。加 1/加 2 平滑让无证据的新条目落在中性 0.5,样本越多越接近
297
+ 真实比率。刻意不用 Wilson 置信下界:那会把「注入 2 次零失守」打到 0.34,比从未
298
+ 注入的还低,等于惩罚正面证据。
299
+ """
300
+ hits = max(0, int(item.get("hits") or 0))
301
+ lost = max(0, int(item.get("lost") or 0))
302
+ if lost > hits:
303
+ lost = hits # 台账被手工改坏时不失守倒挂
304
+ return (hits - lost + _KARMA_PRIOR) / (hits + 2 * _KARMA_PRIOR)
305
+
306
+
288
307
  def list_lessons(scope=None, only_enabled=False, category=None):
289
308
  with _LOCK:
290
309
  items = list((_load().get("lessons") or []))
@@ -296,9 +315,9 @@ def list_lessons(scope=None, only_enabled=False, category=None):
296
315
  if only_enabled:
297
316
  items = [x for x in items if x.get("enabled", True)]
298
317
  # 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)),
318
+ # 首键是可信度下界(注入多且很少失守的居首),被点名归因过成功的(won)作次键
319
+ # 提权,再按注入热度;失守多的自然沉底(不隐藏——降权不删除)。
320
+ items.sort(key=lambda x: (-_karma(x), -int(x.get("won") or 0),
302
321
  -int(x.get("hits") or 0), -int(x.get("seen") or 1),
303
322
  x.get("created_at") or ""))
304
323
  return items
@@ -423,13 +442,12 @@ def relevance_top(lessons, task, limit):
423
442
  return lessons[:limit]
424
443
 
425
444
  # 使用反馈闭环(pmb「量化记忆真实帮助」+ tradememory「按结局加权召回」):
426
- # 相关性优先;同分比 outcome 胜负(注入后任务过审 +1 / 未过 -1),再比 hits。
445
+ # 相关性优先;同分比可信度下界,再比归因成功的次数,最后比注入热度。
427
446
  def rank(x):
428
447
  grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
429
448
  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)
449
+ return (overlap, -_karma(x), -int(x.get("won") or 0),
450
+ -int(x.get("hits") or 0), x.get("id") or "")
433
451
 
434
452
  return sorted(lessons, key=rank)[:limit]
435
453
 
@@ -486,10 +504,12 @@ def block_for(task, scope_override=None, *, stable_order=False, run_id=None):
486
504
  lessons.sort(key=lambda x: x.get("id") or "")
487
505
  lines = []
488
506
  for x in lessons:
489
- lines.append("- **%s**:%s" % (x["title"], x["content"]))
507
+ # 编号可见:复盘官据此点名「哪条已沉淀教训没防住本次问题」,
508
+ # 归因才能落到单条而不是整批(见 note_outcome)。
509
+ lines.append("- [%s] **%s**:%s" % (x["id"], x["title"], x["content"]))
490
510
  used.append(x["id"])
491
511
  lesson_ids.append(x["id"])
492
- lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n"
512
+ lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避;方括号内为教训编号)】\n"
493
513
  + "\n".join(lines))
494
514
 
495
515
  if not parts and not lesson_part:
@@ -510,29 +530,48 @@ def block_for(task, scope_override=None, *, stable_order=False, run_id=None):
510
530
  with _LOCK:
511
531
  if len(_INJECTED) >= _INJECTED_MAX:
512
532
  _INJECTED.clear() # 有界兜底:登记超量整体作废(丢信号不丢内存)
513
- _INJECTED[str(run_id)] = list(lesson_ids)
533
+ rid = str(run_id)
534
+ # 并集而非覆盖:一个 run 里大纲步与评审步都会注入,覆盖会让
535
+ # 先注入的那批教训永远拿不到归因。
536
+ _INJECTED[rid] = sorted(set(_INJECTED.get(rid) or ()) | set(lesson_ids))
514
537
  return text, used
515
538
 
516
539
 
517
- def note_outcome(run_id, passed):
518
- """run 收尾回写注入教训的胜负(tradememory outcome 加权)。
540
+ def note_outcome(run_id, passed, penalize=None, reward=None):
541
+ """按**单条归因结果**回写胜负(outcome 加权 · 精确归因版)。
542
+
543
+ `passed` 取 `_run_outcome` 的三态结论:True → 清算 reward,False → 清算
544
+ penalize,**None → 只消费登记,一律不回写**(本次没有可归因的质量结局)。
545
+ 两个集合都只在本 run 登记过的 id 内生效。幂等:登记消费即清除。
519
546
 
520
- passed=True → won+1(这条教训在场时任务过审);False → lost+1。
521
- 只清算登记在案的教训;幂等(同一 run 消费后清除登记)。"""
547
+ 未被点名的注入教训**不回写**。旧实现给整批 top-8 统一记 +/-1:一次没过就把
548
+ 七八条不相干的教训一起打成负分,`list_lessons` 按 karma 排序后好教训集体沉底、
549
+ UI 首屏再也看不到。归因缺失即本次无信号,宁可不落账——稀疏而正确的胜负优于
550
+ 稠密而失真。
551
+ """
522
552
  rid = str(run_id or "")
523
553
  if not rid:
524
554
  return
525
555
  with _LOCK:
526
- ids = _INJECTED.pop(rid, None)
527
- if not ids:
556
+ registered = {str(i) for i in (_INJECTED.pop(rid, None) or [])}
557
+ if not registered or passed is None:
558
+ return
559
+ src = reward if passed else penalize
560
+ targets = set()
561
+ for i in (src or []):
562
+ if str(i) in registered:
563
+ targets.add(str(i))
564
+ if not targets:
528
565
  return
566
+ key = "won" if passed else "lost"
529
567
  data = _load()
530
- idset = set(ids)
568
+ changed = False
531
569
  for it in (data.get("lessons") or []):
532
- if it.get("id") in idset:
533
- key = "won" if passed else "lost"
570
+ if it.get("id") in targets:
534
571
  it[key] = int(it.get(key) or 0) + 1
535
- _save(data)
572
+ changed = True
573
+ if changed:
574
+ _save(data)
536
575
 
537
576
 
538
577
  def bump_hits(ids):
@@ -558,7 +597,9 @@ LEARN_PROMPT = """你是编排系统的复盘官。下面是刚结束的一次
558
597
  记忆借鉴:任务一次通过且分数高时,把「这次做对了什么」提炼成可复用步骤,title
559
598
  以「做法:」开头,如「做法:先列评分点再逐条应答」)。
560
599
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
561
- {"lessons": [{"title": "≤14 字的归类(教训直接写;有效做法以「做法:」开头)", "category": "问题分类", "content": "下次必须怎么做/避免什么(≤120 字,具体可执行)"}]}
600
+ {"lessons": [{"title": "≤14 字的归类(教训直接写;有效做法以「做法:」开头)", "category": "问题分类", "content": "下次必须怎么做/避免什么(≤120 字,具体可执行)", "violates": ["sk-xxxxxx"]}]}
601
+ violates:本次问题**戳穿了下面「本次已注入的历史教训」中的哪几条**(那几条已在提示词里却没防住),
602
+ 只能填下面列出的编号原值,不确定或没有对应就填 []。
562
603
  category 必须从以下固定枚举中选一个(贴合评审维度,不要自创类别):
563
604
  __CATEGORIES__
564
605
  最多 5 条,只保留反复出现或影响过稿/验收的关键项;一次通过的高分运行优先提炼「做法」;没有值得沉淀的就返回空数组。
@@ -569,6 +610,9 @@ __TYPE__
569
610
  ## 任务目标(摘要)
570
611
  __GOAL__
571
612
 
613
+ ## 本次已注入的历史教训(编号 + 标题;violates 只能引用这里的编号)
614
+ __INJECTED__
615
+
572
616
  ## 本次结论
573
617
  __VERDICT__
574
618
 
@@ -610,6 +654,69 @@ def _collect_issues(run):
610
654
  return issues[:30]
611
655
 
612
656
 
657
+ _DIM_SEP = re.compile(r"[、,,/;;\s\.\-—]+")
658
+
659
+
660
+ def _issue_cats(issue):
661
+ """把一条评审问题归到闭集分类集合。dim 可多选(「钩子、爽点」),
662
+ 逐段归一化;都命中不了再退到 note 正文关键词(与沉淀路径同一套度量)。"""
663
+ out = set()
664
+ for part in _DIM_SEP.split(str(issue.get("dim") or "")):
665
+ c = _normalize_category(part, part)
666
+ if c:
667
+ out.add(c)
668
+ if not out:
669
+ c = _category_from(issue.get("note"))
670
+ if c:
671
+ out.add(c)
672
+ return out
673
+
674
+
675
+ def _lesson_cats(lesson):
676
+ """教训侧的类别集合:category 枚举优先,退到 dim,再退到标题关键词。
677
+ 刻意不扫正文——长文本首个关键词命中即胜,归因会退化成噪声匹配。"""
678
+ c = (_normalize_category(lesson.get("category"), lesson.get("dim"))
679
+ or _category_from(lesson.get("title")))
680
+ return {c} if c else set()
681
+
682
+
683
+ def _collect_strengths(run):
684
+ """本次达标(≥阈值)的维度:过审 run 的正向归因依据——教训守的维度
685
+ 这次分数站得住,才记一次 won。"""
686
+ v = run.get("verdict") or {}
687
+ thr = float(v.get("threshold") or 7.0)
688
+ good = {}
689
+ for c in (v.get("chapter_scores") or []):
690
+ for d, s in (c.get("means") or {}).items():
691
+ if float(s) >= thr:
692
+ good[d] = good.get(d, 0) + 1
693
+ for d, s in (v.get("global_scores") or {}).items():
694
+ if float(s) >= thr:
695
+ good[d] = good.get(d, 0) + 1
696
+ return [{"dim": d} for d in sorted(good, key=lambda k: (-good[k], k))]
697
+
698
+
699
+ def _attribute(registered, by_id, issues, strengths):
700
+ """整批 +/- → 单条归因(A 路:类别匹配,零模型依赖)。
701
+ 返回 (penalize, reward):与本次**问题**同类别的教训挨罚;与本次**达标维度**
702
+ 同类别且不属于问题类别的教训记功。归不了类的教训两侧都不动。"""
703
+ icats, scats = set(), set()
704
+ for i in issues:
705
+ icats |= _issue_cats(i)
706
+ for s in strengths:
707
+ scats |= _issue_cats(s)
708
+ penalize, reward = [], []
709
+ for rid in registered:
710
+ cats = _lesson_cats(by_id.get(rid) or {})
711
+ if not cats:
712
+ continue
713
+ if cats & icats:
714
+ penalize.append(rid)
715
+ elif cats & scats:
716
+ reward.append(rid)
717
+ return penalize, reward
718
+
719
+
613
720
  def _fallback_lessons(task, run):
614
721
  """无编排者时的确定性兜底:按维度把反复出现的问题聚成教训。"""
615
722
  v = run.get("verdict") or {}
@@ -634,65 +741,107 @@ def _fallback_lessons(task, run):
634
741
  return out[:5]
635
742
 
636
743
 
744
+ def _run_outcome(run):
745
+ """把评审结论折成三态结局:True 过审 / False 未过审 / None 无从判定。
746
+
747
+ 旧实现是 `bool(verdict.get("pass"))`,把「没有结论」直接判成「质量不通过」。
748
+ 实跑 110 个 run 里 75 个根本没有 verdict(61 failed / 14 done / 4 running /
749
+ 1 cancelled / 1 timeout),每一个都在给整批注入教训白记一笔 lost。基础设施类
750
+ 失败(换路耗尽、超时、取消)不是质量信号,这点统一执行标准里也写了:取消不得
751
+ 进入任何惩罚。判不出来就返回 None,宁可这次不归因。
752
+ """
753
+ if (run.get("status") or "") != "done":
754
+ return None
755
+ v = run.get("verdict") or {}
756
+ if not v:
757
+ return None
758
+ for key in ("pass", "publishable"): # 单章评审用 pass,连载用 publishable
759
+ val = v.get(key)
760
+ if isinstance(val, bool):
761
+ return val
762
+ if isinstance(v.get("verify_pass"), bool) or isinstance(v.get("review_pass"), bool):
763
+ return bool(v.get("verify_pass")) and bool(v.get("review_pass"))
764
+ gp = v.get("global_pass")
765
+ return gp if isinstance(gp, bool) else None
766
+
767
+
637
768
  def learn_from_run(run_id, use_orchestrator=True):
638
- """运行结束后自动总结教训并沉淀。返回写入条数。"""
769
+ """运行结束后自动总结教训并沉淀。返回写入条数。
770
+
771
+ 胜负清算放在 finally:无论是否沉淀出新教训、编排者是否可用,本 run 的注入登记
772
+ 都必须被消费掉(旧实现把落账放最前就是为了「一定清算」,改成精确归因后同样不能漏,
773
+ 且登记不消费会一直泄漏内存)。
774
+ """
639
775
  from . import store
640
776
  run = store.get_run(run_id)
641
777
  if not run:
642
778
  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
779
+ verdict = run.get("verdict") or {}
780
+ outcome = _run_outcome(run)
781
+ registered = list(_INJECTED.get(str(run_id)) or [])
782
+ by_id = {x.get("id"): x for x in list_lessons()} if registered else {}
660
783
  issues = _collect_issues(run)
661
- lessons = []
662
- if use_orchestrator:
784
+ cited = set() # B 路归因:复盘官点名「已注入却没防住本次问题」的教训编号
785
+ try:
786
+ task = store.get_task(run.get("task_id")) if run.get("task_id") else None
787
+ if not task:
788
+ return 0
789
+ # mock 运行不沉淀(没有真实评审信号)
790
+ if all((s.get("agent") or "").startswith("mock") for s in (run.get("steps") or [])):
791
+ return 0
792
+ v = verdict
793
+ if not v:
794
+ return 0
795
+ lessons = []
796
+ if use_orchestrator:
797
+ try:
798
+ from . import modelhub, runner
799
+ orch = modelhub.resolve_orchestrator()
800
+ if orch:
801
+ prov, model = orch
802
+ verdict_txt = json.dumps({k: v[k] for k in v if k not in ("route", "chapter_scores")},
803
+ ensure_ascii=False)[:1200]
804
+ injected_txt = "\n".join(
805
+ "- [%s] %s" % (lid, (by_id.get(lid) or {}).get("title", ""))
806
+ for lid in registered) or "(本次未注入历史教训,violates 一律填 [])"
807
+ prompt = (LEARN_PROMPT.replace("__CATEGORIES__", "、".join(LESSON_CATEGORIES))
808
+ .replace("__TYPE__", str(task.get("type")))
809
+ .replace("__GOAL__", (task.get("goal") or "")[:600])
810
+ .replace("__INJECTED__", injected_txt)
811
+ .replace("__VERDICT__", verdict_txt)
812
+ .replace("__ISSUES__",
813
+ "\n".join("- %s" % i.get("note", "") for i in issues)[:3000] or "(无)"))
814
+ # 推理模型的思考会吞掉全部预算:max_tokens 给足才有正文可解析
815
+ res = modelhub.chat(prov["id"], model, prompt, max_tokens=8000, timeout=300)
816
+ if res.get("ok"):
817
+ data = runner.extract_json(res.get("text") or "")
818
+ raw = (data or {}).get("lessons") if isinstance(data, dict) else None
819
+ if isinstance(raw, list):
820
+ for x in raw[:5]:
821
+ if isinstance(x, dict) and x.get("title") and x.get("content"):
822
+ lessons.append({"title": str(x["title"]), "content": str(x["content"]),
823
+ "category": x.get("category")})
824
+ for lid in (x.get("violates") or [])[:MAX_LESSONS_INJECT]:
825
+ cited.add(str(lid).strip())
826
+ except Exception:
827
+ lessons = []
828
+ if not lessons:
829
+ lessons = _fallback_lessons(task, run)
830
+ n = 0
831
+ for x in lessons:
832
+ if upsert_lesson(task.get("type") or "*", x["title"], x["content"], source=run_id,
833
+ category=x.get("category"), dim=x.get("dim")):
834
+ n += 1
835
+ return n
836
+ finally:
663
837
  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")})
838
+ # A 路(类别匹配)+ B 路(模型点名)取并集;两者都落空时本次不落账。
839
+ penalize, reward = _attribute(registered, by_id, issues, _collect_strengths(run))
840
+ named = set(penalize) | (cited & set(registered)) # 编造/已挤出 top-k 的编号不认账
841
+ note_outcome(run_id, outcome, penalize=sorted(named),
842
+ reward=[i for i in reward if i not in named])
686
843
  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
844
+ pass
696
845
 
697
846
 
698
847
  def learn_async(run_id):
@@ -1,133 +1,133 @@
1
- # -*- coding: utf-8 -*-
2
- """Step 执行器:压缩触发的重试守门(复用已渲染 prompt,不重复组装)。
3
-
4
- 设计稿:docs/migration/02-context-compaction.md §1D。
5
- 参考 dsh packages/core/agent-loop/src/agent.ts:307-378:
6
- 失败重试只在「前进 surface generation」时返回 {kind:'retry'},
7
- 复用已渲染 PromptAssembly,不重跑 pre-step / 组装 / 用户消息准入。
8
-
9
- Tutti 语境:runner.run_agent 自带 prompt(已渲染);重试的风险不是重复渲染
10
- (prompt 是同一个字符串),而是「不压缩直接重试」必然再次撑爆。
11
- 本模块的守门:仅当 maybe_compact 真正前进了 surface generation 才重试一次。
12
- """
13
- from __future__ import annotations
14
-
15
- import logging
16
-
17
- from .compaction import maybe_compact, DEFAULT_PRESSURE_THRESHOLD
18
- from .error_codes import ErrorCode
19
-
20
- log = logging.getLogger(__name__)
21
-
22
- # 触发「压缩后重试」的错误码
23
- _OVERFLOW_CODES = {ErrorCode.CONTEXT_OVERFLOW, ErrorCode.MAX_TOKENS}
24
-
25
- # 事前预检阈值:最近一次调用的上下文锚点已占本步模型容量九成 → 第一个请求
26
- # 大概率被供应商拒。只拦「大概率必死」,不提前压缩平时的高压(used() 是累计
27
- # 口径,比真实上下文偏大;0.8 的响应式阈值语义与此不同,勿混用)。
28
- _PRECHECK_RATIO = 0.9
29
-
30
-
31
- def _v2_compaction_tuning():
32
- """settings_v2 orchestrator.compaction 微调 → (threshold|None, retain|None)。
33
-
34
- None = 未配置(走 maybe_compact 的模块默认);读取/注册失败静默回落。
35
- retain 允许 0(尾部不保留),与 None(未配置)语义不同,勿合并。"""
36
- try:
37
- from .settings_schema import get as ss_get, register_default_namespaces
38
- register_default_namespaces()
39
- c = (ss_get("orchestrator") or {}).get("compaction") or {}
40
- th = c.get("pressure_threshold")
41
- rt = c.get("retain_tail_tokens")
42
- return (float(th) if th else None,
43
- int(rt) if rt is not None else None)
44
- except Exception:
45
- return None, None
46
-
47
-
48
- def execute_step(session, run_agent_fn, prompt, *, model: str = "",
49
- llm_caller=None, retain_tail_tokens=None, **kwargs):
50
- """执行一次 step;撑爆时压缩并守门重试一次。
51
-
52
- 事前预检(借鉴 freebuff base-chat 每步容量重估):换将切到小窗口模型后,
53
- 响应式路径要等供应商报 CONTEXT_OVERFLOW 才压缩——报得不干净就直接卡死。
54
- 先用「最近一次调用上下文 vs 本步模型容量」判一次,超阈先压缩再发。
55
-
56
- Args:
57
- session: session_log.Session(记录 replace_generation)
58
- run_agent_fn: runner.run_agent(或测试替身),签名 (prompt, **kwargs) -> dict
59
- prompt: 已渲染的提示词(重试时原样复用,不重新渲染)
60
- model: 本步将要使用的模型名(容量与压力估算都按它算)
61
- llm_caller: 压缩摘要 LLM(None = 不压缩,永不重试)
62
- **kwargs: 透传 run_agent_fn
63
-
64
- Returns:
65
- (result, retried: bool)
66
- """
67
- v2_th, v2_rt = _v2_compaction_tuning()
68
- compact_kwargs = {}
69
- if retain_tail_tokens is not None:
70
- compact_kwargs["retain_tail_tokens"] = retain_tail_tokens
71
- elif v2_rt is not None:
72
- compact_kwargs["retain_tail_tokens"] = v2_rt
73
- if v2_th is not None:
74
- compact_kwargs["threshold"] = v2_th
75
- if llm_caller is not None and model:
76
- try:
77
- from .token_meter import token_meter
78
- ctx = token_meter.last_context(session.run_id)
79
- cap = token_meter.capacity(model)
80
- if ctx > 0 and cap > 0 and ctx > cap * _PRECHECK_RATIO:
81
- gen_p0 = session.replace_generation()
82
- if maybe_compact(session, model=model, llm_caller=llm_caller,
83
- run_id=session.run_id, **compact_kwargs) \
84
- and session.replace_generation() > gen_p0:
85
- log.info("precheck: last ctx~%d > %.0f%% of cap(%s)=%d, "
86
- "compacted before step", ctx, _PRECHECK_RATIO * 100,
87
- model, cap)
88
- except Exception:
89
- log.debug("precheck skipped", exc_info=True)
90
- gen0 = session.replace_generation()
91
- result = run_agent_fn(prompt, **kwargs)
92
- code = result.get("error_code") or ""
93
- if code not in _OVERFLOW_CODES or llm_caller is None:
94
- return result, False
95
- # 撑爆 → 尝试压缩
96
- compacted = maybe_compact(session, model=model, llm_caller=llm_caller,
97
- run_id=session.run_id, **compact_kwargs)
98
- gen1 = session.replace_generation()
99
- if not compacted or gen1 <= gen0:
100
- # 压缩未发生或未前进 generation:重试必然再次撑爆,不重试
101
- log.info("overflow but no compaction progress (gen %s->%s), no retry",
102
- gen0, gen1)
103
- return result, False
104
- # 压缩生效:surface 已折叠。**真正替换 CLI 请求上下文**(2026-09-21
105
- # 清最老待深挖项:此前重试仍发原 prompt + 续旧 resume 会话——摘要只进
106
- # 审计日志,CLI 看到的上下文一点没小,重试必然再爆):
107
- # ①重试 prompt 改用 session 派生转录(折叠摘要 + 近尾消息,体量骤降);
108
- # ②弃 resume(旧会话本体仍是膨胀态,续上就前功尽弃)。
109
- msgs = []
110
- try:
111
- msgs = session.derive_messages()
112
- except Exception:
113
- log.debug("derive_messages failed on retry", exc_info=True)
114
- kwargs2 = dict(kwargs)
115
- if msgs:
116
- transcript = "\n\n".join("[%s] %s" % (m.get("role"), m.get("content"))
117
- for m in msgs)
118
- if len(transcript) > 24_000:
119
- transcript = transcript[-24_000:]
120
- retry_prompt = transcript
121
- # 本步指令必须在场:被折叠/截断掉就显式补在尾部(缺失指令比重复严重)
122
- tail = transcript[-2000:]
123
- if prompt[:400] not in tail:
124
- retry_prompt = transcript + "\n\n" + prompt
125
- kwargs2.pop("resume", None)
126
- log.info("compaction advanced gen %s->%s, retrying with derived "
127
- "context (%d msgs, resume dropped)", gen0, gen1, len(msgs))
128
- result2 = run_agent_fn(retry_prompt, **kwargs2)
129
- else:
130
- log.info("compaction advanced gen %s->%s but no surface derived, "
131
- "retrying same prompt", gen0, gen1)
132
- result2 = run_agent_fn(prompt, **kwargs)
1
+ # -*- coding: utf-8 -*-
2
+ """Step 执行器:压缩触发的重试守门(复用已渲染 prompt,不重复组装)。
3
+
4
+ 设计稿:docs/migration/02-context-compaction.md §1D。
5
+ 参考 dsh packages/core/agent-loop/src/agent.ts:307-378:
6
+ 失败重试只在「前进 surface generation」时返回 {kind:'retry'},
7
+ 复用已渲染 PromptAssembly,不重跑 pre-step / 组装 / 用户消息准入。
8
+
9
+ Tutti 语境:runner.run_agent 自带 prompt(已渲染);重试的风险不是重复渲染
10
+ (prompt 是同一个字符串),而是「不压缩直接重试」必然再次撑爆。
11
+ 本模块的守门:仅当 maybe_compact 真正前进了 surface generation 才重试一次。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ from .compaction import maybe_compact, DEFAULT_PRESSURE_THRESHOLD
18
+ from .error_codes import ErrorCode
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # 触发「压缩后重试」的错误码
23
+ _OVERFLOW_CODES = {ErrorCode.CONTEXT_OVERFLOW, ErrorCode.MAX_TOKENS}
24
+
25
+ # 事前预检阈值:最近一次调用的上下文锚点已占本步模型容量九成 → 第一个请求
26
+ # 大概率被供应商拒。只拦「大概率必死」,不提前压缩平时的高压(used() 是累计
27
+ # 口径,比真实上下文偏大;0.8 的响应式阈值语义与此不同,勿混用)。
28
+ _PRECHECK_RATIO = 0.9
29
+
30
+
31
+ def _v2_compaction_tuning():
32
+ """settings_v2 orchestrator.compaction 微调 → (threshold|None, retain|None)。
33
+
34
+ None = 未配置(走 maybe_compact 的模块默认);读取/注册失败静默回落。
35
+ retain 允许 0(尾部不保留),与 None(未配置)语义不同,勿合并。"""
36
+ try:
37
+ from .settings_schema import get as ss_get, register_default_namespaces
38
+ register_default_namespaces()
39
+ c = (ss_get("orchestrator") or {}).get("compaction") or {}
40
+ th = c.get("pressure_threshold")
41
+ rt = c.get("retain_tail_tokens")
42
+ return (float(th) if th else None,
43
+ int(rt) if rt is not None else None)
44
+ except Exception:
45
+ return None, None
46
+
47
+
48
+ def execute_step(session, run_agent_fn, prompt, *, model: str = "",
49
+ llm_caller=None, retain_tail_tokens=None, **kwargs):
50
+ """执行一次 step;撑爆时压缩并守门重试一次。
51
+
52
+ 事前预检(借鉴 freebuff base-chat 每步容量重估):换将切到小窗口模型后,
53
+ 响应式路径要等供应商报 CONTEXT_OVERFLOW 才压缩——报得不干净就直接卡死。
54
+ 先用「最近一次调用上下文 vs 本步模型容量」判一次,超阈先压缩再发。
55
+
56
+ Args:
57
+ session: session_log.Session(记录 replace_generation)
58
+ run_agent_fn: runner.run_agent(或测试替身),签名 (prompt, **kwargs) -> dict
59
+ prompt: 已渲染的提示词(重试时原样复用,不重新渲染)
60
+ model: 本步将要使用的模型名(容量与压力估算都按它算)
61
+ llm_caller: 压缩摘要 LLM(None = 不压缩,永不重试)
62
+ **kwargs: 透传 run_agent_fn
63
+
64
+ Returns:
65
+ (result, retried: bool)
66
+ """
67
+ v2_th, v2_rt = _v2_compaction_tuning()
68
+ compact_kwargs = {}
69
+ if retain_tail_tokens is not None:
70
+ compact_kwargs["retain_tail_tokens"] = retain_tail_tokens
71
+ elif v2_rt is not None:
72
+ compact_kwargs["retain_tail_tokens"] = v2_rt
73
+ if v2_th is not None:
74
+ compact_kwargs["threshold"] = v2_th
75
+ if llm_caller is not None and model:
76
+ try:
77
+ from .token_meter import token_meter
78
+ ctx = token_meter.last_context(session.run_id)
79
+ cap = token_meter.capacity(model)
80
+ if ctx > 0 and cap > 0 and ctx > cap * _PRECHECK_RATIO:
81
+ gen_p0 = session.replace_generation()
82
+ if maybe_compact(session, model=model, llm_caller=llm_caller,
83
+ run_id=session.run_id, **compact_kwargs) \
84
+ and session.replace_generation() > gen_p0:
85
+ log.info("precheck: last ctx~%d > %.0f%% of cap(%s)=%d, "
86
+ "compacted before step", ctx, _PRECHECK_RATIO * 100,
87
+ model, cap)
88
+ except Exception:
89
+ log.debug("precheck skipped", exc_info=True)
90
+ gen0 = session.replace_generation()
91
+ result = run_agent_fn(prompt, **kwargs)
92
+ code = result.get("error_code") or ""
93
+ if code not in _OVERFLOW_CODES or llm_caller is None:
94
+ return result, False
95
+ # 撑爆 → 尝试压缩
96
+ compacted = maybe_compact(session, model=model, llm_caller=llm_caller,
97
+ run_id=session.run_id, **compact_kwargs)
98
+ gen1 = session.replace_generation()
99
+ if not compacted or gen1 <= gen0:
100
+ # 压缩未发生或未前进 generation:重试必然再次撑爆,不重试
101
+ log.info("overflow but no compaction progress (gen %s->%s), no retry",
102
+ gen0, gen1)
103
+ return result, False
104
+ # 压缩生效:surface 已折叠。**真正替换 CLI 请求上下文**(2026-09-21
105
+ # 清最老待深挖项:此前重试仍发原 prompt + 续旧 resume 会话——摘要只进
106
+ # 审计日志,CLI 看到的上下文一点没小,重试必然再爆):
107
+ # ①重试 prompt 改用 session 派生转录(折叠摘要 + 近尾消息,体量骤降);
108
+ # ②弃 resume(旧会话本体仍是膨胀态,续上就前功尽弃)。
109
+ msgs = []
110
+ try:
111
+ msgs = session.derive_messages()
112
+ except Exception:
113
+ log.debug("derive_messages failed on retry", exc_info=True)
114
+ kwargs2 = dict(kwargs)
115
+ if msgs:
116
+ transcript = "\n\n".join("[%s] %s" % (m.get("role"), m.get("content"))
117
+ for m in msgs)
118
+ if len(transcript) > 24_000:
119
+ transcript = transcript[-24_000:]
120
+ retry_prompt = transcript
121
+ # 本步指令必须在场:被折叠/截断掉就显式补在尾部(缺失指令比重复严重)
122
+ tail = transcript[-2000:]
123
+ if prompt[:400] not in tail:
124
+ retry_prompt = transcript + "\n\n" + prompt
125
+ kwargs2.pop("resume", None)
126
+ log.info("compaction advanced gen %s->%s, retrying with derived "
127
+ "context (%d msgs, resume dropped)", gen0, gen1, len(msgs))
128
+ result2 = run_agent_fn(retry_prompt, **kwargs2)
129
+ else:
130
+ log.info("compaction advanced gen %s->%s but no surface derived, "
131
+ "retrying same prompt", gen0, gen1)
132
+ result2 = run_agent_fn(prompt, **kwargs)
133
133
  return result2, True