codebee 0.1.70 → 0.1.72

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.
@@ -23,7 +23,7 @@ import re
23
23
  import threading
24
24
  import time
25
25
 
26
- from .. import operations, paths
26
+ from .. import operations, paths, quality_gate
27
27
  from . import fanqie, flow, ledger, qimao
28
28
  from .browser import Browser, BrowserError, Page
29
29
 
@@ -73,6 +73,37 @@ def _st(plat):
73
73
  return (_state.get("platforms") or {}).get(plat) or {}
74
74
 
75
75
 
76
+ def _quality_release_guard(task, plat, action="publish", target_chapter=None,
77
+ force=False, force_confirmed=False, force_reason=""):
78
+ """Return a release decision backed by the latest completed review.
79
+
80
+ Non-fiction/code tasks keep their existing publish workflow; novel release
81
+ actions must carry a verifiable quality verdict. This keeps the gate
82
+ focused on the signing failure mode without breaking generic file publish.
83
+ """
84
+ if str(task.get("type") or "").lower() not in ("novel", "serial_novel") \
85
+ and (task.get("engine") or "") != "review":
86
+ return {"allowed": True, "status": "not_applicable", "blockers": [],
87
+ "warnings": [], "forced": False}
88
+ from .. import store
89
+ verdict = None
90
+ for run in sorted(store.task_runs(task.get("id")) or [],
91
+ key=lambda item: str(item.get("id") or ""), reverse=True):
92
+ if run.get("status") == "done" and isinstance(run.get("verdict"), dict):
93
+ verdict = run["verdict"]
94
+ break
95
+ decision = quality_gate.evaluate_release(
96
+ verdict or {}, action=action, target_chapter=target_chapter,
97
+ platform_version=ledger.book_for(task.get("id"), plat) or {},
98
+ force=force, force_confirmed=force_confirmed, force_reason=force_reason)
99
+ decision["version_snapshot"] = quality_gate.local_version(task.get("workdir") or "")
100
+ decision["platform_snapshot"] = ledger.book_for(task.get("id"), plat) or {}
101
+ if verdict:
102
+ decision["quality_status"] = verdict.get("quality_status") or "continue_only"
103
+ decision["review_run_id"] = run.get("id")
104
+ return decision
105
+
106
+
76
107
  def view():
77
108
  """前端状态视图:平台状态 + 是否找到浏览器 + 作品登记。"""
78
109
  from .browser import find_browser
@@ -381,7 +412,8 @@ def _create_preflight(plat, data):
381
412
  return ""
382
413
 
383
414
 
384
- def create_book_async(task_id, plat, auto_submit=False):
415
+ def create_book_async(task_id, plat, auto_submit=False, force=False,
416
+ force_confirmed=False, force_reason=""):
385
417
  """按任务 book_meta 的资料在平台建书。返回 (ok, err)。"""
386
418
  from .. import store
387
419
  if plat not in PLATFORMS:
@@ -389,16 +421,32 @@ def create_book_async(task_id, plat, auto_submit=False):
389
421
  task = store.get_task(task_id)
390
422
  if not task:
391
423
  return False, "任务不存在"
392
- meta = ((task.get("book_meta") or {}).get(plat) or {})
424
+ # 连载链沿链继承(都是用第一个):续写批次从自己身上建书也用首批生成的资料
425
+ meta = store.inherited_book_meta(task, plat)
393
426
  if meta.get("status") != "done" or not meta.get("data"):
394
427
  return False, "请先生成该平台的作品信息"
395
428
  if _st(plat).get("status") == "busy":
396
429
  return False, "该平台有操作正在进行中"
397
- if ledger.book_for(task_id, plat):
398
- return False, "该任务已在此平台登记过作品,请直接发章"
430
+ existing = ledger.book_for(task_id, plat)
431
+ # A title-only manual registration is usable as a binding. For legacy
432
+ # entries without source metadata, only a recorded failed create-book
433
+ # attempt is safe to retry; otherwise it may be a real manual binding.
434
+ if existing:
435
+ if existing.get("book_id") or existing.get("source") == "manual":
436
+ return False, "该任务已在此平台登记过作品,请直接发章"
437
+ failed_create = any(
438
+ r.get("action") == "create_book" and not r.get("ok")
439
+ for r in ledger.recent(task_id=task_id, platform=plat, limit=50))
440
+ if not failed_create:
441
+ return False, "该任务已有作品登记但未确认,请登记已有作品或先在平台核对"
399
442
  why = _create_preflight(plat, meta["data"])
400
443
  if why:
401
444
  return False, why
445
+ quality = _quality_release_guard(
446
+ task, plat, action="publish", force=force,
447
+ force_confirmed=force_confirmed, force_reason=force_reason)
448
+ if not quality.get("allowed"):
449
+ return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
402
450
  ok_login, why = _login_guard(plat)
403
451
  if not ok_login:
404
452
  return False, why
@@ -458,7 +506,8 @@ def create_book_async(task_id, plat, auto_submit=False):
458
506
  shot=str(ledger.shot_path(plat, task_id, "")),
459
507
  operation_id=operation_id, operation_status=operation_status,
460
508
  remote_receipt=book_id)
461
- ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name})
509
+ ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name,
510
+ "source": "create"})
462
511
  _set(plat, status="connected", error="")
463
512
  except Exception as e:
464
513
  operations.finish_exception(operation_id, e)
@@ -498,7 +547,7 @@ def register_book(task_id, plat, title, book_id=""):
498
547
  remote_review=None, remote_rejected=None,
499
548
  remote_synced_at=None, remote_error=None)
500
549
  ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
501
- "title": title[:120]})
550
+ "title": title[:120], "source": "manual"})
502
551
  return True, ""
503
552
 
504
553
 
@@ -608,7 +657,8 @@ def read_chapter(fp):
608
657
  return ch_no, title, body, ""
609
658
 
610
659
 
611
- def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
660
+ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
661
+ force=False, force_confirmed=False, force_reason=""):
612
662
  """把一章发到平台(或填好待人工确认)。幂等:已成功发布的章号拒绝重发。"""
613
663
  from .. import store
614
664
  if plat not in PLATFORMS:
@@ -619,6 +669,8 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
619
669
  book = ledger.book_for(task_id, plat)
620
670
  if not book:
621
671
  return False, "该任务尚未在此平台建书,请先「创建作品」"
672
+ if not book.get("book_id") and book.get("source") != "manual":
673
+ return False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
622
674
  # 闭包中需要在找回 book_id 后更新书籍引用;使用独立副本避免
623
675
  # Python 将 book 误判为 run() 的局部变量,导致前置解析触发
624
676
  # UnboundLocalError。
@@ -628,6 +680,11 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
628
680
  ch_no, title, body, err = read_chapter(chapter_file)
629
681
  if err:
630
682
  return False, err
683
+ quality = _quality_release_guard(
684
+ task, plat, action="publish", target_chapter=ch_no or None,
685
+ force=force, force_confirmed=force_confirmed, force_reason=force_reason)
686
+ if not quality.get("allowed"):
687
+ return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
631
688
  n_chars = len(body.replace("\n", "").replace(" ", ""))
632
689
  if n_chars < 100:
633
690
  return False, "正文过短(%d 字),疑似未完成章节" % n_chars
@@ -665,16 +722,27 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
665
722
  values.update(_url_values(mod, book_ref))
666
723
  ledger.save_book(task_id, plat, book_ref)
667
724
  logs.append("已按书名找回 book_id=%s 并更新登记" % bid)
725
+ elif not book_ref.get("book_id"):
726
+ raise RuntimeError("未按作品名找到远端作品,未打开章节编辑器;"
727
+ "请检查平台作品名或补填作品 ID")
668
728
  flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
669
729
  config=mod.CONFIG, auto_submit=auto_submit,
670
730
  shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
671
731
  log=logs.append)
672
- operations.confirm(operation_id, metadata={"platform": plat,
673
- "action": "upload_chapter",
674
- "chapter_no": ch_no})
675
- ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
676
- book_id=book_ref.get("book_id") or "", title=title, ok=True,
677
- operation_id=operation_id, operation_status="confirmed")
732
+ if auto_submit:
733
+ operations.confirm(operation_id, metadata={"platform": plat,
734
+ "action": "upload_chapter",
735
+ "chapter_no": ch_no})
736
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=ch_no,
737
+ book_id=book_ref.get("book_id") or "", title=title, ok=True,
738
+ operation_id=operation_id, operation_status="confirmed")
739
+ else:
740
+ # run_flow stops immediately before the submit step. Keep the
741
+ # operation pending and never count the filled form as published.
742
+ ledger.record(plat, "upload_chapter_pending", task_id=task_id,
743
+ chapter_no=ch_no, book_id=book_ref.get("book_id") or "",
744
+ title=title, ok=False, error="等待人工提交",
745
+ operation_id=operation_id, operation_status="pending")
678
746
  _set(plat, status="connected", error="")
679
747
  except Exception as e:
680
748
  operations.finish_exception(operation_id, e)
@@ -692,6 +760,45 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
692
760
  return True, ""
693
761
 
694
762
 
763
+ def confirm_manual_chapter(task_id, plat, chapter_no):
764
+ """Record an explicit user confirmation after submitting in the browser."""
765
+ from .. import store
766
+ if plat not in PLATFORMS:
767
+ return False, "未知平台"
768
+ if not store.get_task(task_id):
769
+ return False, "任务不存在"
770
+ try:
771
+ chapter_no = int(chapter_no or 0)
772
+ except (TypeError, ValueError):
773
+ chapter_no = 0
774
+ if chapter_no <= 0:
775
+ return False, "章号无效"
776
+ if chapter_no in ledger.published_chapters(task_id, plat):
777
+ return True, ""
778
+ pending = next((r for r in ledger.recent(task_id=task_id, platform=plat, limit=100)
779
+ if r.get("action") == "upload_chapter_pending"
780
+ and int(r.get("chapter_no") or 0) == chapter_no), None)
781
+ if not pending:
782
+ return False, "没有找到待确认的填稿记录,请先重新填入该章"
783
+ op_id = pending.get("operation_id") or ""
784
+ current = operations.get(op_id) if op_id else None
785
+ if current and current.get("status") == "pending":
786
+ ok = operations.confirm(op_id, remote_receipt="manual-confirmed",
787
+ metadata={"platform": plat, "action": "upload_chapter"})
788
+ elif current:
789
+ ok = operations.reconcile(op_id, "confirmed", remote_receipt="manual-confirmed",
790
+ metadata={"platform": plat, "action": "upload_chapter"})
791
+ else:
792
+ ok = True
793
+ if not ok:
794
+ return False, "待确认操作状态已变化,请重新校准后再试"
795
+ ledger.record(plat, "upload_chapter", task_id=task_id, chapter_no=chapter_no,
796
+ book_id=pending.get("book_id") or "", title=pending.get("title") or "",
797
+ ok=True, operation_id=op_id, operation_status="confirmed",
798
+ remote_receipt="manual-confirmed")
799
+ return True, ""
800
+
801
+
695
802
  # ------------------------------------------------ 已发章数校准(平台实况对账)
696
803
  # 台账只记得 CodeBee 自己发成功的章:用户在平台窗口里手工补交/平台驳回/
697
804
  # 台账重复记录都会让「已发 N」漂移(2026-09-28 实案:番茄平台 8 章台账 7)。
@@ -0,0 +1,194 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Deterministic quality and release gates for generated fiction.
3
+
4
+ LLM reviewers provide judgements; this module decides whether the evidence is
5
+ valid enough to support a release action. It deliberately treats missing,
6
+ malformed, out-of-range and non-consensus scores as unevaluated.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from pathlib import Path
13
+
14
+
15
+ MIN_REVIEWERS = 2
16
+ DEFAULT_MAX_SPREAD = 2.0
17
+ _CHAPTER_RE = re.compile(r"^chapter-(\d{1,4})\.(?:md|txt)$", re.I)
18
+
19
+
20
+ def normalize_scores(raw, dimensions):
21
+ """Return (scores, errors); incomplete scores are invalid as a set."""
22
+ raw = raw if isinstance(raw, dict) else {}
23
+ scores, errors = {}, {}
24
+ for dim in dimensions or []:
25
+ value = raw.get(dim)
26
+ try:
27
+ number = float(value)
28
+ except (TypeError, ValueError):
29
+ errors[dim] = "非数字"
30
+ continue
31
+ if not math.isfinite(number) or number < 1.0 or number > 10.0:
32
+ errors[dim] = "必须在 1-10 之间"
33
+ continue
34
+ scores[dim] = round(number, 2)
35
+ if errors or len(scores) != len(list(dimensions or [])):
36
+ return {}, errors or {str(d): "缺失" for d in dimensions or [] if d not in scores}
37
+ return scores, {}
38
+
39
+
40
+ def aggregate_reviews(reviews, dimensions, threshold=7.0,
41
+ min_reviewers=MIN_REVIEWERS,
42
+ max_spread=DEFAULT_MAX_SPREAD):
43
+ """Validate reviewers, calculate means, and require reviewer consensus."""
44
+ dims = list(dimensions or [])
45
+ valid = []
46
+ invalid = []
47
+ seen = set()
48
+ for idx, review in enumerate(reviews or []):
49
+ review = review if isinstance(review, dict) else {}
50
+ reviewer_id = str(review.get("id") or "reviewer-%d" % idx)
51
+ if reviewer_id in seen:
52
+ continue
53
+ seen.add(reviewer_id)
54
+ scores, errors = normalize_scores(review.get("scores"), dims)
55
+ if errors:
56
+ invalid.append({"id": reviewer_id, "errors": errors})
57
+ else:
58
+ valid.append({"id": reviewer_id, "scores": scores})
59
+
60
+ reasons = []
61
+ eligible = len(valid) >= int(min_reviewers)
62
+ if not eligible:
63
+ reasons.append("至少需要 %d 名有效评审,当前 %d 名" %
64
+ (int(min_reviewers), len(valid)))
65
+ means = {}
66
+ spreads = {}
67
+ for dim in dims:
68
+ values = [r["scores"][dim] for r in valid]
69
+ if values:
70
+ means[dim] = round(sum(values) / len(values), 1)
71
+ spreads[dim] = round(max(values) - min(values), 1)
72
+
73
+ consensus = bool(eligible and means and
74
+ all(spreads.get(dim, float("inf")) <= float(max_spread)
75
+ for dim in dims))
76
+ if eligible and not consensus:
77
+ bad = [dim for dim in dims if spreads.get(dim, float("inf")) > float(max_spread)]
78
+ reasons.append("评审分歧超过 %.1f 分:%s" % (float(max_spread), "、".join(bad)))
79
+ passed = bool(consensus and means and
80
+ all(value >= float(threshold) for value in means.values()))
81
+ if consensus and not passed:
82
+ reasons.append("综合分存在低于 %.1f 的维度" % float(threshold))
83
+ return {
84
+ "eligible": eligible,
85
+ "consensus": consensus,
86
+ "passed": passed,
87
+ "means": means,
88
+ "spreads": spreads,
89
+ "valid_reviewers": valid,
90
+ "invalid_reviewers": invalid,
91
+ "reviewer_count": len(valid),
92
+ "reasons": reasons,
93
+ }
94
+
95
+
96
+ def local_version(workdir):
97
+ """Return the local manuscript version used for a release decision."""
98
+ root = Path(workdir or "")
99
+ rows = []
100
+ if root.is_dir():
101
+ for path in root.iterdir():
102
+ match = _CHAPTER_RE.match(path.name)
103
+ if not match or not path.is_file():
104
+ continue
105
+ try:
106
+ text = path.read_text(encoding="utf-8", errors="replace")
107
+ except OSError:
108
+ continue
109
+ rows.append((int(match.group(1)), len(re.sub(r"\s", "", text))))
110
+ return {
111
+ "local_total": len(rows),
112
+ "local_latest": max((n for n, _ in rows), default=0),
113
+ "local_words": sum(words for _, words in rows),
114
+ }
115
+
116
+
117
+ def evaluate_release(verdict, action="publish", target_chapter=None,
118
+ platform_version=None, force=False,
119
+ force_confirmed=False, force_reason=""):
120
+ """Decide whether a publish/signing action is allowed.
121
+
122
+ ``continue`` is intentionally separate: writers may continue improving an
123
+ unready manuscript, while publishing and signing remain gated.
124
+ """
125
+ verdict = verdict if isinstance(verdict, dict) else {}
126
+ action = str(action or "publish").strip().lower()
127
+ if action == "continue":
128
+ return {"allowed": True, "status": "continue_only", "forced": False,
129
+ "blockers": [], "warnings": []}
130
+
131
+ blockers = []
132
+ warnings = []
133
+ if verdict.get("publishable") is not True:
134
+ blockers.append("最近一次质量评审未达标")
135
+ if verdict.get("global_pass") is False:
136
+ blockers.append("全局一致性评审未通过")
137
+ if verdict.get("global_reviewer_count", 0) < MIN_REVIEWERS:
138
+ blockers.append("有效全局评审不足 %d 名" % MIN_REVIEWERS)
139
+ if verdict.get("global_reviewer_consensus") is False:
140
+ blockers.append("全局评审未形成共识")
141
+ chapter_scores = verdict.get("chapter_scores") or []
142
+ if target_chapter is not None:
143
+ try:
144
+ target = int(target_chapter)
145
+ except (TypeError, ValueError):
146
+ target = 0
147
+ try:
148
+ reviewed_end = int(verdict.get("end_chapter") or 0)
149
+ except (TypeError, ValueError):
150
+ reviewed_end = 0
151
+ if target > 0 and reviewed_end < target:
152
+ blockers.append("目标章节超出最近评审版本(目标第 %d 章,评审至第 %d 章)" %
153
+ (target, reviewed_end))
154
+ if chapter_scores and any(c.get("passed") is not True for c in chapter_scores):
155
+ blockers.append("存在未达标章节")
156
+ if any(c.get("reviewer_count", MIN_REVIEWERS) < MIN_REVIEWERS or
157
+ c.get("reviewer_consensus") is False for c in chapter_scores):
158
+ blockers.append("存在评审人数不足或无共识章节")
159
+ majors = [x for x in (verdict.get("major_issues") or [])
160
+ if isinstance(x, dict) and x.get("severity") == "major"]
161
+ if majors:
162
+ blockers.append("存在 major 问题(共 %d 条)" % len(majors))
163
+
164
+ if action == "signing":
165
+ checkpoint = verdict.get("signing_checkpoint") or {}
166
+ if checkpoint.get("passed") is not True:
167
+ blockers.append("2 万字签约检查点未通过")
168
+ if checkpoint.get("reviewer_count", MIN_REVIEWERS) < MIN_REVIEWERS:
169
+ blockers.append("签约检查点有效评审不足 %d 名" % MIN_REVIEWERS)
170
+ if checkpoint.get("reviewer_consensus") is False:
171
+ blockers.append("签约检查点评审未形成共识")
172
+ remote = platform_version if isinstance(platform_version, dict) else {}
173
+ remote_n = remote.get("remote_published")
174
+ if remote_n is None:
175
+ remote_n = remote.get("remote_total")
176
+ end_chapter = int(target_chapter or verdict.get("end_chapter") or 0)
177
+ if end_chapter and (remote_n is None or int(remote_n or 0) < end_chapter):
178
+ blockers.append("平台版本与申请评审版本不一致(本地第 %d 章,平台已发布第 %d 章)" %
179
+ (end_chapter, int(remote_n or 0)))
180
+
181
+ forced = bool(force)
182
+ if blockers and forced:
183
+ if not force_confirmed:
184
+ blockers.append("强制放行必须二次确认")
185
+ if not str(force_reason or "").strip():
186
+ blockers.append("强制放行必须填写人工复核原因")
187
+ if force_confirmed and str(force_reason or "").strip() and \
188
+ all("强制放行必须" not in x for x in blockers):
189
+ warnings = list(blockers)
190
+ blockers = []
191
+ allowed = not blockers
192
+ return {"allowed": allowed, "status": "allowed" if allowed else "blocked",
193
+ "forced": bool(allowed and forced), "blockers": blockers,
194
+ "warnings": warnings}
@@ -16,7 +16,8 @@ _FILE = paths.DATA_DIR / "settings.json"
16
16
  # 「导出诊断包」是用户手动操作不受此开关限制)
17
17
  # publish_daily_cap / publish_fail_streak:自动发布护栏——每任务每平台每日
18
18
  # 成功发章上限、平台连续失败几次后暂停自动发布(publish/auto.py 读取)
19
- # pet_enabled / pet_mode:桌面蜜蜂(app/pet.py)开关与显示模式。关闭后看护线程
19
+ # pet_enabled / pet_mode:桌面蜜蜂(app/pet.py)开关与显示模式(默认关,设置页
20
+ # 可开)。关闭后看护线程不再拉起、在岗蜜蜂轮询到 false 自行退出;mode=always
20
21
  # 不再拉起、在岗蜜蜂轮询到 false 自行退出;mode=always 常驻,tasks_only 仅任务
21
22
  # 运行时出现(空闲 90s 隐身)。
22
23
  # cleanup_enabled / cleanup_retention_days:每日垃圾清理(core/cleanup.py)——
@@ -39,7 +40,7 @@ DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
39
40
  # 端点(Basic auth);密钥只存本机
40
41
  "backup_remote_enabled": False, "backup_remote_url": "",
41
42
  "backup_remote_user": "", "backup_remote_pass": "",
42
- "pet_enabled": True, "pet_mode": "always", "pet_skin": "plush",
43
+ "pet_enabled": False, "pet_mode": "always", "pet_skin": "plush",
43
44
  "cleanup_enabled": True, "cleanup_retention_days": 14,
44
45
  # claude_config_sync:打开/运行前防线是否直写 ~/.claude/settings.json。
45
46
  # 关闭后交互 claude 配置归用户手动管理(cc-switch 等),编排步骤经
@@ -66,6 +66,23 @@ _PATTERNS = [
66
66
  (r"(you (are|must) (now|act as)|从此你是|你现在必须"
67
67
  r"|从现在起你(?:是|扮演|成为|要[^。\n]{0,4}服从)|请你扮演)",
68
68
  "越权人格", "试图重设助手人格"),
69
+ # SkillSpector 规则对表补充(0929 第四轮:AR/OH/TR 三族缺口)
70
+ (r"(do not|don't|never|不用|无需|不必)[^\n]{0,14}"
71
+ r"(refuse|decline|reject|reject)|(?:拒绝|推辞)[^\n]{0,6}(?:是)?(?:禁止|不允许|违规)"
72
+ r"|you (?:will|must) (?:always|never fail to) comply",
73
+ "提示注入", "反拒答抑制(试图关掉模型的拒绝能力)"),
74
+ (r"(omit|skip|remove)[^\n]{0,16}(disclaimer|warning|safety)"
75
+ r"|(?:省略|跳过|不要加|去掉)[^\n]{0,10}(?:免责|声明|安全(?:提示|警告|策略))",
76
+ "提示注入", "免责/安全声明抑制"),
77
+ (r"(?:your|the) (?:output|response|answer)[^\n]{0,30}"
78
+ r"(?:will be|is) (?:executed|run|evaluated as (?:code|commands?))"
79
+ r"|(?:输出|回答)(?:会|将)[^\n]{0,12}(?:被)?(?:直接)?(?:执行|当作命令|作为代码)",
80
+ "数据外发", "输出即执行指示(作答将被当命令跑——OH1 输出注入)"),
81
+ (r"(?:keyword|trigger|关键词|触发)[^\n]{0,50}"
82
+ r"(?:any|every|all|任何|所有|一切)|"
83
+ r"(?:any|every|all|任何|所有|一切)[^\n]{0,30}"
84
+ r"(?:keyword|trigger|关键词|触发)",
85
+ "可疑意图", "过宽触发条件(任何输入都激活——TR1 触发滥用)"),
69
86
  (r"exfiltrat|渗透|后门|backdoor|keylog", "可疑意图", "包含可疑渗透/后门词汇"),
70
87
  ]
71
88
 
@@ -23,6 +23,7 @@ import json
23
23
  import re
24
24
  import threading
25
25
  import time
26
+ from datetime import datetime
26
27
  from pathlib import Path
27
28
 
28
29
  from . import paths, revisions
@@ -338,6 +339,52 @@ def _karma(item):
338
339
  return (hits - lost + _KARMA_PRIOR) / (hits + 2 * _KARMA_PRIOR)
339
340
 
340
341
 
342
+ _DECAY_GRACE_DAYS = 30 # 宽限期:30 天内不衰减(新条目与常用条目都不受影响)
343
+ _DECAY_RAMP_DAYS = 60 # 宽限期后线性爬满(再 60 天衰减到底)
344
+
345
+
346
+ def _surplus_decay(item, now=None):
347
+ """衰减后的「信任盈余」(ds, lost):won==0 且闲置超宽限期的条目,盈余
348
+ (hits−lost)⁺ 随时长线性衰减到 0——过去的注入证据会过期(hippo-memory 借鉴,
349
+ 066 班深挖);lost 是失守证据,粘滞不衰减。won≥1 有结局归因背书不衰减;
350
+ 无时间戳的条目不衰减(无从判旧不臆测)。updated_at 随每次 upsert 刷新,
351
+ 被再次触及即重置。只压排序,不删除、不改数据。"""
352
+ hits = max(0, int(item.get("hits") or 0))
353
+ lost = min(max(0, int(item.get("lost") or 0)), hits)
354
+ if int(item.get("won") or 0) > 0:
355
+ return hits - lost, lost
356
+ stamp = item.get("updated_at") or item.get("created_at")
357
+ if not stamp:
358
+ return hits - lost, lost
359
+ try:
360
+ then = datetime.strptime(str(stamp)[:19], "%Y-%m-%d %H:%M:%S")
361
+ except ValueError:
362
+ return hits - lost, lost
363
+ now = now or datetime.now()
364
+ idle = (now - then).days
365
+ if idle <= _DECAY_GRACE_DAYS:
366
+ return hits - lost, lost
367
+ ramp = min(1.0, (idle - _DECAY_GRACE_DAYS) / float(_DECAY_RAMP_DAYS))
368
+ return int(round((hits - lost) * (1.0 - ramp))), lost
369
+
370
+
371
+ def _eff_karma(item, now=None):
372
+ """有效可信度:以衰减后盈余重算 karma。ramp=0 时与 _karma 完全等价
373
+ ((hits−lost+1)/(hits+2) 恒等变形),满衰减时未归因条目回中性 0.5——
374
+ 与新鲜条目同位,先后交给「新者优先」尾键;失守者因 lost 粘滞继续下沉。"""
375
+ ds, lost = _surplus_decay(item, now=now)
376
+ return (ds + _KARMA_PRIOR) / (ds + lost + 2 * _KARMA_PRIOR)
377
+
378
+
379
+ def _ts(item):
380
+ """created_at → epoch 秒(排序用,可取负实现新者优先);缺失/坏值记 0。"""
381
+ stamp = str(item.get("created_at") or "")[:19]
382
+ try:
383
+ return datetime.strptime(stamp, "%Y-%m-%d %H:%M:%S").timestamp()
384
+ except ValueError:
385
+ return 0.0
386
+
387
+
341
388
  def list_lessons(scope=None, only_enabled=False, category=None):
342
389
  with _LOCK:
343
390
  items = list((_load().get("lessons") or []))
@@ -349,11 +396,13 @@ def list_lessons(scope=None, only_enabled=False, category=None):
349
396
  if only_enabled:
350
397
  items = [x for x in items if x.get("enabled", True)]
351
398
  # karma 感知排序(UI 列表用;注入排序在 relevance_top 不走这里):
352
- # 首键是可信度下界(注入多且很少失守的居首),被点名归因过成功的(won)作次键
353
- # 提权,再按注入热度;失守多的自然沉底(不隐藏——降权不删除)。
354
- items.sort(key=lambda x: (-_karma(x), -int(x.get("won") or 0),
355
- -int(x.get("hits") or 0), -int(x.get("seen") or 1),
356
- x.get("created_at") or ""))
399
+ # 首键是有效可信度(未归因条目的信任盈余随闲置衰减——证据会过期;won 背书与
400
+ # 失守证据都粘滞),被点名归因过成功的(won)作次键提权,再按衰减后盈余;
401
+ # 失守多的自然沉底(不隐藏——降权不删除)。衰减到中性的同分者按新者优先
402
+ # (-_ts 尾键)——「先试新的」是衰减语义的一部分。
403
+ items.sort(key=lambda x: (-_eff_karma(x), -int(x.get("won") or 0),
404
+ -_surplus_decay(x)[0], -int(x.get("seen") or 1),
405
+ -_ts(x), x.get("id") or ""))
357
406
  return items
358
407
 
359
408
 
@@ -495,12 +544,13 @@ def relevance_top(lessons, task, limit):
495
544
  return lessons[:limit]
496
545
 
497
546
  # 使用反馈闭环(pmb「量化记忆真实帮助」+ tradememory「按结局加权召回」):
498
- # 相关性优先;同分比可信度下界,再比归因成功的次数,最后比注入热度。
547
+ # 相关性优先;同分比有效可信度(未归因盈余随闲置衰减),再比归因成功的次数,
548
+ # 最后比衰减后盈余;同到中性的按新者优先。
499
549
  def rank(x):
500
550
  grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
501
551
  overlap = -len(probe & grams)
502
- return (overlap, -_karma(x), -int(x.get("won") or 0),
503
- -int(x.get("hits") or 0), x.get("id") or "")
552
+ return (overlap, -_eff_karma(x), -int(x.get("won") or 0),
553
+ -_surplus_decay(x)[0], -_ts(x), x.get("id") or "")
504
554
 
505
555
  return sorted(lessons, key=rank)[:limit]
506
556
 
package/app/core/store.py CHANGED
@@ -502,6 +502,57 @@ def list_tasks(limit=None, archived=None):
502
502
  return out
503
503
 
504
504
 
505
+ def serial_chain_ids(task_id):
506
+ """连载链条全部任务 id,根任务在前(一本书一条链:开书资料/发布绑定沿链共享)。
507
+
508
+ 含祖先(沿 serial.continues 上溯)与后代(全量扫 continues 指向链条成员);
509
+ 非连载/未知任务回退 [task_id]。环与超深链安全(上限 50)。"""
510
+ task_id = str(task_id or "")
511
+ if not task_id:
512
+ return []
513
+ with LOCK:
514
+ by_id = {str(t.get("id")): t for t in _TASKS.values()}
515
+ if task_id not in by_id:
516
+ return [task_id]
517
+ order, seen = [], set()
518
+ cur = task_id
519
+ while cur and cur in by_id and cur not in seen and len(seen) < 50:
520
+ seen.add(cur)
521
+ order.append(cur)
522
+ cur = str(((by_id[cur].get("serial") or {}).get("continues")) or "") or ""
523
+ order.reverse() # 根在前
524
+ in_chain, grew = set(order), True
525
+ while grew: # 后代收进链条,迭代到收敛
526
+ grew = False
527
+ for tid, t in by_id.items():
528
+ if tid in in_chain:
529
+ continue
530
+ parent = str(((t.get("serial") or {}).get("continues")) or "")
531
+ if parent and parent in in_chain:
532
+ order.append(tid)
533
+ in_chain.add(tid)
534
+ grew = True
535
+ return order
536
+
537
+
538
+ def inherited_book_meta(task, platform):
539
+ """任务在某平台的作品信息:连载链上「生成完成」的条目整链共用(都是用第一个)。
540
+
541
+ 根在前找第一个 status=done 且有 data 的条目;整链都没有则返回任务自己的
542
+ 条目(可能为空——调用方按未生成口径处理)。"""
543
+ task = task or {}
544
+ platform = str(platform)
545
+ own = ((task.get("book_meta") or {}).get(platform)) or {}
546
+ for tid in serial_chain_ids(task.get("id") or ""):
547
+ t = task if tid == str(task.get("id")) else get_task(tid)
548
+ if not t:
549
+ continue
550
+ e = ((t.get("book_meta") or {}).get(platform)) or {}
551
+ if e.get("status") == "done" and e.get("data"):
552
+ return e
553
+ return own
554
+
555
+
505
556
  def migrate_task_workdirs(old_root, new_root):
506
557
  """把「旧默认保存路径」下的任务目录搬到新默认路径下,并更新任务记录。
507
558