codebee 0.1.72 → 0.1.74

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/app/core/store.py CHANGED
@@ -195,6 +195,18 @@ def create_task(payload):
195
195
  # 参数修订号(借鉴 WorkDSH 契约纪律 expectedRevision):写接口据此做过期
196
196
  # 写入检测。老任务无此键按 1 处理;不带 expected_rev 的调用不受影响。
197
197
  task["rev"] = 1
198
+ # Durable task contract: acceptance criteria and approval requirement are
199
+ # persisted outside the task JSON so evidence/audit history survives task
200
+ # parameter edits and can be consumed by API/MCP clients independently.
201
+ raw_criteria = payload.get("acceptance_criteria")
202
+ if raw_criteria not in (None, "") and not isinstance(raw_criteria, list):
203
+ raise ValueError("acceptance_criteria 必须是列表")
204
+ task["acceptance_criteria"] = [str(x).strip()[:500] for x in
205
+ (raw_criteria or []) if str(x).strip()][:40]
206
+ approval_required = payload.get("approval_required", False)
207
+ if not isinstance(approval_required, bool):
208
+ raise ValueError("approval_required 必须是布尔值")
209
+ task["approval_required"] = approval_required
198
210
  if flow["engine"] == "direct":
199
211
  task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
200
212
  "direct_provider_id")[:80]
@@ -388,6 +400,24 @@ def create_task(payload):
388
400
  with LOCK:
389
401
  _TASKS[task["id"]] = task
390
402
  _save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
403
+ try:
404
+ from . import contracts
405
+ contracts.create(task["id"], {
406
+ "acceptance_criteria": task.get("acceptance_criteria"),
407
+ "approval_required": task.get("approval_required"),
408
+ "actor": "system",
409
+ })
410
+ except Exception:
411
+ # Contracts are an evidence enrichment layer; legacy task creation
412
+ # must remain available if a damaged optional contract file is found.
413
+ pass
414
+ if task.get("serial"):
415
+ try:
416
+ from . import story_tracking
417
+ story_tracking.init(task["workdir"], task["id"], task["title"],
418
+ premise=task.get("context") or task.get("goal") or "")
419
+ except Exception:
420
+ pass
391
421
  return task
392
422
 
393
423
 
@@ -535,6 +565,30 @@ def serial_chain_ids(task_id):
535
565
  return order
536
566
 
537
567
 
568
+ def chain_book_meta(task, platform):
569
+ """任务在某平台的作品信息「面板视图」:根优先返回首个有状态的条目。
570
+
571
+ 与 inherited_book_meta 同为根优先,但不筛 done——生成可从链上任意任务
572
+ 触发、落点在根任务,running/failed 也要沿链可见,否则续写批次触发生成
573
+ 后面板永远空转。running/done 优先于 failed(旧失败不遮新结果)。"""
574
+ task = task or {}
575
+ platform = str(platform)
576
+ own = ((task.get("book_meta") or {}).get(platform)) or {}
577
+ first_failed = None
578
+ for tid in serial_chain_ids(task.get("id") or ""):
579
+ t = task if tid == str(task.get("id")) else get_task(tid)
580
+ if not t:
581
+ continue
582
+ e = ((t.get("book_meta") or {}).get(platform)) or {}
583
+ if not (isinstance(e, dict) and e.get("status")):
584
+ continue
585
+ if e.get("status") in ("running", "done"):
586
+ return e
587
+ if first_failed is None:
588
+ first_failed = e
589
+ return first_failed or own
590
+
591
+
538
592
  def inherited_book_meta(task, platform):
539
593
  """任务在某平台的作品信息:连载链上「生成完成」的条目整链共用(都是用第一个)。
540
594
 
@@ -1050,6 +1104,22 @@ def update_run(run_id, expected_status=None, **fields):
1050
1104
  # Acceptance reporting is evidence enrichment; it must not
1051
1105
  # prevent the primary run from reaching a terminal state.
1052
1106
  pass
1107
+ try:
1108
+ from . import contracts
1109
+ for case in (run.get("acceptance") or {}).get("cases") or []:
1110
+ contracts.add_evidence(
1111
+ run.get("task_id") or "",
1112
+ {"criterion": "acceptance:%s" % case.get("case"),
1113
+ "status": "passed" if case.get("status") == "passed" else case.get("status"),
1114
+ "summary": case.get("reason") or "acceptance matrix",
1115
+ "source": "acceptance"}, actor="system")
1116
+ contracts.record_acceptance_cases(
1117
+ run.get("task_id") or "",
1118
+ (run.get("acceptance") or {}).get("cases") or [],
1119
+ actor="system")
1120
+ contracts.complete(run.get("task_id") or "", run)
1121
+ except Exception:
1122
+ pass
1053
1123
  _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1054
1124
  if st in TERMINAL_STATUSES and prev_status not in TERMINAL_STATUSES:
1055
1125
  # 事后对账:只有「非终态 → 终态」这一次翻转才记账,重复写终态
@@ -1720,6 +1790,8 @@ def continue_task(task_id, chapters=None):
1720
1790
  "difficulty": task.get("difficulty") or "auto",
1721
1791
  "implementer": task.get("implementer") or "",
1722
1792
  "critics": task.get("critics") or [],
1793
+ "acceptance_criteria": task.get("acceptance_criteria") or [],
1794
+ "approval_required": bool(task.get("approval_required")),
1723
1795
  "manuscript": task.get("manuscript") or "manuscript.md",
1724
1796
  "threshold": task.get("threshold") or 7.0,
1725
1797
  "serial": {"chapters": batch,
@@ -0,0 +1,241 @@
1
+ """Structured, deterministic long-form fiction tracking.
2
+
3
+ The JSON state is the source of truth. Markdown files under ``derived`` are
4
+ regenerated from that state and are checked for drift before a run consumes
5
+ them. This keeps long serial projects resumable without requiring a vector DB.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import time
13
+ from pathlib import Path
14
+
15
+ _MAX_CHAPTERS = 5000
16
+ _STATE_REL = Path(".codebee") / "story_tracking.json"
17
+ _DERIVED = Path(".codebee") / "derived"
18
+ _CHAPTERS = Path(".codebee") / "chapters"
19
+
20
+
21
+ def _now():
22
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
23
+
24
+
25
+ def _root(workdir):
26
+ root = Path(workdir).expanduser().resolve()
27
+ if not root.is_dir():
28
+ raise ValueError("工作目录不存在")
29
+ return root
30
+
31
+
32
+ def _state_path(root):
33
+ return root / _STATE_REL
34
+
35
+
36
+ def _digest(text):
37
+ return hashlib.sha256(str(text).encode("utf-8")).hexdigest()
38
+
39
+
40
+ def _empty(task_id, title, premise=""):
41
+ return {"version": 1, "task_id": str(task_id), "title": str(title or "")[:120],
42
+ "premise": str(premise or "")[:2000], "state_revision": 0,
43
+ "current_chapter": 0, "facts": [], "characters": [], "timeline": [],
44
+ "foreshadowing": [], "next_promises": [], "author_truth": [],
45
+ "reader_known": [], "chapters": [], "derived_digests": {},
46
+ "created_at": _now(), "updated_at": _now()}
47
+
48
+
49
+ def _save(root, state):
50
+ path = _state_path(root)
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ tmp = path.with_suffix(".tmp")
53
+ tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
54
+ tmp.replace(path)
55
+
56
+
57
+ def _load(root):
58
+ path = _state_path(root)
59
+ try:
60
+ data = json.loads(path.read_text(encoding="utf-8"))
61
+ except FileNotFoundError:
62
+ return None
63
+ except (OSError, ValueError) as exc:
64
+ raise ValueError("故事追踪状态不可读: %s" % exc)
65
+ if not isinstance(data, dict) or data.get("version") != 1:
66
+ raise ValueError("不支持的故事追踪状态版本")
67
+ for name in ("facts", "characters", "timeline", "foreshadowing",
68
+ "next_promises", "author_truth", "reader_known", "chapters"):
69
+ if not isinstance(data.get(name), list):
70
+ raise ValueError("故事追踪状态字段 %s 必须是列表" % name)
71
+ if not isinstance(data.get("derived_digests"), dict):
72
+ raise ValueError("故事追踪状态字段 derived_digests 必须是对象")
73
+ if any(not isinstance(row, dict) for row in data.get("chapters") or []):
74
+ raise ValueError("故事追踪状态 chapters 含无效记录")
75
+ return data
76
+
77
+
78
+ def init(workdir, task_id, title, premise=""):
79
+ root = _root(workdir)
80
+ state = _load(root)
81
+ if state is None:
82
+ state = _empty(task_id, title, premise)
83
+ _save(root, state)
84
+ _render(root, state)
85
+ return state
86
+
87
+
88
+ def load(workdir):
89
+ return _load(_root(workdir))
90
+
91
+
92
+ def _clean_lines(values, limit=100):
93
+ result = []
94
+ for value in values if isinstance(values, list) else []:
95
+ text = str(value or "").strip()[:1000]
96
+ if text and text not in result:
97
+ result.append(text)
98
+ if len(result) >= limit:
99
+ break
100
+ return result
101
+
102
+
103
+ def _clean_foreshadowing(values):
104
+ result = []
105
+ for raw in values if isinstance(values, list) else []:
106
+ if not isinstance(raw, dict):
107
+ continue
108
+ item = {"id": str(raw.get("id") or "").strip()[:40],
109
+ "text": str(raw.get("text") or "").strip()[:1000],
110
+ "status": str(raw.get("status") or "active").strip()[:30],
111
+ "planted_chapter": raw.get("planted_chapter"),
112
+ "payoff_chapter": raw.get("payoff_chapter")}
113
+ if item["id"] and item["text"]:
114
+ result.append(item)
115
+ return result[-500:]
116
+
117
+
118
+ def commit_chapter(workdir, chapter_no, outline, prose, *, facts=None, characters=None,
119
+ timeline=None, foreshadowing=None, next_promises=None,
120
+ author_truth=None, reader_known=None):
121
+ root = _root(workdir)
122
+ try:
123
+ number = int(chapter_no)
124
+ except (TypeError, ValueError):
125
+ raise ValueError("章节号无效")
126
+ if not 1 <= number <= _MAX_CHAPTERS:
127
+ raise ValueError("章节号必须在 1-%d 之间" % _MAX_CHAPTERS)
128
+ outline = str(outline or "").strip()
129
+ prose = str(prose or "").strip()
130
+ if not outline:
131
+ raise ValueError("提交章节前必须有细纲")
132
+ if not prose:
133
+ raise ValueError("正文不能为空")
134
+ state = _load(root)
135
+ if state is None:
136
+ state = _empty("", root.name)
137
+ # A shared workdir can be used by continuation tasks. The structured
138
+ # tracking file is book-level state, so accepting a newer task id here
139
+ # would make the next run look like a foreign-state drift. Keep the
140
+ # original owner and append chapters to the existing book timeline.
141
+ record = {"chapter": number, "outline": outline[:12000], "prose_digest": _digest(prose),
142
+ "word_count": len(prose), "committed_at": _now(),
143
+ "facts": _clean_lines(facts), "characters": _clean_lines(characters),
144
+ "timeline": _clean_lines(timeline), "foreshadowing": _clean_foreshadowing(foreshadowing),
145
+ "next_promises": _clean_lines(next_promises, 20),
146
+ "author_truth": _clean_lines(author_truth, 100), "reader_known": _clean_lines(reader_known, 100)}
147
+ existing = next((x for x in state["chapters"] if x.get("chapter") == number), None)
148
+ if existing and existing.get("prose_digest") == record["prose_digest"] and existing.get("outline") == outline:
149
+ return state
150
+ if existing:
151
+ state["chapters"] = [x for x in state["chapters"] if x.get("chapter") != number]
152
+ state["chapters"].append(record)
153
+ state["chapters"].sort(key=lambda x: int(x.get("chapter") or 0))
154
+ state["current_chapter"] = max(int(state.get("current_chapter") or 0), number)
155
+ state["facts"] = _clean_lines([x for c in state["chapters"] for x in c.get("facts", [])], 500)
156
+ state["characters"] = _clean_lines([x for c in state["chapters"] for x in c.get("characters", [])], 300)
157
+ state["timeline"] = _clean_lines([x for c in state["chapters"] for x in c.get("timeline", [])], 500)
158
+ state["foreshadowing"] = _clean_foreshadowing([x for c in state["chapters"] for x in c.get("foreshadowing", [])])
159
+ state["next_promises"] = record["next_promises"]
160
+ state["author_truth"] = record["author_truth"]
161
+ state["reader_known"] = record["reader_known"]
162
+ state["state_revision"] = int(state.get("state_revision") or 0) + 1
163
+ state["updated_at"] = _now()
164
+ chapter_dir = _CHAPTERS / ("%04d" % number)
165
+ (root / chapter_dir).mkdir(parents=True, exist_ok=True)
166
+ (root / chapter_dir / "outline.md").write_text(outline + "\n", encoding="utf-8")
167
+ (root / chapter_dir / "prose.md").write_text(prose + "\n", encoding="utf-8")
168
+ _save(root, state)
169
+ _render(root, state)
170
+ return state
171
+
172
+
173
+ def _render(root, state):
174
+ derived = root / _DERIVED
175
+ derived.mkdir(parents=True, exist_ok=True)
176
+ lines = ["# 故事上下文", "", "- 书名:%s" % state.get("title", ""),
177
+ "- 当前章:第%s章" % state.get("current_chapter", 0),
178
+ "- 状态修订:%s" % state.get("state_revision", 0), ""]
179
+ if state.get("premise"):
180
+ lines += ["## 核心 premise", "", state["premise"], ""]
181
+ lines += ["## 长期事实", ""] + ["- " + x for x in state.get("facts", [])] + [""]
182
+ lines += ["## 活跃伏笔", ""]
183
+ for item in state.get("foreshadowing", []):
184
+ lines.append("- %s|%s|%s" % (item.get("id"), item.get("text"), item.get("status")))
185
+ lines += ["", "## 下一章承诺", ""] + ["- " + x for x in state.get("next_promises", [])] + [""]
186
+ lines += ["## 作者真相", ""] + ["- " + x for x in state.get("author_truth", [])] + [""]
187
+ lines += ["## 读者已知", ""] + ["- " + x for x in state.get("reader_known", [])] + [""]
188
+ context = "\n".join(lines).rstrip() + "\n"
189
+ (derived / "context.md").write_text(context, encoding="utf-8")
190
+ (derived / "foreshadowing.md").write_text("# 伏笔\n\n" + "\n".join(
191
+ "- %s|%s|%s" % (x.get("id"), x.get("text"), x.get("status"))
192
+ for x in state.get("foreshadowing", [])) + "\n", encoding="utf-8")
193
+ (derived / "timeline.md").write_text("# 时间线\n\n" + "\n".join(
194
+ "- " + x for x in state.get("timeline", [])) + "\n", encoding="utf-8")
195
+ state["derived_digests"] = {}
196
+ for name in ("context.md", "foreshadowing.md", "timeline.md"):
197
+ state["derived_digests"][name] = _digest((derived / name).read_text(encoding="utf-8"))
198
+ _save(root, state)
199
+ try:
200
+ from . import retrieval
201
+ for name in ("context.md", "foreshadowing.md", "timeline.md"):
202
+ path = derived / name
203
+ retrieval.upsert(str(path), path.read_text(encoding="utf-8"),
204
+ {"task_id": state.get("task_id"), "kind": "story_tracking"})
205
+ except Exception:
206
+ pass
207
+
208
+
209
+ def check(workdir):
210
+ root = _root(workdir)
211
+ state = _load(root)
212
+ if not state:
213
+ return {"ok": False, "errors": ["缺少故事追踪状态"]}
214
+ errors = []
215
+ for name, digest in (state.get("derived_digests") or {}).items():
216
+ path = root / _DERIVED / name
217
+ if not path.is_file():
218
+ errors.append("缺少派生文件 %s" % name)
219
+ continue
220
+ actual = _digest(path.read_text(encoding="utf-8"))
221
+ if actual != digest:
222
+ errors.append("派生文件已被手工修改: %s" % name)
223
+ for chapter in state.get("chapters") or []:
224
+ number = int(chapter.get("chapter") or 0)
225
+ outline = root / _CHAPTERS / ("%04d" % number) / "outline.md"
226
+ prose = root / _CHAPTERS / ("%04d" % number) / "prose.md"
227
+ if not outline.is_file() or not prose.is_file():
228
+ errors.append("章节文件缺失: %04d" % number)
229
+ elif outline.read_text(encoding="utf-8").strip() != str(chapter.get("outline") or "").strip():
230
+ # A continuation run may legitimately replace the shared outline
231
+ # with a book-level outline containing additional future chapters.
232
+ # Only reject a mismatch when the persisted chapter's own outline
233
+ # is no longer represented in the current file.
234
+ persisted = str(chapter.get("outline") or "").strip()
235
+ current = outline.read_text(encoding="utf-8").strip()
236
+ if persisted and persisted not in current:
237
+ errors.append("章节细纲不一致: %04d" % number)
238
+ elif _digest(prose.read_text(encoding="utf-8").strip()) != chapter.get("prose_digest"):
239
+ errors.append("章节正文不一致: %04d" % number)
240
+ return {"ok": not errors, "errors": errors, "state_revision": state.get("state_revision", 0),
241
+ "current_chapter": state.get("current_chapter", 0)}