codebee 0.1.95 → 0.1.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +725 -720
- package/LICENSE +21 -21
- package/README.md +515 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -95
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1680 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +143 -0
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +198 -191
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +82 -0
- package/app/core/evalbench.py +184 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -0
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -4
- package/app/core/knowledge_pipeline.py +86 -0
- package/app/core/manager.py +2232 -2232
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -189
- package/app/core/openapi.py +177 -154
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +43 -19
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +52 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +26 -3
- package/app/core/publish/browser.py +655 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +210 -165
- package/app/core/publish/flow.py +753 -640
- package/app/core/publish/flows-fanqie-calibrated.json +349 -336
- package/app/core/publish/ledger.py +13 -1
- package/app/core/publish/manager.py +1150 -1118
- package/app/core/publish/qimao.py +176 -176
- package/app/core/publish/volumes.py +108 -0
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -194
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2238 -2238
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +20 -0
- package/app/core/settings_schema.py +270 -270
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/fanqie-novel.md +8 -6
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skillpacks/qimao-signing.md +14 -11
- package/app/core/skills.py +32 -21
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +9 -0
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +207 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -0
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +104 -9
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +286 -18
- package/app/ui/artdiff.js +74 -74
- package/app/ui/assets/community-qr.png +0 -0
- package/app/ui/assets/donate-alipay.jpg +0 -0
- package/app/ui/assets/donate-qr.jpg +0 -0
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +33 -0
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +32 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +196 -2
- package/bin/tutti.js +147 -147
- package/package.json +48 -48
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
package/app/core/capability.py
CHANGED
|
@@ -1,144 +1,144 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""能力维度(4D):按任务类型(writing/coding/reasoning/vision)选模型。
|
|
3
|
-
|
|
4
|
-
设计稿:docs/migration/05-model-seams.md §4D(按 CodeBee 实际架构落地:
|
|
5
|
-
不改 modelhub.py——供应商声明可选 `strengths` 字段即可被识别;
|
|
6
|
-
选择逻辑独立成模块,零侵入)。
|
|
7
|
-
|
|
8
|
-
供应商声明示例(data/models.json 的 providers 条目):
|
|
9
|
-
{"id": "p1", ..., "strengths": ["writing", "reasoning"]}
|
|
10
|
-
未声明 strengths 的供应商视为无偏好(只做兜底)。
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import re
|
|
15
|
-
|
|
16
|
-
from .dispatch import TYPE_DIMENSIONS
|
|
17
|
-
|
|
18
|
-
# 能力维度(先固化清单,schema 见设计稿)
|
|
19
|
-
DIMS = ("writing", "coding", "reasoning", "vision")
|
|
20
|
-
|
|
21
|
-
# 关键词分类表(覆盖 task type / 目标文本;可按需扩充)
|
|
22
|
-
_KEYWORDS = {
|
|
23
|
-
"writing": ("写", "文案", "大纲", "连载", "章节", "小说", "营销", "总结", "润色",
|
|
24
|
-
"novel", "draft", "review"),
|
|
25
|
-
"coding": ("代码", "实现", "修复", "重构", "测试", "bug", "code", "fix",
|
|
26
|
-
"refactor", "test", "编译", "报错"),
|
|
27
|
-
"reasoning": ("推理", "分析", "调研", "对比", "规划", "拆解", "评审",
|
|
28
|
-
"reason", "analyze", "plan", "orchestrate"),
|
|
29
|
-
"vision": ("图", "图片", "识别", "视觉", "ocr", "image", "vision", "截图"),
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def classify_task_type(task) -> str:
|
|
34
|
-
"""把任务分类到 DIMS 之一。task 显式带 task_type 时优先。"""
|
|
35
|
-
explicit = str((task or {}).get("task_type") or "").strip().lower()
|
|
36
|
-
if explicit in DIMS:
|
|
37
|
-
return explicit
|
|
38
|
-
preset = str((task or {}).get("type") or "").strip().lower()
|
|
39
|
-
if preset in TYPE_DIMENSIONS:
|
|
40
|
-
return TYPE_DIMENSIONS[preset]
|
|
41
|
-
text = "%s %s" % ((task or {}).get("type") or "",
|
|
42
|
-
(task or {}).get("goal") or "")
|
|
43
|
-
scores = {}
|
|
44
|
-
for dim, kws in _KEYWORDS.items():
|
|
45
|
-
scores[dim] = sum(1 for kw in kws if kw in text)
|
|
46
|
-
best = max(scores, key=lambda d: scores[d])
|
|
47
|
-
if scores[best] > 0:
|
|
48
|
-
return best
|
|
49
|
-
return "coding" # 默认兜底(CodeBee 主场景)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def provider_strengths(prov):
|
|
53
|
-
"""读供应商声明的能力维度(容错:非法值忽略)。"""
|
|
54
|
-
raw = (prov or {}).get("strengths")
|
|
55
|
-
if not isinstance(raw, list):
|
|
56
|
-
return []
|
|
57
|
-
return [s for s in raw if s in DIMS]
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def pick_by_strength(providers, task_type, *, enabled_only=True):
|
|
61
|
-
"""按能力维度挑供应商。
|
|
62
|
-
|
|
63
|
-
规则:
|
|
64
|
-
1. 声明了该维度的可用供应商优先(保持原有顺序);
|
|
65
|
-
2. 未声明的排后面(兜底);
|
|
66
|
-
3. 都没有声明时返回原顺序(行为同现状)。
|
|
67
|
-
返回 (排序后的供应商列表, 分类依据说明)。
|
|
68
|
-
"""
|
|
69
|
-
task_type = task_type if task_type in DIMS else classify_task_type({"goal": task_type})
|
|
70
|
-
pool = list(providers or [])
|
|
71
|
-
if enabled_only:
|
|
72
|
-
pool = [p for p in pool if p.get("enabled", True)]
|
|
73
|
-
strong = [p for p in pool if task_type in provider_strengths(p)]
|
|
74
|
-
rest = [p for p in pool if p not in strong]
|
|
75
|
-
ranked = strong + rest
|
|
76
|
-
reason = "%s:优先 %s" % (
|
|
77
|
-
task_type,
|
|
78
|
-
"、".join(p.get("id", "?") for p in strong) if strong else "(无声明,按原序兜底)")
|
|
79
|
-
return ranked, reason
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def resolve_binding_by_task(task, providers, bindings):
|
|
83
|
-
"""任务 → 绑定解析(能力维度优先,回落 default)。
|
|
84
|
-
|
|
85
|
-
bindings 形如 modelhub 的 {"writing": {...}, "coding": {...}, "default": {...}};
|
|
86
|
-
对应维度的绑定缺失/为空时回落 default。返回 (binding, task_type)。
|
|
87
|
-
"""
|
|
88
|
-
tt = classify_task_type(task)
|
|
89
|
-
b = (bindings or {}).get(tt)
|
|
90
|
-
if not b:
|
|
91
|
-
b = (bindings or {}).get("default")
|
|
92
|
-
return b, tt
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# ---------------------------------------------------------------- §07 T3.1 级联
|
|
96
|
-
|
|
97
|
-
TIER_ORDER = {"budget": 0, "standard": 1, "premium": 2}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def cascade_reorder(agent, tier_of, *, providers=None, pricing=None,
|
|
101
|
-
difficulty="easy", task_type="", role=""):
|
|
102
|
-
"""FrugalGPT 式便宜优先:easy 任务把 call_chain 按 tier 升序稳定重排。
|
|
103
|
-
|
|
104
|
-
agent 来自 modelhub.bind_agent(可能带 call_chain);tier_of(chain_entry)
|
|
105
|
-
返回该条目的档位串("budget"/"standard"/"premium"或 None=standard)。
|
|
106
|
-
稳定排序保证同档内保持用户配置的原顺序。链长 <2 或非链式原样返回。
|
|
107
|
-
"""
|
|
108
|
-
chain = (agent or {}).get("call_chain") or []
|
|
109
|
-
if len(chain) < 2:
|
|
110
|
-
return agent
|
|
111
|
-
if providers is not None and (task_type or role):
|
|
112
|
-
from .dispatch import rank_model_entries
|
|
113
|
-
ranked, _decisions = rank_model_entries(
|
|
114
|
-
chain, {p.get("id"): p for p in providers or []},
|
|
115
|
-
pricing or {}, difficulty, task_type, role)
|
|
116
|
-
out = dict(agent)
|
|
117
|
-
out["call_chain"] = ranked
|
|
118
|
-
return out
|
|
119
|
-
|
|
120
|
-
def key(entry):
|
|
121
|
-
return TIER_ORDER.get(tier_of(entry) or "standard", 1)
|
|
122
|
-
|
|
123
|
-
out = dict(agent)
|
|
124
|
-
out["call_chain"] = sorted(chain, key=key)
|
|
125
|
-
return out
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def make_tier_lookup(providers):
|
|
129
|
-
"""由 providers 列表构造 chain_entry → tier 的查询函数。
|
|
130
|
-
|
|
131
|
-
tier 来源(按优先级):链条目 model 在供应商 models[] 里的 tier 字段 →
|
|
132
|
-
供应商级 tier → None(按 standard 兜底)。缺什么都不抛。
|
|
133
|
-
"""
|
|
134
|
-
by_id = {p.get("id"): p for p in (providers or []) if p.get("id")}
|
|
135
|
-
|
|
136
|
-
def tier_of(entry):
|
|
137
|
-
p = by_id.get((entry or {}).get("provider_id") or "")
|
|
138
|
-
if not p:
|
|
139
|
-
return None
|
|
140
|
-
for m in (p.get("models") or []):
|
|
141
|
-
if isinstance(m, dict) and m.get("name") == (entry or {}).get("model"):
|
|
142
|
-
return m.get("tier") or p.get("tier")
|
|
143
|
-
return p.get("tier")
|
|
144
|
-
return tier_of
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""能力维度(4D):按任务类型(writing/coding/reasoning/vision)选模型。
|
|
3
|
+
|
|
4
|
+
设计稿:docs/migration/05-model-seams.md §4D(按 CodeBee 实际架构落地:
|
|
5
|
+
不改 modelhub.py——供应商声明可选 `strengths` 字段即可被识别;
|
|
6
|
+
选择逻辑独立成模块,零侵入)。
|
|
7
|
+
|
|
8
|
+
供应商声明示例(data/models.json 的 providers 条目):
|
|
9
|
+
{"id": "p1", ..., "strengths": ["writing", "reasoning"]}
|
|
10
|
+
未声明 strengths 的供应商视为无偏好(只做兜底)。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from .dispatch import TYPE_DIMENSIONS
|
|
17
|
+
|
|
18
|
+
# 能力维度(先固化清单,schema 见设计稿)
|
|
19
|
+
DIMS = ("writing", "coding", "reasoning", "vision")
|
|
20
|
+
|
|
21
|
+
# 关键词分类表(覆盖 task type / 目标文本;可按需扩充)
|
|
22
|
+
_KEYWORDS = {
|
|
23
|
+
"writing": ("写", "文案", "大纲", "连载", "章节", "小说", "营销", "总结", "润色",
|
|
24
|
+
"novel", "draft", "review"),
|
|
25
|
+
"coding": ("代码", "实现", "修复", "重构", "测试", "bug", "code", "fix",
|
|
26
|
+
"refactor", "test", "编译", "报错"),
|
|
27
|
+
"reasoning": ("推理", "分析", "调研", "对比", "规划", "拆解", "评审",
|
|
28
|
+
"reason", "analyze", "plan", "orchestrate"),
|
|
29
|
+
"vision": ("图", "图片", "识别", "视觉", "ocr", "image", "vision", "截图"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def classify_task_type(task) -> str:
|
|
34
|
+
"""把任务分类到 DIMS 之一。task 显式带 task_type 时优先。"""
|
|
35
|
+
explicit = str((task or {}).get("task_type") or "").strip().lower()
|
|
36
|
+
if explicit in DIMS:
|
|
37
|
+
return explicit
|
|
38
|
+
preset = str((task or {}).get("type") or "").strip().lower()
|
|
39
|
+
if preset in TYPE_DIMENSIONS:
|
|
40
|
+
return TYPE_DIMENSIONS[preset]
|
|
41
|
+
text = "%s %s" % ((task or {}).get("type") or "",
|
|
42
|
+
(task or {}).get("goal") or "")
|
|
43
|
+
scores = {}
|
|
44
|
+
for dim, kws in _KEYWORDS.items():
|
|
45
|
+
scores[dim] = sum(1 for kw in kws if kw in text)
|
|
46
|
+
best = max(scores, key=lambda d: scores[d])
|
|
47
|
+
if scores[best] > 0:
|
|
48
|
+
return best
|
|
49
|
+
return "coding" # 默认兜底(CodeBee 主场景)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def provider_strengths(prov):
|
|
53
|
+
"""读供应商声明的能力维度(容错:非法值忽略)。"""
|
|
54
|
+
raw = (prov or {}).get("strengths")
|
|
55
|
+
if not isinstance(raw, list):
|
|
56
|
+
return []
|
|
57
|
+
return [s for s in raw if s in DIMS]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pick_by_strength(providers, task_type, *, enabled_only=True):
|
|
61
|
+
"""按能力维度挑供应商。
|
|
62
|
+
|
|
63
|
+
规则:
|
|
64
|
+
1. 声明了该维度的可用供应商优先(保持原有顺序);
|
|
65
|
+
2. 未声明的排后面(兜底);
|
|
66
|
+
3. 都没有声明时返回原顺序(行为同现状)。
|
|
67
|
+
返回 (排序后的供应商列表, 分类依据说明)。
|
|
68
|
+
"""
|
|
69
|
+
task_type = task_type if task_type in DIMS else classify_task_type({"goal": task_type})
|
|
70
|
+
pool = list(providers or [])
|
|
71
|
+
if enabled_only:
|
|
72
|
+
pool = [p for p in pool if p.get("enabled", True)]
|
|
73
|
+
strong = [p for p in pool if task_type in provider_strengths(p)]
|
|
74
|
+
rest = [p for p in pool if p not in strong]
|
|
75
|
+
ranked = strong + rest
|
|
76
|
+
reason = "%s:优先 %s" % (
|
|
77
|
+
task_type,
|
|
78
|
+
"、".join(p.get("id", "?") for p in strong) if strong else "(无声明,按原序兜底)")
|
|
79
|
+
return ranked, reason
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_binding_by_task(task, providers, bindings):
|
|
83
|
+
"""任务 → 绑定解析(能力维度优先,回落 default)。
|
|
84
|
+
|
|
85
|
+
bindings 形如 modelhub 的 {"writing": {...}, "coding": {...}, "default": {...}};
|
|
86
|
+
对应维度的绑定缺失/为空时回落 default。返回 (binding, task_type)。
|
|
87
|
+
"""
|
|
88
|
+
tt = classify_task_type(task)
|
|
89
|
+
b = (bindings or {}).get(tt)
|
|
90
|
+
if not b:
|
|
91
|
+
b = (bindings or {}).get("default")
|
|
92
|
+
return b, tt
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------- §07 T3.1 级联
|
|
96
|
+
|
|
97
|
+
TIER_ORDER = {"budget": 0, "standard": 1, "premium": 2}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cascade_reorder(agent, tier_of, *, providers=None, pricing=None,
|
|
101
|
+
difficulty="easy", task_type="", role=""):
|
|
102
|
+
"""FrugalGPT 式便宜优先:easy 任务把 call_chain 按 tier 升序稳定重排。
|
|
103
|
+
|
|
104
|
+
agent 来自 modelhub.bind_agent(可能带 call_chain);tier_of(chain_entry)
|
|
105
|
+
返回该条目的档位串("budget"/"standard"/"premium"或 None=standard)。
|
|
106
|
+
稳定排序保证同档内保持用户配置的原顺序。链长 <2 或非链式原样返回。
|
|
107
|
+
"""
|
|
108
|
+
chain = (agent or {}).get("call_chain") or []
|
|
109
|
+
if len(chain) < 2:
|
|
110
|
+
return agent
|
|
111
|
+
if providers is not None and (task_type or role):
|
|
112
|
+
from .dispatch import rank_model_entries
|
|
113
|
+
ranked, _decisions = rank_model_entries(
|
|
114
|
+
chain, {p.get("id"): p for p in providers or []},
|
|
115
|
+
pricing or {}, difficulty, task_type, role)
|
|
116
|
+
out = dict(agent)
|
|
117
|
+
out["call_chain"] = ranked
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def key(entry):
|
|
121
|
+
return TIER_ORDER.get(tier_of(entry) or "standard", 1)
|
|
122
|
+
|
|
123
|
+
out = dict(agent)
|
|
124
|
+
out["call_chain"] = sorted(chain, key=key)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def make_tier_lookup(providers):
|
|
129
|
+
"""由 providers 列表构造 chain_entry → tier 的查询函数。
|
|
130
|
+
|
|
131
|
+
tier 来源(按优先级):链条目 model 在供应商 models[] 里的 tier 字段 →
|
|
132
|
+
供应商级 tier → None(按 standard 兜底)。缺什么都不抛。
|
|
133
|
+
"""
|
|
134
|
+
by_id = {p.get("id"): p for p in (providers or []) if p.get("id")}
|
|
135
|
+
|
|
136
|
+
def tier_of(entry):
|
|
137
|
+
p = by_id.get((entry or {}).get("provider_id") or "")
|
|
138
|
+
if not p:
|
|
139
|
+
return None
|
|
140
|
+
for m in (p.get("models") or []):
|
|
141
|
+
if isinstance(m, dict) and m.get("name") == (entry or {}).get("model"):
|
|
142
|
+
return m.get("tier") or p.get("tier")
|
|
143
|
+
return p.get("tier")
|
|
144
|
+
return tier_of
|
package/app/core/chaptersafe.py
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""章节安全落盘(tmp+原子改名,inkos 安全章节工作区借鉴)。
|
|
3
|
-
|
|
4
|
-
写一半崩溃/磁盘满不会留下半章正文冒充成稿——断点续跑按「文件够长」
|
|
5
|
-
判定,半文件会被误当合法稿复用;.tmp 写完 os.replace 原子落位,
|
|
6
|
-
失败清理残渣后原样抛出。路径守卫用 pathlib resolve+parents 惯用法
|
|
7
|
-
(同 _ms_io/_inside 口径)。pipeline._write_chapter 委托此函数。"""
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def atomic_write_chapter(workdir, i, text):
|
|
14
|
-
"""第 i 章安全落盘:tmp 写入 → 原子改名。返回最终路径。"""
|
|
15
|
-
root = Path(workdir).resolve()
|
|
16
|
-
p = (root / ("chapter-%02d.md" % int(i))).resolve()
|
|
17
|
-
# 守卫:目标必须在 workdir 之内且不得是 workdir 本身(".." 作 workdir
|
|
18
|
-
# 时 resolve 到父目录,p 仍是其中一章——但那不是调用方的工作目录,
|
|
19
|
-
# 一并拒绝:workdir 必须是已存在的目录)
|
|
20
|
-
if root not in p.parents or not root.is_dir():
|
|
21
|
-
raise ValueError("章节路径越界: chapter-%02d.md" % int(i))
|
|
22
|
-
tmp = Path(str(p) + ".tmp")
|
|
23
|
-
try:
|
|
24
|
-
tmp.write_text(str(text), encoding="utf-8", errors="replace")
|
|
25
|
-
tmp.replace(p)
|
|
26
|
-
except BaseException:
|
|
27
|
-
try:
|
|
28
|
-
tmp.unlink(missing_ok=True)
|
|
29
|
-
except OSError:
|
|
30
|
-
pass
|
|
31
|
-
raise
|
|
32
|
-
return p
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""章节安全落盘(tmp+原子改名,inkos 安全章节工作区借鉴)。
|
|
3
|
+
|
|
4
|
+
写一半崩溃/磁盘满不会留下半章正文冒充成稿——断点续跑按「文件够长」
|
|
5
|
+
判定,半文件会被误当合法稿复用;.tmp 写完 os.replace 原子落位,
|
|
6
|
+
失败清理残渣后原样抛出。路径守卫用 pathlib resolve+parents 惯用法
|
|
7
|
+
(同 _ms_io/_inside 口径)。pipeline._write_chapter 委托此函数。"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def atomic_write_chapter(workdir, i, text):
|
|
14
|
+
"""第 i 章安全落盘:tmp 写入 → 原子改名。返回最终路径。"""
|
|
15
|
+
root = Path(workdir).resolve()
|
|
16
|
+
p = (root / ("chapter-%02d.md" % int(i))).resolve()
|
|
17
|
+
# 守卫:目标必须在 workdir 之内且不得是 workdir 本身(".." 作 workdir
|
|
18
|
+
# 时 resolve 到父目录,p 仍是其中一章——但那不是调用方的工作目录,
|
|
19
|
+
# 一并拒绝:workdir 必须是已存在的目录)
|
|
20
|
+
if root not in p.parents or not root.is_dir():
|
|
21
|
+
raise ValueError("章节路径越界: chapter-%02d.md" % int(i))
|
|
22
|
+
tmp = Path(str(p) + ".tmp")
|
|
23
|
+
try:
|
|
24
|
+
tmp.write_text(str(text), encoding="utf-8", errors="replace")
|
|
25
|
+
tmp.replace(p)
|
|
26
|
+
except BaseException:
|
|
27
|
+
try:
|
|
28
|
+
tmp.unlink(missing_ok=True)
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
raise
|
|
32
|
+
return p
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Append-only execution checkpoints with conservative replay semantics."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import paths
|
|
13
|
+
from .redact import scrub_text
|
|
14
|
+
|
|
15
|
+
_DIR = paths.DATA_DIR / "checkpoints"
|
|
16
|
+
_LOCK = threading.RLock()
|
|
17
|
+
_TERMINAL = {"done", "failed", "cancelled", "timeout", "unknown"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _file():
|
|
21
|
+
return _DIR / ("checkpoints-%s.jsonl" % time.strftime("%Y%m"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hash(value):
|
|
25
|
+
if isinstance(value, (dict, list, tuple)):
|
|
26
|
+
raw = json.dumps(value, ensure_ascii=False, sort_keys=True,
|
|
27
|
+
separators=(",", ":"), default=str)
|
|
28
|
+
else:
|
|
29
|
+
raw = str(value or "")
|
|
30
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _append(row):
|
|
34
|
+
_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
with _file().open("a", encoding="utf-8") as handle:
|
|
36
|
+
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
|
37
|
+
handle.flush()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def start(run_id, step, role, prompt="", *, input_data=None, attempt=1,
|
|
41
|
+
idempotency_key=""):
|
|
42
|
+
"""Record a step start without persisting prompt contents."""
|
|
43
|
+
now = time.time()
|
|
44
|
+
try:
|
|
45
|
+
attempt = max(1, min(100, int(attempt)))
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
attempt = 1
|
|
48
|
+
row = {"event_id": "cp-" + secrets.token_hex(10), "event": "start",
|
|
49
|
+
"run_id": str(run_id or "")[:80], "step": int(step),
|
|
50
|
+
"role": str(role or "")[:80], "status": "running",
|
|
51
|
+
"attempt": attempt, "idempotency_key": str(idempotency_key or "")[:160],
|
|
52
|
+
"prompt_sha256": _hash(prompt), "input_sha256": _hash(input_data),
|
|
53
|
+
"created_at": now}
|
|
54
|
+
with _LOCK:
|
|
55
|
+
try:
|
|
56
|
+
_append(row)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
return row
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def finish(run_id, step, status, *, error="", output=None, metadata=None):
|
|
63
|
+
status = str(status or "unknown").strip().lower()
|
|
64
|
+
if status not in _TERMINAL:
|
|
65
|
+
status = "unknown"
|
|
66
|
+
row = {"event_id": "cp-" + secrets.token_hex(10), "event": "finish",
|
|
67
|
+
"run_id": str(run_id or "")[:80], "step": int(step),
|
|
68
|
+
"status": status, "error": scrub_text(error, limit=500),
|
|
69
|
+
"output_sha256": _hash(output), "metadata": {
|
|
70
|
+
str(k)[:40]: v for k, v in (metadata or {}).items()
|
|
71
|
+
if isinstance(v, (str, int, float, bool))
|
|
72
|
+
}, "created_at": time.time()}
|
|
73
|
+
with _LOCK:
|
|
74
|
+
try:
|
|
75
|
+
_append(row)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
return row
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read(run_id=""):
|
|
82
|
+
rows = []
|
|
83
|
+
try:
|
|
84
|
+
files = sorted(_DIR.glob("checkpoints-*.jsonl"), reverse=True)
|
|
85
|
+
except OSError:
|
|
86
|
+
files = []
|
|
87
|
+
for path in files[:24]:
|
|
88
|
+
try:
|
|
89
|
+
if path.stat().st_size > 4 * 1024 * 1024:
|
|
90
|
+
continue
|
|
91
|
+
for line in path.read_text(encoding="utf-8").splitlines()[-50000:]:
|
|
92
|
+
try:
|
|
93
|
+
row = json.loads(line)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
continue
|
|
96
|
+
if isinstance(row, dict) and (not run_id or row.get("run_id") == run_id):
|
|
97
|
+
rows.append(row)
|
|
98
|
+
except OSError:
|
|
99
|
+
continue
|
|
100
|
+
return rows
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def replay_preview(run_id):
|
|
104
|
+
"""Return latest checkpoint per step and whether automatic replay is safe."""
|
|
105
|
+
with _LOCK:
|
|
106
|
+
rows = _read(str(run_id or "")[:80])
|
|
107
|
+
latest = {}
|
|
108
|
+
starts = {}
|
|
109
|
+
finishes = {}
|
|
110
|
+
for row in rows:
|
|
111
|
+
try:
|
|
112
|
+
key = int(row.get("step"))
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
continue
|
|
115
|
+
if key < 0:
|
|
116
|
+
continue
|
|
117
|
+
latest[key] = dict(latest.get(key) or {}, **row)
|
|
118
|
+
if row.get("event") == "start":
|
|
119
|
+
starts[key] = row
|
|
120
|
+
elif row.get("event") == "finish":
|
|
121
|
+
finishes[key] = row
|
|
122
|
+
steps = []
|
|
123
|
+
for key in sorted(latest):
|
|
124
|
+
item = dict(latest[key])
|
|
125
|
+
if key in starts:
|
|
126
|
+
item.setdefault("prompt_sha256", starts[key].get("prompt_sha256"))
|
|
127
|
+
item.setdefault("attempt", starts[key].get("attempt", 1))
|
|
128
|
+
steps.append(item)
|
|
129
|
+
unknown = next((x for x in steps if x.get("status") in ("unknown", "running")
|
|
130
|
+
or x.get("event") != "finish"
|
|
131
|
+
or x.get("step") not in finishes
|
|
132
|
+
or x.get("step") not in starts), None)
|
|
133
|
+
if not steps:
|
|
134
|
+
unknown = {"reason": "checkpoint_not_found"}
|
|
135
|
+
return {"run_id": str(run_id or ""), "steps": steps,
|
|
136
|
+
"replayable": unknown is None,
|
|
137
|
+
"blocked_reason": ("checkpoint_not_found" if not steps else
|
|
138
|
+
"requires_reconciliation" if unknown else "")}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def replay(run_id):
|
|
142
|
+
"""Alias used by integrations; intentionally returns a preview only."""
|
|
143
|
+
return replay_preview(run_id)
|