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.
- package/CHANGELOG.md +15 -0
- package/README.md +3 -4
- package/app/core/builtin_agent.py +7 -0
- package/app/core/evalbench.py +119 -3
- package/app/core/jobs.py +21 -0
- package/app/core/market.py +88 -2
- package/app/core/market_remote.py +16 -0
- package/app/core/modelhub.py +3 -2
- package/app/core/pipeline.py +102 -30
- package/app/core/prompt_guard.py +78 -0
- package/app/core/publish/auto.py +43 -14
- package/app/core/publish/ledger.py +90 -12
- package/app/core/publish/manager.py +121 -14
- package/app/core/quality_gate.py +194 -0
- package/app/core/settings.py +3 -2
- package/app/core/skill_scan.py +17 -0
- package/app/core/skills.py +58 -8
- package/app/core/store.py +51 -0
- package/app/core/zentao.py +135 -57
- package/app/main.py +124 -14
- package/app/pet.py +1 -1
- package/app/ui/app.js +269 -58
- package/app/ui/i18n.js +25 -1
- package/app/ui/index.html +5 -0
- package/app/ui/style.css +7051 -6965
- package/package.json +3 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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}
|
package/app/core/settings.py
CHANGED
|
@@ -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":
|
|
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 等),编排步骤经
|
package/app/core/skill_scan.py
CHANGED
|
@@ -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
|
|
package/app/core/skills.py
CHANGED
|
@@ -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
|
-
#
|
|
353
|
-
#
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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, -
|
|
503
|
-
-
|
|
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
|
|