codebee 0.1.73 → 0.1.76
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 +23 -0
- package/README.md +5 -4
- package/app/core/analytics.py +38 -0
- package/app/core/bookmeta.py +115 -14
- package/app/core/contracts.py +255 -0
- package/app/core/error_codes.py +9 -0
- package/app/core/health.py +16 -1
- package/app/core/jobs.py +72 -0
- package/app/core/manager.py +38 -14
- package/app/core/modelhub.py +104 -9
- package/app/core/openapi.py +154 -0
- package/app/core/pipeline.py +210 -18
- package/app/core/presence.py +77 -0
- package/app/core/publish/auto.py +11 -0
- package/app/core/retrieval.py +137 -0
- package/app/core/runner.py +101 -5
- package/app/core/store.py +72 -0
- package/app/core/story_tracking.py +275 -0
- package/app/main.py +240 -20
- package/app/mcp_server.py +75 -6
- package/app/ui/app.js +237 -43
- package/app/ui/i18n.js +34 -5
- package/app/ui/index.html +9 -0
- package/app/ui/style.css +32 -6
- package/package.json +1 -1
package/app/core/pipeline.py
CHANGED
|
@@ -2832,6 +2832,97 @@ def _weakest_chapters(chapter_scores, global_means, threshold, limit=2):
|
|
|
2832
2832
|
return cand[:limit]
|
|
2833
2833
|
|
|
2834
2834
|
|
|
2835
|
+
_ARCHIVE_DIR = "已成稿"
|
|
2836
|
+
_CH_MENTION_RE = re.compile(r"第\s*(\d{1,4})\s*(?:[—–\-~至到]\s*(\d{1,4})\s*)?章")
|
|
2837
|
+
|
|
2838
|
+
|
|
2839
|
+
def _archived_preface(workdir, start):
|
|
2840
|
+
"""前文回退:续写批次的前作章文件被归档成单文件(发布后 chapter-NN.md
|
|
2841
|
+
不在场)时,从「已成稿/」目录找回前文全文,供全书评审与合并成书使用。
|
|
2842
|
+
任一前文章节文件仍在场则视为未归档,返回空串(正常路径零影响)。"""
|
|
2843
|
+
if start <= 1:
|
|
2844
|
+
return ""
|
|
2845
|
+
for i in range(1, start):
|
|
2846
|
+
if _read_chapter(workdir, i):
|
|
2847
|
+
return ""
|
|
2848
|
+
adir = os.path.join(str(workdir), _ARCHIVE_DIR)
|
|
2849
|
+
try:
|
|
2850
|
+
names = sorted(os.listdir(adir))
|
|
2851
|
+
except OSError:
|
|
2852
|
+
return ""
|
|
2853
|
+
parts = []
|
|
2854
|
+
for name in names:
|
|
2855
|
+
if not name.lower().endswith((".md", ".txt")):
|
|
2856
|
+
continue
|
|
2857
|
+
if "目录" in name: # 阅读目录/章节目录是索引不是正文
|
|
2858
|
+
continue
|
|
2859
|
+
p = os.path.join(adir, name)
|
|
2860
|
+
if not os.path.isfile(p):
|
|
2861
|
+
continue
|
|
2862
|
+
try:
|
|
2863
|
+
t = _read_text_any_enc(p)
|
|
2864
|
+
except Exception:
|
|
2865
|
+
continue
|
|
2866
|
+
if t and t.strip():
|
|
2867
|
+
parts.append(t.strip())
|
|
2868
|
+
return "\n\n".join(parts)
|
|
2869
|
+
|
|
2870
|
+
|
|
2871
|
+
def _full_manuscript(workdir, start, end, cap=60000):
|
|
2872
|
+
"""全书评审文本:本批 start..end 全文保底在场,前文(章文件缺失时回退
|
|
2873
|
+
归档)用剩余预算从尾部截取——越靠近本批的前文对一致性越要紧。直拼再
|
|
2874
|
+
[:cap] 会把预算整段喂给前文、把本批(真正的评审对象)截掉。"""
|
|
2875
|
+
new_text = "\n\n".join(t for t in (_read_chapter(workdir, i)
|
|
2876
|
+
for i in range(start, end + 1)) if t)
|
|
2877
|
+
old_text = "\n\n".join(t for t in (_read_chapter(workdir, i)
|
|
2878
|
+
for i in range(1, start)) if t)
|
|
2879
|
+
if not old_text and start > 1:
|
|
2880
|
+
old_text = _archived_preface(workdir, start)
|
|
2881
|
+
budget = cap - len(new_text) - 2
|
|
2882
|
+
if budget <= 0:
|
|
2883
|
+
return new_text[:cap]
|
|
2884
|
+
if len(old_text) > budget:
|
|
2885
|
+
cut_mark = "……(前文较长,仅保留紧邻本批的尾部)……\n\n"
|
|
2886
|
+
keep = budget - len(cut_mark)
|
|
2887
|
+
old_text = cut_mark + (old_text[-keep:] if keep > 0 else "")
|
|
2888
|
+
return (old_text + "\n\n" + new_text)[:cap] if old_text else new_text[:cap]
|
|
2889
|
+
|
|
2890
|
+
|
|
2891
|
+
def _chapters_named_in(issues, lo=1, hi=None):
|
|
2892
|
+
"""评审 issue 文本里点名的章号集合(「第22章」「第26—28章」都认)。
|
|
2893
|
+
用于把全局一致性 major 落到具体章;范围外的章号丢弃。"""
|
|
2894
|
+
out = set()
|
|
2895
|
+
for it in issues or []:
|
|
2896
|
+
if not isinstance(it, dict):
|
|
2897
|
+
continue
|
|
2898
|
+
for m in _CH_MENTION_RE.finditer(str(it.get("note") or it.get("issue") or "")):
|
|
2899
|
+
a = int(m.group(1))
|
|
2900
|
+
b = int(m.group(2)) if m.group(2) else a
|
|
2901
|
+
if b < a:
|
|
2902
|
+
a, b = b, a
|
|
2903
|
+
for c in range(a, b + 1):
|
|
2904
|
+
if c >= lo and (hi is None or c <= hi):
|
|
2905
|
+
out.add(c)
|
|
2906
|
+
return out
|
|
2907
|
+
|
|
2908
|
+
|
|
2909
|
+
def _dedup_issues(items, key_len=60):
|
|
2910
|
+
"""跨轮评审 issues 去重:同章同维度同要点(归一化前缀)只保留首条。
|
|
2911
|
+
全局评审每轮重跑都会把上一轮的问题原样或换个说法再列一遍,不去重会把
|
|
2912
|
+
同一处问题计成三四条 major,门禁报告数字虚高。"""
|
|
2913
|
+
seen, out = set(), []
|
|
2914
|
+
for x in items or []:
|
|
2915
|
+
if not isinstance(x, dict):
|
|
2916
|
+
continue
|
|
2917
|
+
k = (str(x.get("chapter")), str(x.get("dim")),
|
|
2918
|
+
re.sub(r"\s", "", str(x.get("note") or ""))[:key_len])
|
|
2919
|
+
if k in seen:
|
|
2920
|
+
continue
|
|
2921
|
+
seen.add(k)
|
|
2922
|
+
out.append(x)
|
|
2923
|
+
return out
|
|
2924
|
+
|
|
2925
|
+
|
|
2835
2926
|
def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route, resume_ctx, difficulty):
|
|
2836
2927
|
"""连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
|
|
2837
2928
|
import json as _json
|
|
@@ -2862,6 +2953,27 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2862
2953
|
|
|
2863
2954
|
refresh_actual_route(impl)
|
|
2864
2955
|
workdir = task["workdir"]
|
|
2956
|
+
# Long-form state is a deterministic input to every chapter. Bootstrap
|
|
2957
|
+
# legacy serial tasks once, then refuse to write over manually edited
|
|
2958
|
+
# derived views until the author reconciles the source JSON.
|
|
2959
|
+
from . import story_tracking
|
|
2960
|
+
try:
|
|
2961
|
+
tracking_state = story_tracking.load(workdir)
|
|
2962
|
+
if tracking_state is None:
|
|
2963
|
+
story_tracking.init(workdir, task.get("id"), task.get("title"),
|
|
2964
|
+
premise=task.get("goal") or "")
|
|
2965
|
+
tracking_check = story_tracking.check(workdir)
|
|
2966
|
+
except Exception as exc:
|
|
2967
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
2968
|
+
error="故事追踪状态不可用,请先修复:%s" % str(exc)[:500],
|
|
2969
|
+
ended_at=_now())
|
|
2970
|
+
return
|
|
2971
|
+
if not tracking_check.get("ok"):
|
|
2972
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
2973
|
+
error="故事追踪状态存在漂移,请先修复:%s" %
|
|
2974
|
+
";".join(tracking_check.get("errors") or [])[:500],
|
|
2975
|
+
ended_at=_now())
|
|
2976
|
+
return
|
|
2865
2977
|
# 续会话步骤的 CLI 启动目录(稿件读写仍用 workdir)
|
|
2866
2978
|
step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
|
|
2867
2979
|
serial = task.get("serial") or {}
|
|
@@ -3840,6 +3952,36 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3840
3952
|
chapter_scores.append(cs_new)
|
|
3841
3953
|
# 每章即时持久化:长篇中断/超时后可断点续跑,不丢已完成章的分数
|
|
3842
3954
|
store.update_run(run_id, chapter_scores=chapter_scores)
|
|
3955
|
+
# Structured story state is committed alongside the chapter. The
|
|
3956
|
+
# pipeline remains compatible with legacy workdirs: a tracking failure
|
|
3957
|
+
# is reported in the run evidence but never deletes a valid chapter.
|
|
3958
|
+
try:
|
|
3959
|
+
from . import story_tracking
|
|
3960
|
+
story_tracking.commit_chapter(
|
|
3961
|
+
workdir, i, outline_txt, _read_chapter(workdir, i),
|
|
3962
|
+
facts=[str(x.get("note") or "") for x in issues_all
|
|
3963
|
+
if isinstance(x, dict) and x.get("chapter") == i and x.get("note")],
|
|
3964
|
+
foreshadowing=[{"id": "F-%04d-%02d" % (i, n + 1),
|
|
3965
|
+
"text": str(line)[:400], "status": "active",
|
|
3966
|
+
"planted_chapter": i}
|
|
3967
|
+
for n, line in enumerate(ledger_lines[:20])],
|
|
3968
|
+
next_promises=[ch.get("hook") or ch.get("highlight") or ""],
|
|
3969
|
+
)
|
|
3970
|
+
except Exception as exc:
|
|
3971
|
+
try:
|
|
3972
|
+
store.update_run(run_id, story_tracking_warning=str(exc)[:300])
|
|
3973
|
+
except Exception:
|
|
3974
|
+
pass
|
|
3975
|
+
# 旧账换新账(章内修订回路):本章终评达标 → 用终轮评审的 issues 换掉
|
|
3976
|
+
# 第 1 轮的旧账。不换的话「修订后已达标」的章仍顶着第 1 轮 majors,
|
|
3977
|
+
# 最终 publishable 被陈账永远压着 False。放在 story_tracking 取材之后:
|
|
3978
|
+
# 事实沉淀仍吃得到修订前的完整意见,门禁只认终轮评判。
|
|
3979
|
+
if cs_new["passed"] and cj_by_agent:
|
|
3980
|
+
issues_all[:] = [x for x in issues_all if x.get("chapter") != i]
|
|
3981
|
+
for _cj in cj_by_agent.values():
|
|
3982
|
+
if isinstance(_cj, dict):
|
|
3983
|
+
issues_all.extend({"chapter": i, **it}
|
|
3984
|
+
for it in (_cj.get("issues") or [])[:6])
|
|
3843
3985
|
# 资源账本(借鉴角色资源账本):评审提出的道具/伤情/承诺/伏笔增量
|
|
3844
3986
|
# 追加到 .codebee/resource-ledger.md,下一章起草时注入,防跨章穿帮。
|
|
3845
3987
|
# 失败静默——账本是增强不是硬依赖。
|
|
@@ -3932,16 +4074,17 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3932
4074
|
store.update_run(run_id, signing_checkpoint=cp)
|
|
3933
4075
|
|
|
3934
4076
|
# ---- 3) 全局一致性评审(覆盖 1..end 全书:续写批次必须连同旧章一起查一致性)
|
|
3935
|
-
full_text =
|
|
4077
|
+
full_text = _full_manuscript(workdir, start, end)
|
|
3936
4078
|
global_issues = []
|
|
3937
4079
|
global_review_results = []
|
|
3938
4080
|
|
|
3939
4081
|
def run_global_round(agent_list):
|
|
3940
|
-
"""一轮全局评审:返回 (出分评审数,
|
|
4082
|
+
"""一轮全局评审:返回 (出分评审数, 按维累计分, 有效评审, 本轮 issues)。
|
|
4083
|
+
失败/不可解析不得当成低分计入。
|
|
3941
4084
|
|
|
3942
4085
|
多评审并发(2026-09-22,同章级评审并发):各自读同一份全书文本、互不依赖,
|
|
3943
4086
|
串行只是把等待时间叠起来。共享结构仍在 join 后按原顺序合并,结果与串行一致。"""
|
|
3944
|
-
gmeans_acc, scored, valid_reviews = {}, 0, []
|
|
4087
|
+
gmeans_acc, scored, valid_reviews, round_issues = {}, 0, [], []
|
|
3945
4088
|
results = {}
|
|
3946
4089
|
# 绑定解析提前到主线程(线程内不做会写盘的 bind_agent)
|
|
3947
4090
|
bound = [modelhub.bind_agent(a, difficulty) for a in agent_list]
|
|
@@ -3997,14 +4140,15 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3997
4140
|
if not errors:
|
|
3998
4141
|
valid_reviews.append({"id": agent_list[idx].get("id") or "reviewer-%d" % idx,
|
|
3999
4142
|
"scores": scores})
|
|
4000
|
-
|
|
4143
|
+
round_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
|
|
4001
4144
|
for d in dims:
|
|
4002
4145
|
v = scores.get(d) if not errors else partial_scores.get(d)
|
|
4003
4146
|
if v is not None:
|
|
4004
4147
|
gmeans_acc.setdefault(d, []).append(float(v))
|
|
4005
|
-
return scored, gmeans_acc, valid_reviews
|
|
4148
|
+
return scored, gmeans_acc, valid_reviews, round_issues
|
|
4006
4149
|
|
|
4007
|
-
gscored, gmeans_acc, global_review_results = run_global_round(critics)
|
|
4150
|
+
gscored, gmeans_acc, global_review_results, _gi = run_global_round(critics)
|
|
4151
|
+
global_issues.extend(_gi)
|
|
4008
4152
|
# 「评不上」≠「评了低分」:全局评审全挂时先从其它真实智能体补位(对齐章级
|
|
4009
4153
|
# 评审者级 fallback);补位后仍零分则判 run 失败——「无法评审」绝不能当成
|
|
4010
4154
|
# 「全局评审未通过」去盖「未达标」章(2026-09-18 假未达标案:codex 绑定链
|
|
@@ -4014,8 +4158,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4014
4158
|
tried = {a.get("id") for a in critics}
|
|
4015
4159
|
for spare in [a for a in (agents or [])
|
|
4016
4160
|
if a.get("mode") == "real" and a.get("id") not in tried][:2]:
|
|
4017
|
-
sc, acc, spare_reviews = run_global_round([spare])
|
|
4161
|
+
sc, acc, spare_reviews, _sgi = run_global_round([spare])
|
|
4018
4162
|
global_review_results.extend(spare_reviews)
|
|
4163
|
+
global_issues.extend(_sgi)
|
|
4019
4164
|
for d, xs in acc.items():
|
|
4020
4165
|
gmeans_acc.setdefault(d, []).extend(xs)
|
|
4021
4166
|
gscored += sc
|
|
@@ -4042,9 +4187,17 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4042
4187
|
while (not global_pass) and polish_rounds < 2 and chapter_scores and global_means:
|
|
4043
4188
|
polish_rounds += 1
|
|
4044
4189
|
weak = _weakest_chapters(chapter_scores, global_means, threshold, limit=2)
|
|
4190
|
+
# 全局一致性 major 点名的章优先进入打磨面:脱纲/主线漂移类问题的病灶章
|
|
4191
|
+
# 单章分数往往全部达标(章级评审只看局部),只按分数选章永远选不中
|
|
4192
|
+
# (2026-10-03 不周山守村人实案:21–28 章整体脱纲、章分全 8+,两轮
|
|
4193
|
+
# 打磨重改的全是达标章,全局分纹丝不动)。
|
|
4194
|
+
named = _chapters_named_in(global_issues, lo=start, hi=end)
|
|
4195
|
+
if named:
|
|
4196
|
+
weak = ([c for c in chapter_scores if c.get("chapter") in named]
|
|
4197
|
+
+ [c for c in weak if c.get("chapter") not in named])[:2]
|
|
4045
4198
|
if not weak:
|
|
4046
4199
|
break
|
|
4047
|
-
note = "自动打磨第 %d
|
|
4200
|
+
note = "自动打磨第 %d 轮:全局评审未过,重改 %s" % (
|
|
4048
4201
|
polish_rounds, "、".join("第 %d 章" % c["chapter"] for c in weak))
|
|
4049
4202
|
pstep, _ = store.add_step(run_id, "polish-r%d" % polish_rounds, impl["id"],
|
|
4050
4203
|
impl.get("label"), note=note)
|
|
@@ -4057,10 +4210,24 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4057
4210
|
for d in dims if float((c.get("means") or {}).get(d, 0.0)) < threshold)
|
|
4058
4211
|
gj = ";".join("%s %.1f" % (d, s2) for d, s2 in global_means.items()
|
|
4059
4212
|
if float(s2) < threshold)
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4213
|
+
# 本章被全局评审点名时附上原话,并放开「不得改主线」约束——脱纲章
|
|
4214
|
+
# 唯一的修法就是按大纲推翻自创情节,保事实微调恰恰修不动
|
|
4215
|
+
ch_named = [x for x in global_issues
|
|
4216
|
+
if i in _chapters_named_in([x], lo=i, hi=i)]
|
|
4217
|
+
if ch_named:
|
|
4218
|
+
crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
|
|
4219
|
+
"\n- 全书评审对本章的点名(逐条对照处理):\n%s"
|
|
4220
|
+
"\n- 重改要求:若现稿剧情与「本章按大纲应完成」脱轨(评审已点名),"
|
|
4221
|
+
"以本章大纲要点为准重写剧情,可推翻现稿自创的情节线;"
|
|
4222
|
+
"本书其余各章的既成事实仍不得改动。"
|
|
4223
|
+
% (dims_txt or "(无)", threshold, gj or "(无)",
|
|
4224
|
+
"\n".join("- [%s] %s" % (x.get("dim"), str(x.get("note") or "")[:220])
|
|
4225
|
+
for x in ch_named[:4])))
|
|
4226
|
+
else:
|
|
4227
|
+
crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
|
|
4228
|
+
"\n- 重改要求:优先修全书节奏/衔接问题(章间过渡、信息倾泻、主角主动性),"
|
|
4229
|
+
"再补本章短板;不得改动既有剧情主线的关键事实。"
|
|
4230
|
+
% (dims_txt or "(无)", threshold, gj or "(无)"))
|
|
4064
4231
|
_psig = novel_quality.signal_summary(_read_chapter(workdir, i), chapter=i)
|
|
4065
4232
|
if _psig.get("hints"):
|
|
4066
4233
|
crit += ("\n- 确定性复查提示(修订须对应处理):"
|
|
@@ -4073,7 +4240,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4073
4240
|
.replace("__I__", str(i)).replace("__FILE__", "chapter-%02d.md" % i)
|
|
4074
4241
|
.replace("__GOAL__", task["goal"])
|
|
4075
4242
|
.replace("__VOLUME__", vol_block_for(i))
|
|
4243
|
+
.replace("__BEATS__", ch.get("beats") or "按大纲推进")
|
|
4244
|
+
.replace("__HIGHLIGHT__", ch.get("highlight")
|
|
4245
|
+
or "按剧情要点自然铺设一处小冲突/小反转")
|
|
4076
4246
|
.replace("__CRITIQUE__", crit)
|
|
4247
|
+
.replace("__STYLE__", style_tpl)
|
|
4077
4248
|
.replace("__WORDS__", str(wpc)))
|
|
4078
4249
|
prompt = attachments.append_task_context(prompt, task)
|
|
4079
4250
|
res = _run_step(run_id, "polish-c%d" % i, modelhub.bind_agent(impl, difficulty),
|
|
@@ -4117,6 +4288,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4117
4288
|
c2["polished"] = True
|
|
4118
4289
|
c2["words"] = _wc(_read_chapter(workdir, i))
|
|
4119
4290
|
fixed.append(i)
|
|
4291
|
+
if repolished:
|
|
4292
|
+
# 旧账换新账(章级):该章旧 issues 是对打磨前稿件的评判,重写
|
|
4293
|
+
# 已落盘并重评,陈账不撤会同全局陈账一起永远压着 publishable
|
|
4294
|
+
issues_all[:] = [x for x in issues_all if x.get("chapter") != i]
|
|
4295
|
+
for _cj in cj_by_agent.values():
|
|
4296
|
+
if isinstance(_cj, dict):
|
|
4297
|
+
issues_all.extend({"chapter": i, **it}
|
|
4298
|
+
for it in (_cj.get("issues") or [])[:6])
|
|
4120
4299
|
store.update_run(run_id, chapter_scores=chapter_scores)
|
|
4121
4300
|
_check_cancel(ev)
|
|
4122
4301
|
if not fixed:
|
|
@@ -4132,8 +4311,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4132
4311
|
# 重评全书一致性(同一评审闭包;本轮全挂则保留上一轮结论——评审链挂了
|
|
4133
4312
|
# 不代表书变差,不能拿「无法评审」覆盖真实分数)
|
|
4134
4313
|
try:
|
|
4135
|
-
full_text =
|
|
4136
|
-
gscored2, gmeans_acc2, global_review_results2 = run_global_round(critics)
|
|
4314
|
+
full_text = _full_manuscript(workdir, start, end)
|
|
4315
|
+
gscored2, gmeans_acc2, global_review_results2, gi2 = run_global_round(critics)
|
|
4137
4316
|
except BaseException:
|
|
4138
4317
|
store.finish_step(run_id, pstep["n"], "failed",
|
|
4139
4318
|
summary="打磨后全书重评异常中止,已落盘章稿不受影响")
|
|
@@ -4144,6 +4323,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4144
4323
|
global_pass = _all_ge(global_means, threshold)
|
|
4145
4324
|
global_review = quality_gate.aggregate_reviews(
|
|
4146
4325
|
global_review_results, dims, threshold=threshold)
|
|
4326
|
+
# 旧账换新账:上一轮的 majors 是对打磨前稿件的评判,重评出了新分
|
|
4327
|
+
# 却不换 issues,重写修好了也永远 publishable=False(陈账死闸)
|
|
4328
|
+
global_issues[:] = gi2
|
|
4147
4329
|
store.finish_step(run_id, pstep["n"], "done" if global_pass else "failed",
|
|
4148
4330
|
summary="重改 %s;打磨后全局 %s(%s)" % (
|
|
4149
4331
|
"、".join("第 %d 章" % x for x in fixed),
|
|
@@ -4156,6 +4338,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4156
4338
|
ms_name = _ms_name(task.get("manuscript"))
|
|
4157
4339
|
book_title = outline.get("book_title") or task["title"]
|
|
4158
4340
|
parts = ["# %s" % book_title, ""]
|
|
4341
|
+
# 前文归档回退:前作章文件已被归档(chapter-NN.md 不在场)时,合并成品
|
|
4342
|
+
# 不能只剩本批——否则续写批次的「成书」会静默丢掉此前全部章节
|
|
4343
|
+
arch_preface = _archived_preface(workdir, start) if start > 1 else ""
|
|
4344
|
+
if arch_preface:
|
|
4345
|
+
parts.append("## 前文(第 1–%d 章 · 归档稿)" % (start - 1))
|
|
4346
|
+
parts.append("")
|
|
4347
|
+
parts.append(arch_preface)
|
|
4348
|
+
parts.append("")
|
|
4159
4349
|
# 分卷标题:卷首章之前插「第 X 卷 《卷名》」分隔(全书 1..end 都插,续写
|
|
4160
4350
|
# 批次合并时上一批的卷标题也一并补上,不会只在首批出现)
|
|
4161
4351
|
vol_heads = {}
|
|
@@ -4176,13 +4366,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4176
4366
|
f.write("\n".join(parts))
|
|
4177
4367
|
total_words = _wc("\n".join(parts))
|
|
4178
4368
|
store.finish_step(run_id, step["n"], "done",
|
|
4179
|
-
summary="已合并 %d 章为 %s(约 %d 字%s)" % (
|
|
4369
|
+
summary="已合并 %d 章为 %s(约 %d 字%s%s)" % (
|
|
4180
4370
|
n, ms_name, total_words,
|
|
4181
|
-
",分 %d 卷" % len(vol_heads) if vol_heads else ""
|
|
4371
|
+
",分 %d 卷" % len(vol_heads) if vol_heads else "",
|
|
4372
|
+
",前文 1–%d 章取自归档" % (start - 1) if arch_preface else ""),
|
|
4182
4373
|
duration_s=0.1)
|
|
4183
4374
|
|
|
4184
4375
|
chapters_pass = all(c["passed"] for c in chapter_scores)
|
|
4185
|
-
major_issues = [x for x in issues_all + global_issues
|
|
4376
|
+
major_issues = [x for x in _dedup_issues(list(issues_all) + list(global_issues))
|
|
4186
4377
|
if isinstance(x, dict) and x.get("severity") == "major"]
|
|
4187
4378
|
publishable = bool(chapters_pass and global_pass and not major_issues)
|
|
4188
4379
|
signing_checkpoint = (store.get_run(run_id) or {}).get("signing_checkpoint") or {}
|
|
@@ -4247,7 +4438,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4247
4438
|
lines += ["", "## 全局评审(阈值 %.1f)" % threshold, ""]
|
|
4248
4439
|
lines += ["- %s:%.1f" % (d, global_means.get(d, 0.0)) for d in dims]
|
|
4249
4440
|
lines += ["", "## 主要问题", ""]
|
|
4250
|
-
majors = [x for x in issues_all + global_issues
|
|
4441
|
+
majors = [x for x in _dedup_issues(list(issues_all) + list(global_issues))
|
|
4442
|
+
if x.get("severity") == "major"][:12]
|
|
4251
4443
|
if majors:
|
|
4252
4444
|
lines.extend("- [第%s章][%s] %s" % (str(x.get("chapter", "?")), x.get("dim", "?"),
|
|
4253
4445
|
str(x.get("note", ""))[:150]) for x in majors)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Small local agent presence registry for the operator dashboard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from . import paths
|
|
9
|
+
|
|
10
|
+
_LOCK = threading.RLock()
|
|
11
|
+
_FILE = paths.DATA_DIR / "agent_presence.json"
|
|
12
|
+
_STALE_S = 180
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load():
|
|
16
|
+
try:
|
|
17
|
+
value = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
18
|
+
return value if isinstance(value, dict) else {}
|
|
19
|
+
except Exception:
|
|
20
|
+
return {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _save(value):
|
|
24
|
+
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
tmp = _FILE.with_suffix(".tmp")
|
|
26
|
+
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
27
|
+
tmp.replace(_FILE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def heartbeat(agent_id, label="", capabilities=None, task_id="", status="idle", metadata=None):
|
|
31
|
+
aid = str(agent_id or "").strip()[:100]
|
|
32
|
+
if not aid:
|
|
33
|
+
raise ValueError("agent_id 必填")
|
|
34
|
+
if capabilities is not None and not isinstance(capabilities, list):
|
|
35
|
+
raise ValueError("capabilities 必须是列表")
|
|
36
|
+
if status is not None and not isinstance(status, str):
|
|
37
|
+
raise ValueError("status 必须是文本")
|
|
38
|
+
now = time.time()
|
|
39
|
+
with _LOCK:
|
|
40
|
+
data = _load()
|
|
41
|
+
safe_meta = metadata if isinstance(metadata, dict) else {}
|
|
42
|
+
# Presence is a hot, user-controlled endpoint; keep the durable
|
|
43
|
+
# registry bounded even when an agent sends oversized diagnostics.
|
|
44
|
+
safe_meta = {str(k)[:80]: str(v)[:500] for k, v in list(safe_meta.items())[:30]}
|
|
45
|
+
data[aid] = {"agent_id": aid, "label": str(label or aid)[:120],
|
|
46
|
+
"capabilities": [str(x)[:80] for x in (capabilities or [])][:30],
|
|
47
|
+
"task_id": str(task_id or "")[:100], "status": str(status or "idle")[:30],
|
|
48
|
+
"metadata": safe_meta,
|
|
49
|
+
"last_seen": now}
|
|
50
|
+
_save(data)
|
|
51
|
+
return data[aid]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_agents():
|
|
55
|
+
now = time.time()
|
|
56
|
+
with _LOCK:
|
|
57
|
+
rows = []
|
|
58
|
+
for row in _load().values():
|
|
59
|
+
item = dict(row)
|
|
60
|
+
try:
|
|
61
|
+
last_seen = float(item.get("last_seen") or 0)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
last_seen = 0.0
|
|
64
|
+
age = max(0, now - last_seen)
|
|
65
|
+
item["age_s"] = int(age)
|
|
66
|
+
item["online"] = age <= _STALE_S
|
|
67
|
+
rows.append(item)
|
|
68
|
+
return sorted(rows, key=lambda x: (not x["online"], x.get("label") or ""))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def remove(agent_id):
|
|
72
|
+
with _LOCK:
|
|
73
|
+
data = _load()
|
|
74
|
+
existed = str(agent_id or "") in data
|
|
75
|
+
data.pop(str(agent_id or ""), None)
|
|
76
|
+
_save(data)
|
|
77
|
+
return existed
|
package/app/core/publish/auto.py
CHANGED
|
@@ -186,6 +186,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, force=False,
|
|
|
186
186
|
task = store.get_task(task_id)
|
|
187
187
|
if not task:
|
|
188
188
|
return False, "任务不存在"
|
|
189
|
+
from .. import contracts
|
|
190
|
+
contract = contracts.get(task_id)
|
|
191
|
+
if ((contract and contract.get("approval_required")) or task.get("approval_required")) \
|
|
192
|
+
and not contracts.release_allowed(task_id):
|
|
193
|
+
return False, "任务需要先完成审批,才能发布产物"
|
|
189
194
|
book = ledger.book_for(task_id, platform)
|
|
190
195
|
if not book:
|
|
191
196
|
return False, "该任务尚未在此平台建书,请先「创建作品」"
|
|
@@ -359,6 +364,12 @@ def due_tasks(now=None):
|
|
|
359
364
|
ap = task.get("auto_publish")
|
|
360
365
|
if not isinstance(ap, dict) or not ap.get("enabled"):
|
|
361
366
|
continue
|
|
367
|
+
if ap.get("auto_submit"):
|
|
368
|
+
from .. import contracts
|
|
369
|
+
contract = contracts.get(task["id"])
|
|
370
|
+
if ((contract and contract.get("approval_required")) or
|
|
371
|
+
task.get("approval_required")) and not contracts.release_allowed(task["id"]):
|
|
372
|
+
continue
|
|
362
373
|
if str(ap.get("time") or "") <= hhmm_now and fired.get(task["id"]) != today:
|
|
363
374
|
plat = ap.get("platform")
|
|
364
375
|
if plat and _book_ready(ledger.book_for(task["id"], plat)):
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Dependency-free retrieval index with traceable keyword scoring.
|
|
2
|
+
|
|
3
|
+
This is intentionally a RAG-lite layer: SQLite/vector backends can be added
|
|
4
|
+
later without changing the API contract. Every hit carries its source and the
|
|
5
|
+
terms that caused the match.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import paths
|
|
16
|
+
|
|
17
|
+
_LOCK = threading.RLock()
|
|
18
|
+
_FILE = paths.DATA_DIR / "retrieval_index.json"
|
|
19
|
+
_WORD_RE = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]")
|
|
20
|
+
_MAX_FILES = 500
|
|
21
|
+
_MAX_BYTES = 20 * 1024 * 1024
|
|
22
|
+
_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _terms(text):
|
|
26
|
+
return [x.lower() for x in _WORD_RE.findall(str(text or ""))]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load():
|
|
30
|
+
try:
|
|
31
|
+
value = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
32
|
+
return value if isinstance(value, dict) else {"documents": {}}
|
|
33
|
+
except Exception:
|
|
34
|
+
return {"version": 1, "documents": {}}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _save(value):
|
|
38
|
+
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
tmp = _FILE.with_suffix(".tmp")
|
|
40
|
+
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
41
|
+
tmp.replace(_FILE)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def upsert(source, text, metadata=None, doc_id=""):
|
|
45
|
+
source = str(source or "").strip()[:500]
|
|
46
|
+
text = str(text or "").strip()
|
|
47
|
+
if not source or not text:
|
|
48
|
+
raise ValueError("source 和 text 不能为空")
|
|
49
|
+
root_hint = ""
|
|
50
|
+
if isinstance(metadata, dict):
|
|
51
|
+
root_hint = str(metadata.get("root") or "")[:500]
|
|
52
|
+
did = str(doc_id or hashlib.sha256((root_hint + "\0" + source).encode("utf-8")).hexdigest()[:20])[:100]
|
|
53
|
+
meta = metadata if isinstance(metadata, dict) else {}
|
|
54
|
+
meta = {str(k)[:80]: str(v)[:1000] for k, v in list(meta.items())[:30]}
|
|
55
|
+
row = {"id": did, "source": source, "text": text[:200000],
|
|
56
|
+
"terms": sorted(set(_terms(text))),
|
|
57
|
+
"metadata": meta}
|
|
58
|
+
with _LOCK:
|
|
59
|
+
data = _load()
|
|
60
|
+
data.setdefault("version", 1)
|
|
61
|
+
documents = data.setdefault("documents", {})
|
|
62
|
+
# Explicit IDs are client supplied; avoid silently replacing an
|
|
63
|
+
# unrelated document when two sources reuse the same short ID.
|
|
64
|
+
if did in documents and documents[did].get("source") != source:
|
|
65
|
+
suffix = hashlib.sha256((source + "\0" + text).encode("utf-8")).hexdigest()[:12]
|
|
66
|
+
did = (did[:87] + "-" + suffix)[:100]
|
|
67
|
+
row["id"] = did
|
|
68
|
+
documents[did] = row
|
|
69
|
+
_save(data)
|
|
70
|
+
return row
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def index_directory(root, extensions=(".md", ".txt", ".json"), limit=500,
|
|
74
|
+
allowed_roots=None, max_bytes=_MAX_BYTES):
|
|
75
|
+
root = Path(root).expanduser().resolve()
|
|
76
|
+
if not root.is_dir():
|
|
77
|
+
raise ValueError("目录不存在")
|
|
78
|
+
if allowed_roots is not None:
|
|
79
|
+
roots = [Path(p).expanduser().resolve() for p in allowed_roots if p]
|
|
80
|
+
if not any(root == p or p in root.parents for p in roots):
|
|
81
|
+
raise ValueError("只能索引任务工作目录范围内的文件")
|
|
82
|
+
try:
|
|
83
|
+
file_limit = max(1, min(_MAX_FILES, int(limit)))
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
raise ValueError("limit 必须是整数")
|
|
86
|
+
try:
|
|
87
|
+
byte_limit = max(1, min(_MAX_BYTES, int(max_bytes)))
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
raise ValueError("max_bytes 必须是整数")
|
|
90
|
+
count = 0
|
|
91
|
+
total_bytes = 0
|
|
92
|
+
for path in root.rglob("*"):
|
|
93
|
+
if count >= file_limit or total_bytes >= byte_limit:
|
|
94
|
+
break
|
|
95
|
+
# Never follow a symlink while indexing user-controlled trees.
|
|
96
|
+
if path.is_symlink() or any(part in _SKIP_DIRS for part in path.relative_to(root).parts):
|
|
97
|
+
continue
|
|
98
|
+
if path.is_file() and path.suffix.lower() in extensions:
|
|
99
|
+
try:
|
|
100
|
+
resolved = path.resolve()
|
|
101
|
+
if resolved != root and root not in resolved.parents:
|
|
102
|
+
continue
|
|
103
|
+
size = path.stat().st_size
|
|
104
|
+
if size > byte_limit - total_bytes:
|
|
105
|
+
continue
|
|
106
|
+
upsert(str(path.relative_to(root)), path.read_text(encoding="utf-8", errors="replace"),
|
|
107
|
+
{"root": str(root)})
|
|
108
|
+
count += 1
|
|
109
|
+
total_bytes += size
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
return {"indexed": count, "bytes": total_bytes,
|
|
113
|
+
"total": len(_load().get("documents") or {})}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def search(query, limit=10):
|
|
117
|
+
try:
|
|
118
|
+
n = max(1, min(50, int(limit or 10)))
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
raise ValueError("limit 必须是整数")
|
|
121
|
+
terms = set(_terms(str(query or "")[:500]))
|
|
122
|
+
if not terms:
|
|
123
|
+
return []
|
|
124
|
+
with _LOCK:
|
|
125
|
+
docs = list((_load().get("documents") or {}).values())
|
|
126
|
+
rows = []
|
|
127
|
+
for doc in docs:
|
|
128
|
+
matched = sorted(terms.intersection(doc.get("terms") or []))
|
|
129
|
+
if not matched:
|
|
130
|
+
continue
|
|
131
|
+
score = len(matched) / max(1, len(terms))
|
|
132
|
+
rows.append({"id": doc.get("id"), "source": doc.get("source"),
|
|
133
|
+
"score": round(score, 4), "matched_terms": matched,
|
|
134
|
+
"snippet": str(doc.get("text") or "")[:600],
|
|
135
|
+
"metadata": doc.get("metadata") or {}})
|
|
136
|
+
rows.sort(key=lambda x: (-x["score"], x["source"] or ""))
|
|
137
|
+
return rows[:n]
|