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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- 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
|