codebee 0.1.0

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/app/__init__.py +0 -0
  4. package/app/core/__init__.py +0 -0
  5. package/app/core/attachments.py +322 -0
  6. package/app/core/automation.py +585 -0
  7. package/app/core/bookmeta.py +296 -0
  8. package/app/core/capability.py +130 -0
  9. package/app/core/catalog.py +319 -0
  10. package/app/core/compaction.py +186 -0
  11. package/app/core/diagnostics.py +115 -0
  12. package/app/core/env_scrub.py +84 -0
  13. package/app/core/error_codes.py +65 -0
  14. package/app/core/flows.py +328 -0
  15. package/app/core/gitmod.py +949 -0
  16. package/app/core/goal_service.py +159 -0
  17. package/app/core/health.py +294 -0
  18. package/app/core/history.py +32 -0
  19. package/app/core/jobs.py +424 -0
  20. package/app/core/manager.py +1415 -0
  21. package/app/core/market.py +299 -0
  22. package/app/core/market_remote.py +896 -0
  23. package/app/core/mocks.py +64 -0
  24. package/app/core/modelhub.py +2750 -0
  25. package/app/core/paths.py +60 -0
  26. package/app/core/pipeline.py +2161 -0
  27. package/app/core/planner.py +493 -0
  28. package/app/core/registry.py +105 -0
  29. package/app/core/remote.py +303 -0
  30. package/app/core/repeat_guard.py +124 -0
  31. package/app/core/router.py +120 -0
  32. package/app/core/runner.py +856 -0
  33. package/app/core/selfupdate.py +170 -0
  34. package/app/core/session_log.py +162 -0
  35. package/app/core/sessions.py +312 -0
  36. package/app/core/settings.py +85 -0
  37. package/app/core/settings_schema.py +250 -0
  38. package/app/core/skillpacks/fanqie-novel.md +80 -0
  39. package/app/core/skillpacks/market/character-bible.md +66 -0
  40. package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
  41. package/app/core/skillpacks/market/git-workflow.md +57 -0
  42. package/app/core/skillpacks/market/release-notes.md +72 -0
  43. package/app/core/skillpacks/market/weekly-report.md +71 -0
  44. package/app/core/skillpacks/market/worldview-consistency.md +70 -0
  45. package/app/core/skillpacks/qimao-signing.md +105 -0
  46. package/app/core/skills.py +649 -0
  47. package/app/core/step_runner.py +61 -0
  48. package/app/core/store.py +1321 -0
  49. package/app/core/token_meter.py +130 -0
  50. package/app/core/usage.py +450 -0
  51. package/app/main.py +1448 -0
  52. package/app/ui/app.js +8021 -0
  53. package/app/ui/i18n.js +1709 -0
  54. package/app/ui/icons/brand-horizontal.png +0 -0
  55. package/app/ui/icons/brand-square.png +0 -0
  56. package/app/ui/icons/icon-192.png +0 -0
  57. package/app/ui/icons/icon-512.png +0 -0
  58. package/app/ui/icons/logo-horizontal.png +0 -0
  59. package/app/ui/icons/logo-mark.png +0 -0
  60. package/app/ui/index.html +864 -0
  61. package/app/ui/manifest.json +16 -0
  62. package/app/ui/qrcode.js +2297 -0
  63. package/app/ui/style.css +2733 -0
  64. package/bin/tutti.js +121 -0
  65. package/package.json +39 -0
@@ -0,0 +1,649 @@
1
+ # -*- coding: utf-8 -*-
2
+ """经验库(Skills):内置写作/工程规范包 + 用户自建包 + 运行中自动沉淀的教训。
3
+
4
+ 设计稿:docs/migration/04-skills-seam.md §3A/§3B(按 Tutti 实际架构落地:
5
+ 模型在外部 CLI 中无法按需调「skill 工具」,注入式 block_for 是唯一通道,
6
+ 故 dsh 的「按需正文加载」不适用;落地的是多源 provider + persona + mtime 热缓存)。
7
+
8
+ 三层内容:
9
+ 1) **内置经验包**(app/core/skillpacks/*.md):人工维护的领域规范(如七猫签约标准),
10
+ 按流程类型(scope)匹配注入;
11
+ 2) **用户自建包**(data/skillpacks/*.md):frontmatter 声明 name/scopes/persona,
12
+ 无需改代码即可沉淀领域规范;目录 mtime 缓存,改文件即生效(3A 多源分层);
13
+ 3) **自动教训**(data/skills.json):每次运行结束后由编排者(或退化规则)总结本次
14
+ 评审暴露的问题,去重沉淀为可复用教训,下次同类任务自动带上——**越跑越好**。
15
+
16
+ Persona(3B):包的 frontmatter `persona:` 字段注入为独立的「角色设定」块,
17
+ 排在正文之前(不与规范正文混排)。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import re
24
+ import threading
25
+ import time
26
+ from pathlib import Path
27
+
28
+ from . import paths
29
+
30
+ _LOCK = threading.RLock()
31
+ _FILE = paths.DATA_DIR / "skills.json"
32
+ PACK_DIR = paths.APP_DIR / "core" / "skillpacks"
33
+
34
+
35
+ def _user_pack_dir():
36
+ """用户自建包目录:每次现取 paths.DATA_DIR(测试重定向后自动跟随)。"""
37
+ return paths.DATA_DIR / "skillpacks"
38
+
39
+ MAX_INJECT_CHARS = 9000 # 单次注入上限(防止提示词爆炸;七猫+番茄双平台包并存后上调)
40
+ MAX_LESSONS_INJECT = 8 # 注入的自动教训条数上限
41
+
42
+ # 自动教训的问题分类:闭集枚举,对齐评审维度。沉淀时由复盘官归类(兜底路径按评审
43
+ # 维度关键词映射),UI 据此分类过滤查看。刻意保持小而稳,避免类别爆炸让过滤失去意义。
44
+ LESSON_CATEGORIES = ["情节逻辑", "人物塑造", "节奏爽点", "文笔风格", "一致性", "流程规范"]
45
+ LESSON_UNCATEGORIZED = "未分类" # 无法归类的兜底
46
+ # dim(评审维度名)/标题/正文 → 分类:按关键词就近命中,首个匹配者胜。
47
+ # 顺序敏感:流程规范排在一致性前(「不一致」这类工程标题不该误入一致性类);
48
+ # 一致性刻意不收裸「一致」。文笔风格不收裸「重复」(易误伤「重复修复」类工程教训)。
49
+ _CATEGORY_KEYWORDS = [
50
+ ("情节逻辑", ("情节", "剧情", "主线", "冲突", "逻辑", "事件", "伏笔", "填坑", "转折")),
51
+ ("人物塑造", ("人物", "角色", "弧光", "人设", "性格", "动机", "ooc", "崩人设")),
52
+ ("节奏爽点", ("节奏", "爽点", "钩子", "吸引力", "开篇", "黄金三章", "追读", "断章", "高潮")),
53
+ ("流程规范", ("流程", "规范", "格式", "字数", "签约", "交稿", "工程", "测试", "部署",
54
+ "接口", "验证", "评审", "验收", "修复", "单测", "用例", "verify", "review",
55
+ "失败", "定位", "诊断", "回归", "空转")),
56
+ ("一致性", ("连贯", "吃书", "时间线", "前后矛盾", "前后不一", "连续性", "设定冲突", "人设统一")),
57
+ ("文笔风格", ("文笔", "语言", "描写", "对白", "对话", "文风", "措辞", "病句")),
58
+ ]
59
+
60
+
61
+ def _category_from(text):
62
+ """把模型归类文本或评审维度名就近映射到闭集分类;命中不了返回 None。"""
63
+ s = str(text or "").strip().lower()
64
+ if not s:
65
+ return None
66
+ for cat, kws in _CATEGORY_KEYWORDS:
67
+ for kw in kws:
68
+ if kw.lower() in s:
69
+ return cat
70
+ return None
71
+
72
+
73
+ def _normalize_category(category, dim=None):
74
+ """把任意输入归一到闭集分类:先精确命中枚举(category 与 dim 都走精确),
75
+ 再按关键词映射 category,再退到 dim 关键词,最后 None(调用方据此落未分类)。"""
76
+ c = str(category or "").strip()
77
+ if c in LESSON_CATEGORIES:
78
+ return c
79
+ d = str(dim or "").strip()
80
+ if d in LESSON_CATEGORIES:
81
+ return d
82
+ return _category_from(c) or _category_from(d) or None
83
+
84
+ # 内置经验包:文件 → 适用流程(scope);scope 为空表示适用全部
85
+ BUILTIN_PACKS = [
86
+ {"id": "qimao-signing", "name": "七猫签约标准与写作规范",
87
+ "file": "qimao-signing.md",
88
+ "scopes": ["novel", "serial_novel"],
89
+ "note": "黄金一章、爽点纪律、期待感三源、人物红线、自检清单"},
90
+ {"id": "fanqie-novel", "name": "番茄小说写作与流量守则",
91
+ "file": "fanqie-novel.md",
92
+ "scopes": ["novel", "serial_novel"],
93
+ "note": "算法流量池/完读追读、黄金三章整体验、题材标签匹配、更新纪律、合同要点"},
94
+ ]
95
+
96
+ # ---------------------------------------------------------------- 用户自建包(3A)
97
+
98
+ # 用户包 mtime 缓存:路径 → (mtime, 解析结果)。文件改动即失效(零依赖替代 watchdog)
99
+ _user_pack_cache = {}
100
+ _user_dir_mtime = {"ts": 0.0, "ids": None}
101
+
102
+
103
+ def _parse_frontmatter(text):
104
+ """解析 markdown 头部 ```--- frontmatter ---```(YAML 子集:key: value / list)。
105
+
106
+ 返回 (meta: dict, body: str)。无 frontmatter 时 meta 为空、body 为原文。
107
+ """
108
+ if not text.startswith("---"):
109
+ return {}, text
110
+ end = text.find("\n---", 3)
111
+ if end < 0:
112
+ return {}, text
113
+ head = text[3:end].strip("\r\n")
114
+ body = text[end + 4:].lstrip("\r\n")
115
+ meta = {}
116
+ cur_list_key = None
117
+ for line in head.splitlines():
118
+ line = line.rstrip()
119
+ if not line.strip() or line.strip().startswith("#"):
120
+ continue
121
+ if line.lstrip().startswith("- "):
122
+ if cur_list_key:
123
+ meta.setdefault(cur_list_key, []).append(
124
+ line.lstrip()[2:].strip().strip("'\""))
125
+ continue
126
+ m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
127
+ if m:
128
+ key, val = m.group(1), m.group(2).strip()
129
+ if val == "":
130
+ meta[key] = []
131
+ cur_list_key = key
132
+ else:
133
+ meta[key] = val.strip("'\"")
134
+ cur_list_key = None
135
+ return meta, body
136
+
137
+
138
+ def _user_pack_dir_mtime():
139
+ try:
140
+ return _user_pack_dir().stat().st_mtime
141
+ except OSError:
142
+ return 0.0
143
+
144
+
145
+ def _list_user_pack_files():
146
+ """data/skillpacks/*.md 文件清单(目录 mtime 缓存)。"""
147
+ with _LOCK:
148
+ dts = _user_pack_dir_mtime()
149
+ if _user_dir_mtime["ids"] is not None and dts == _user_dir_mtime["ts"]:
150
+ return _user_dir_mtime["ids"]
151
+ files = []
152
+ try:
153
+ _user_pack_dir().mkdir(parents=True, exist_ok=True)
154
+ for p in sorted(_user_pack_dir().glob("*.md")):
155
+ files.append(p)
156
+ except OSError:
157
+ pass
158
+ _user_dir_mtime["ts"] = dts
159
+ _user_dir_mtime["ids"] = files
160
+ return files
161
+
162
+
163
+ def _load_user_pack(path):
164
+ """读取并解析一个用户包(文件 mtime 缓存)。文件缺失/不可读返回 None。"""
165
+ try:
166
+ mt = path.stat().st_mtime
167
+ except OSError:
168
+ return None
169
+ key = str(path)
170
+ with _LOCK:
171
+ hit = _user_pack_cache.get(key)
172
+ if hit and hit[0] == mt:
173
+ return hit[1]
174
+ try:
175
+ raw = path.read_text(encoding="utf-8", errors="replace")
176
+ except OSError:
177
+ return None
178
+ meta, body = _parse_frontmatter(raw)
179
+ stem = path.stem
180
+ pack = {
181
+ "id": "user-" + hashlib.sha256(stem.encode("utf-8")).hexdigest()[:10],
182
+ "name": str(meta.get("name") or stem),
183
+ "file": key,
184
+ "scopes": [str(s) for s in (meta.get("scopes") or ["*"])] or ["*"],
185
+ "note": str(meta.get("note") or ""),
186
+ "persona": str(meta.get("persona") or ""),
187
+ "builtin": False,
188
+ "user": True,
189
+ }
190
+ pack["body"] = body
191
+ with _LOCK:
192
+ _user_pack_cache[key] = (mt, pack)
193
+ return pack
194
+
195
+
196
+ def user_packs():
197
+ """全部用户自建包(每次现读清单 + mtime 缓存正文——改文件即生效)。"""
198
+ out = []
199
+ for p in _list_user_pack_files():
200
+ pack = _load_user_pack(p)
201
+ if pack:
202
+ out.append(pack)
203
+ return out
204
+
205
+
206
+ def _now():
207
+ return time.strftime("%Y-%m-%d %H:%M:%S")
208
+
209
+
210
+ def _load():
211
+ try:
212
+ data = json.loads(_FILE.read_text(encoding="utf-8"))
213
+ return data if isinstance(data, dict) else {}
214
+ except Exception:
215
+ return {}
216
+
217
+
218
+ def _save(data):
219
+ _FILE.parent.mkdir(parents=True, exist_ok=True)
220
+ tmp = _FILE.with_suffix(".tmp")
221
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
222
+ tmp.replace(_FILE)
223
+
224
+
225
+ # ---------------------------------------------------------------- 内置经验包
226
+
227
+ def pack_text(pack):
228
+ """读取包正文(内置包相对 skillpacks/,用户包 file 为绝对路径)。文件缺失返回空串。"""
229
+ try:
230
+ if pack.get("user"):
231
+ p = Path(pack["file"])
232
+ return p.read_text(encoding="utf-8", errors="replace") if p.is_file() else ""
233
+ p = (PACK_DIR / pack["file"]).resolve()
234
+ if PACK_DIR.resolve() not in p.parents or not p.is_file():
235
+ return ""
236
+ return p.read_text(encoding="utf-8", errors="replace")
237
+ except Exception:
238
+ return ""
239
+
240
+
241
+ def all_packs():
242
+ """内置 + 用户自建包的并集(3A 多源分层:同名场景下内置优先)。"""
243
+ builtin = [dict(p) for p in BUILTIN_PACKS]
244
+ builtin_ids = {p["id"] for p in builtin}
245
+ out = list(builtin)
246
+ for up in user_packs():
247
+ if up["id"] not in builtin_ids:
248
+ out.append(up)
249
+ return out
250
+
251
+
252
+ def list_packs():
253
+ with _LOCK:
254
+ state = _load().get("packs") or {}
255
+ out = []
256
+ for p in all_packs():
257
+ st = state.get(p["id"]) or {}
258
+ out.append({"id": p["id"], "name": p["name"], "scopes": p["scopes"],
259
+ "note": p.get("note", ""), "builtin": bool(p.get("builtin")),
260
+ "user": bool(p.get("user")),
261
+ "persona": bool(p.get("persona")),
262
+ "enabled": bool(st.get("enabled", True)),
263
+ "chars": len(pack_text(p))})
264
+ return out
265
+
266
+
267
+ def _pack_enabled(pid):
268
+ with _LOCK:
269
+ st = (_load().get("packs") or {}).get(pid) or {}
270
+ return bool(st.get("enabled", True))
271
+
272
+
273
+ # ---------------------------------------------------------------- 自动教训
274
+
275
+ def _norm_title(t):
276
+ """标题归一化:去掉空白与中英文标点(含全角),用于跨次运行去重。"""
277
+ s = re.sub(r"[\s\.,;:!?,。、;:!?…·—-\-_/\\|@#$%^&*+=~`'\"“”‘’()()【】\[\]《》<>]+",
278
+ "", str(t or ""))
279
+ return s[:40]
280
+
281
+
282
+ def _lesson_id(scope, title):
283
+ h = hashlib.sha256(("%s|%s" % (scope, _norm_title(title))).encode("utf-8")).hexdigest()[:12]
284
+ return "sk-" + h
285
+
286
+
287
+ def list_lessons(scope=None, only_enabled=False, category=None):
288
+ with _LOCK:
289
+ items = list((_load().get("lessons") or []))
290
+ if scope:
291
+ items = [x for x in items if x.get("scope") in (scope, "*")]
292
+ if category:
293
+ items = [x for x in items
294
+ if (x.get("category") or LESSON_UNCATEGORIZED) == category]
295
+ if only_enabled:
296
+ items = [x for x in items if x.get("enabled", True)]
297
+ items.sort(key=lambda x: (-int(x.get("hits") or 0), -int(x.get("seen") or 1),
298
+ x.get("created_at") or ""))
299
+ return items
300
+
301
+
302
+ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
303
+ """写入/合并一条教训:同 scope 同标题视为同一条(seen+1,内容取新的)。
304
+
305
+ category 为闭集枚举之一(见 LESSON_CATEGORIES);输入非法时退到 dim 关键词映射,
306
+ 仍归不出则落「未分类」。id 仍只按 scope+标题哈希,故老教训再沉淀会合并而非分裂。
307
+ """
308
+ title = str(title or "").strip()[:60]
309
+ content = str(content or "").strip()[:1200]
310
+ if not title or not content:
311
+ return None
312
+ cat = _normalize_category(category, dim) or LESSON_UNCATEGORIZED
313
+ lid = _lesson_id(scope, title)
314
+ with _LOCK:
315
+ data = _load()
316
+ items = data.setdefault("lessons", [])
317
+ for it in items:
318
+ if it.get("id") == lid:
319
+ it["content"] = content
320
+ it["seen"] = int(it.get("seen") or 1) + 1
321
+ it["updated_at"] = _now()
322
+ # 合并时不降级已有分类:除非本次归到了明确类别,或该条原本没有分类
323
+ if cat != LESSON_UNCATEGORIZED or not it.get("category"):
324
+ it["category"] = cat
325
+ if source:
326
+ it["source"] = source
327
+ _save(data)
328
+ return it
329
+ it = {"id": lid, "scope": scope, "title": title, "content": content,
330
+ "source": source, "hits": 0, "seen": 1, "enabled": True,
331
+ "category": cat,
332
+ "created_at": _now(), "kind": "lesson"}
333
+ items.append(it)
334
+ _save(data)
335
+ return it
336
+
337
+
338
+ def lesson_op(lesson_id, op):
339
+ """启用/停用/删除教训。返回错误或 None。"""
340
+ if op not in ("enable", "disable", "delete"):
341
+ return "未知操作 " + str(op)
342
+ with _LOCK:
343
+ data = _load()
344
+ items = data.get("lessons") or []
345
+ hit = next((x for x in items if x.get("id") == lesson_id), None)
346
+ if not hit:
347
+ return "教训不存在"
348
+ if op == "delete":
349
+ data["lessons"] = [x for x in items if x.get("id") != lesson_id]
350
+ else:
351
+ hit["enabled"] = (op == "enable")
352
+ _save(data)
353
+ return None
354
+
355
+
356
+ def pack_op(pack_id, op):
357
+ """启用/停用包(内置与用户自建均可停用;用户包不可通过此接口删除,删文件即可)。"""
358
+ if op not in ("enable", "disable"):
359
+ return "未知操作 " + str(op)
360
+ known = {p["id"] for p in all_packs()}
361
+ if pack_id not in known:
362
+ return "经验包不存在"
363
+ with _LOCK:
364
+ data = _load()
365
+ packs = data.setdefault("packs", {})
366
+ packs[pack_id] = {"enabled": (op == "enable")}
367
+ _save(data)
368
+ return None
369
+
370
+
371
+ # ---------------------------------------------------------------- 注入
372
+
373
+ def _text_bigrams(text):
374
+ """中文友好的零依赖关键词:去空白/标点后取字符 bigram 集合。
375
+ 不引入分词依赖,对中文标题/短句的重叠度足够区分相关性。"""
376
+ t = re.sub(r"[\s\W_]+", "", str(text or ""))
377
+ return {t[i:i + 2] for i in range(len(t) - 1)}
378
+
379
+
380
+ def relevance_top(lessons, task, limit):
381
+ """任务相关性 top-k(pro-workflow 思想):教训积累超过注入上限时,
382
+ 按与任务目标/上下文/标题的重叠度选最相关的 limit 条,而不是只看 hits。
383
+ 排序依据在单个 run 内恒定(goal 固定、id 唯一),同一任务字节稳定,
384
+ 不碎供应商前缀缓存。不超过上限时不重排,保持既有 hits 语义。"""
385
+ if len(lessons) <= limit:
386
+ return lessons
387
+ probe = (_text_bigrams((task or {}).get("goal"))
388
+ | _text_bigrams((task or {}).get("context"))
389
+ | _text_bigrams((task or {}).get("title")))
390
+ if not probe:
391
+ return lessons[:limit]
392
+
393
+ def rank(x):
394
+ grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
395
+ return (-len(probe & grams), x.get("id") or "")
396
+
397
+ return sorted(lessons, key=rank)[:limit]
398
+
399
+
400
+ def block_for(task, scope_override=None, *, stable_order=False):
401
+ """生成注入提示词的经验块。命中即计数。返回 (文本, 命中的 id 列表)。
402
+
403
+ 3A:内置包 + 用户自建包都参与 scope 匹配;3B:带 persona 的包先注入
404
+ 「角色设定」块再注入规范正文。
405
+ stable_order=True(docs/migration/07-token-cost.md T1.2'):教训按 id 排序
406
+ 而非 hits——hits 在任务中途变化会让技能块字节级不稳定,打碎供应商的
407
+ 前缀缓存(同一任务 8 章应看到完全相同的技能块)。内容不变,只稳排序。
408
+ """
409
+ scope = scope_override or task.get("type") or "*"
410
+ parts, used = [], []
411
+
412
+ for p in all_packs():
413
+ if scope not in p["scopes"] and "*" not in p["scopes"]:
414
+ continue
415
+ if not _pack_enabled(p["id"]):
416
+ continue
417
+ txt = pack_text(p).strip()
418
+ if not txt and not p.get("persona"):
419
+ continue
420
+ # 3B:persona 独立成块(角色设定与规范正文分开,模型更易区分 obey 层级)
421
+ if p.get("persona"):
422
+ parts.append("### 【角色设定:%s】\n%s" % (p["name"], str(p["persona"]).strip()))
423
+ if txt:
424
+ parts.append("### 【%s】\n%s" % (p["name"], txt))
425
+ used.append(p["id"])
426
+
427
+ lessons = relevance_top(list_lessons(scope, only_enabled=True), task,
428
+ MAX_LESSONS_INJECT)
429
+ if lessons:
430
+ if stable_order:
431
+ lessons.sort(key=lambda x: x.get("id") or "")
432
+ lines = []
433
+ for x in lessons:
434
+ lines.append("- **%s**:%s" % (x["title"], x["content"]))
435
+ used.append(x["id"])
436
+ parts.append("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n" + "\n".join(lines))
437
+
438
+ if not parts:
439
+ return "", []
440
+ text = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n" + "\n\n".join(parts)
441
+ if len(text) > MAX_INJECT_CHARS:
442
+ text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
443
+ if used:
444
+ bump_hits(used)
445
+ return text, used
446
+
447
+
448
+ def bump_hits(ids):
449
+ ids = [i for i in (ids or []) if i]
450
+ if not ids:
451
+ return
452
+ with _LOCK:
453
+ data = _load()
454
+ changed = False
455
+ for it in (data.get("lessons") or []):
456
+ if it.get("id") in ids:
457
+ it["hits"] = int(it.get("hits") or 0) + 1
458
+ changed = True
459
+ if changed:
460
+ _save(data)
461
+
462
+
463
+ # ---------------------------------------------------------------- 运行后自动总结(自学习闭环)
464
+
465
+ LEARN_PROMPT = """你是编排系统的复盘官。下面是刚结束的一次任务运行的评审结果与主要问题。
466
+ 请把**可复用到下次同类任务**的经验教训提炼出来(不要复述本次剧情,不要写泛泛的套话)。
467
+ 只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
468
+ {"lessons": [{"title": "≤14 字的问题归类", "category": "问题分类", "content": "下次必须怎么做/避免什么(≤120 字,具体可执行)"}]}
469
+ category 必须从以下固定枚举中选一个(贴合评审维度,不要自创类别):
470
+ __CATEGORIES__
471
+ 最多 5 条,只保留反复出现或影响过稿/验收的关键问题;没有值得沉淀的就返回空数组。
472
+
473
+ ## 任务类型
474
+ __TYPE__
475
+
476
+ ## 任务目标(摘要)
477
+ __GOAL__
478
+
479
+ ## 本次结论
480
+ __VERDICT__
481
+
482
+ ## 评审主要问题
483
+ __ISSUES__"""
484
+
485
+
486
+ def _collect_issues(run):
487
+ """从 verdict / 报告里提取 major 问题与低分维度(确定性兜底用)。"""
488
+ v = run.get("verdict") or {}
489
+ issues = []
490
+ for c in (v.get("chapter_scores") or []):
491
+ m = c.get("means") or {}
492
+ if not m:
493
+ continue
494
+ low = [d for d, s in m.items() if float(s) < float(v.get("threshold") or 7.0)]
495
+ if low:
496
+ issues.append({"dim": "、".join(low), "severity": "major",
497
+ "note": "第 %s 章《%s》维度偏低(%s)" % (
498
+ c.get("chapter"), c.get("title"),
499
+ ",".join("%s %.1f" % (d, m[d]) for d in low))})
500
+ gs = v.get("global_scores") or {}
501
+ low_g = [d for d, s in gs.items() if float(s) < float(v.get("threshold") or 7.0)]
502
+ if low_g:
503
+ issues.append({"dim": "、".join(low_g), "severity": "major",
504
+ "note": "全书一致性评审偏低:%s" % ",".join("%s %.1f" % (d, gs[d]) for d in low_g)})
505
+ # 报告里的 major 明细(比 verdict 更细)
506
+ try:
507
+ p = paths.RUNS_DIR / run["id"] / "report.md"
508
+ if p.is_file():
509
+ txt = p.read_text(encoding="utf-8", errors="replace")
510
+ tail = txt.split("## 主要问题")[-1] if "## 主要问题" in txt else ""
511
+ for line in tail.splitlines():
512
+ line = line.strip()
513
+ if line.startswith("- [") and len(issues) < 30:
514
+ issues.append({"dim": "报告", "severity": "major", "note": line[2:][:200]})
515
+ except Exception:
516
+ pass
517
+ return issues[:30]
518
+
519
+
520
+ def _fallback_lessons(task, run):
521
+ """无编排者时的确定性兜底:按维度把反复出现的问题聚成教训。"""
522
+ v = run.get("verdict") or {}
523
+ out = []
524
+ weak = {}
525
+ for c in (v.get("chapter_scores") or []):
526
+ for d, s in (c.get("means") or {}).items():
527
+ if float(s) < float(v.get("threshold") or 7.0):
528
+ weak.setdefault(d, []).append((c.get("chapter"), float(s)))
529
+ for d, lst in sorted(weak.items(), key=lambda kv: -len(kv[1]))[:3]:
530
+ chs = "、".join("第 %s 章(%.1f)" % (c, s) for c, s in lst[:4])
531
+ out.append({"dim": d,
532
+ "title": "%s 维度反复不达标" % d,
533
+ "content": "历史运行中 %s 的「%s」多次低于阈值(%s)。写这一维度前先对照经验包自检,"
534
+ "宁可少写事件也要把该维度做扎实。" % (task.get("type"), d, chs)})
535
+ for d, s in (v.get("global_scores") or {}).items():
536
+ if float(s) < float(v.get("threshold") or 7.0):
537
+ out.append({"dim": d,
538
+ "title": "全书「%s」被一致性评审扣分" % d,
539
+ "content": "单章达标但全书「%s」仅 %.1f 分。下一部作品在章纲阶段就要规划该维度的"
540
+ "整体曲线(而不是逐章各写各的)。" % (d, float(s))})
541
+ return out[:5]
542
+
543
+
544
+ def learn_from_run(run_id, use_orchestrator=True):
545
+ """运行结束后自动总结教训并沉淀。返回写入条数。"""
546
+ from . import store
547
+ run = store.get_run(run_id)
548
+ if not run:
549
+ return 0
550
+ task = store.get_task(run.get("task_id")) if run.get("task_id") else None
551
+ if not task:
552
+ return 0
553
+ # mock 运行不沉淀(没有真实评审信号)
554
+ if all((s.get("agent") or "").startswith("mock") for s in (run.get("steps") or [])):
555
+ return 0
556
+ v = run.get("verdict") or {}
557
+ if not v:
558
+ return 0
559
+ issues = _collect_issues(run)
560
+ lessons = []
561
+ if use_orchestrator:
562
+ try:
563
+ from . import modelhub, runner
564
+ orch = modelhub.resolve_orchestrator()
565
+ if orch:
566
+ prov, model = orch
567
+ verdict_txt = json.dumps({k: v[k] for k in v if k not in ("route", "chapter_scores")},
568
+ ensure_ascii=False)[:1200]
569
+ prompt = (LEARN_PROMPT.replace("__CATEGORIES__", "、".join(LESSON_CATEGORIES))
570
+ .replace("__TYPE__", str(task.get("type")))
571
+ .replace("__GOAL__", (task.get("goal") or "")[:600])
572
+ .replace("__VERDICT__", verdict_txt)
573
+ .replace("__ISSUES__",
574
+ "\n".join("- %s" % i.get("note", "") for i in issues)[:3000] or "(无)"))
575
+ # 推理模型的思考会吞掉全部预算:max_tokens 给足才有正文可解析
576
+ res = modelhub.chat(prov["id"], model, prompt, max_tokens=8000, timeout=300)
577
+ if res.get("ok"):
578
+ data = runner.extract_json(res.get("text") or "")
579
+ raw = (data or {}).get("lessons") if isinstance(data, dict) else None
580
+ if isinstance(raw, list):
581
+ for x in raw[:5]:
582
+ if isinstance(x, dict) and x.get("title") and x.get("content"):
583
+ lessons.append({"title": str(x["title"]), "content": str(x["content"]),
584
+ "category": x.get("category")})
585
+ except Exception:
586
+ lessons = []
587
+ if not lessons:
588
+ lessons = _fallback_lessons(task, run)
589
+ n = 0
590
+ for x in lessons:
591
+ if upsert_lesson(task.get("type") or "*", x["title"], x["content"], source=run_id,
592
+ category=x.get("category"), dim=x.get("dim")):
593
+ n += 1
594
+ return n
595
+
596
+
597
+ def learn_async(run_id):
598
+ """异步总结(不阻塞任务收尾)。"""
599
+ def _run():
600
+ try:
601
+ learn_from_run(run_id)
602
+ except Exception:
603
+ pass
604
+ threading.Thread(target=_run, name="skill-learn", daemon=True).start()
605
+
606
+
607
+ def view():
608
+ """经验库总览(给 UI/API)。categories:闭集枚举 + 实际出现过的分类,
609
+ counts 给每类条数,供 UI 下拉过滤与计数显示。"""
610
+ lessons = list_lessons()
611
+ counts = {}
612
+ for x in lessons:
613
+ c = x.get("category") or LESSON_UNCATEGORIZED
614
+ counts[c] = counts.get(c, 0) + 1
615
+ cats = list(LESSON_CATEGORIES)
616
+ for c in sorted(counts): # 历史里出现过的额外分类也带上(不丢过滤项)
617
+ if c not in cats:
618
+ cats.append(c)
619
+ return {"packs": list_packs(), "lessons": lessons,
620
+ "categories": cats, "counts": counts, "total": len(lessons)}
621
+
622
+
623
+ def migrate_lesson_categories():
624
+ """一次性迁移:给分类字段上线前沉淀的教训按 标题→正文 关键词回填 category。
625
+
626
+ 幂等——已有 category 的条目一律不动(不覆盖复盘官/用户的判断);
627
+ 全部都有分类时零写入。改动前留 .bak。返回回填条数。"""
628
+ with _LOCK:
629
+ data = _load()
630
+ items = data.get("lessons") or []
631
+ dirty = 0
632
+ for it in items:
633
+ if it.get("category"):
634
+ continue
635
+ cat = (_category_from(it.get("title"))
636
+ or _category_from(it.get("content")))
637
+ if cat:
638
+ it["category"] = cat
639
+ dirty += 1
640
+ if not dirty:
641
+ return 0
642
+ try:
643
+ bak = _FILE.with_suffix(".json.bak")
644
+ if _FILE.is_file() and not bak.is_file():
645
+ bak.write_bytes(_FILE.read_bytes())
646
+ except Exception:
647
+ pass
648
+ _save(data)
649
+ return dirty