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.
@@ -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 = "\n\n".join(_read_chapter(workdir, i) for i in range(1, end + 1))
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
- global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
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 轮:全局评审未过,重改最弱章 %s" % (
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
- crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
4061
- "\n- 重改要求:优先修全书节奏/衔接问题(章间过渡、信息倾泻、主角主动性),"
4062
- "再补本章短板;不得改动既有剧情主线的关键事实。"
4063
- % (dims_txt or "(无)", threshold, gj or "(无)"))
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 = "\n\n".join(_read_chapter(workdir, i2) for i2 in range(1, end + 1))
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 if x.get("severity") == "major"][:12]
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
@@ -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]