codebee 0.1.95 → 0.1.97
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 +733 -720
- package/LICENSE +21 -21
- package/README.md +516 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +118 -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 +246 -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 +2055 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +648 -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/mcp_client.py +200 -29
- 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 +319 -59
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +84 -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/project_memory.py +139 -0
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +106 -22
- package/app/core/publish/browser.py +694 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +212 -165
- package/app/core/publish/flow.py +832 -640
- package/app/core/publish/flows-fanqie-calibrated.json +361 -336
- package/app/core/publish/ledger.py +51 -2
- 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 +2239 -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 +33 -5
- package/app/core/settings_schema.py +282 -271
- 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 +24 -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/tool_outputs.py +107 -0
- 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 +865 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +179 -10
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +563 -21
- 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/assets/workbench-reference.png +0 -0
- package/app/ui/board.css +451 -666
- package/app/ui/board.html +165 -162
- package/app/ui/board.js +509 -305
- package/app/ui/hive3d.js +242 -0
- package/app/ui/i18n.js +125 -9
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +60 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +189 -9
- package/bin/tutti.js +147 -147
- package/package.json +49 -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/settings.py
CHANGED
|
@@ -14,8 +14,9 @@ _FILE = paths.DATA_DIR / "settings.json"
|
|
|
14
14
|
# default_workdir 为空表示未自定义,用 builtin_workdir() 回落;
|
|
15
15
|
# telemetry_errors:匿名错误回传开关(默认开;关掉后版本 ping/错误上传/诊断包遥测部分全部停发,
|
|
16
16
|
# 「导出诊断包」是用户手动操作不受此开关限制)
|
|
17
|
-
# publish_daily_cap / publish_fail_streak
|
|
18
|
-
#
|
|
17
|
+
# publish_daily_cap / publish_fail_streak / publish_fail_window_h:自动发布护栏——
|
|
18
|
+
# 每任务每平台每日成功发章上限、平台连续失败几次后暂停自动发布、连败只统计
|
|
19
|
+
# 最近多少小时内的失败(跨天旧失败不进「连续」口径)(publish/auto.py 读取)
|
|
19
20
|
# pet_enabled / pet_mode:桌面蜜蜂(app/pet.py)开关与显示模式(默认关,设置页
|
|
20
21
|
# 可开)。关闭后看护线程不再拉起、在岗蜜蜂轮询到 false 自行退出;mode=always
|
|
21
22
|
# 不再拉起、在岗蜜蜂轮询到 false 自行退出;mode=always 常驻,tasks_only 仅任务
|
|
@@ -26,8 +27,10 @@ _FILE = paths.DATA_DIR / "settings.json"
|
|
|
26
27
|
# Server酱(微信)/ Telegram Bot。密钥只存本机 settings.json,不出网(推送
|
|
27
28
|
# 时才带着发给对应服务)。bark_server 留空回落官方 api.day.app。
|
|
28
29
|
DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
|
|
30
|
+
"hooks_signing_secret": "",
|
|
29
31
|
"telemetry_errors": True, "publish_daily_cap": 10,
|
|
30
|
-
"publish_fail_streak": 3, "
|
|
32
|
+
"publish_fail_streak": 3, "publish_fail_window_h": 12,
|
|
33
|
+
"notify_webhook": "", "notify_base_url": "",
|
|
31
34
|
"notify_bark_server": "", "notify_bark_key": "",
|
|
32
35
|
"notify_ntfy_topic": "", "notify_serverchan_key": "",
|
|
33
36
|
"notify_telegram_token": "", "notify_telegram_chat_id": "",
|
|
@@ -35,6 +38,9 @@ DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
|
|
|
35
38
|
# enabled=False 关;days 间隔天数控节流;mcp_servers=内置智能体的
|
|
36
39
|
# MCP 服务器清单(JSON 数组字符串,stdio 形态)
|
|
37
40
|
"bench_auto_enabled": False, "bench_auto_days": 7,
|
|
41
|
+
# 评测反哺:实测分软信号进绑定链排序(dispatch 读 benchstore.bonus_for,
|
|
42
|
+
# ±4.5 封顶翻不动硬约束)。默认开——评测了就该去影响;关=纯榜单展示
|
|
43
|
+
"bench_feedback_enabled": True,
|
|
38
44
|
"mcp_servers": "",
|
|
39
45
|
# 备份远程通道(remotesync):导出成功后自动 PUT 到 WebDAV/S3 兼容
|
|
40
46
|
# 端点(Basic auth);密钥只存本机
|
|
@@ -49,6 +55,18 @@ DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
|
|
|
49
55
|
# 桌宠形象白名单(与 app/pet.py 的 SKINS 对齐;这里不 import pet 模块,避免
|
|
50
56
|
# core 反向依赖 app 根目录脚本)
|
|
51
57
|
PET_SKINS = ("plush", "robot")
|
|
58
|
+
SECRET_KEYS = frozenset({"hooks_token", "hooks_signing_secret", "notify_webhook",
|
|
59
|
+
"notify_bark_key", "notify_ntfy_topic", "notify_serverchan_key",
|
|
60
|
+
"notify_telegram_token", "notify_telegram_chat_id", "backup_remote_pass"})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def public_view(value=None):
|
|
64
|
+
"""Return settings suitable for API responses without credential material."""
|
|
65
|
+
source = dict(value if isinstance(value, dict) else load())
|
|
66
|
+
for key in SECRET_KEYS:
|
|
67
|
+
if key in source:
|
|
68
|
+
source[key] = "***" if source[key] else ""
|
|
69
|
+
return source
|
|
52
70
|
# 并发保护上限 24:编排任务有空位即直接启动,满载回滚 queued 排队自动补跑,
|
|
53
71
|
# 不秒判失败;同任务单飞守卫在 jobs 层。该上限保护的是本机资源(CLI 子进程
|
|
54
72
|
# 数),与各 API 厂商的限流无关;direct 对话走 jobs 轻量池不占此额度。
|
|
@@ -120,18 +138,26 @@ def save(patch):
|
|
|
120
138
|
cur["default_workdir"] = wd
|
|
121
139
|
if "hooks_token" in patch:
|
|
122
140
|
cur["hooks_token"] = str(patch.get("hooks_token") or "").strip()[:128]
|
|
141
|
+
if "hooks_signing_secret" in patch:
|
|
142
|
+
cur["hooks_signing_secret"] = str(patch.get("hooks_signing_secret") or "").strip()[:256]
|
|
123
143
|
if "telemetry_errors" in patch:
|
|
124
144
|
cur["telemetry_errors"] = bool(patch.get("telemetry_errors"))
|
|
125
145
|
if "publish_daily_cap" in patch:
|
|
126
146
|
try:
|
|
127
|
-
|
|
147
|
+
v = int(patch.get("publish_daily_cap"))
|
|
128
148
|
except (TypeError, ValueError):
|
|
129
|
-
return cur, "publish_daily_cap 必须是
|
|
149
|
+
return cur, "publish_daily_cap 必须是 0-50 的整数(0=不限制)"
|
|
150
|
+
cur["publish_daily_cap"] = max(0, min(50, v))
|
|
130
151
|
if "publish_fail_streak" in patch:
|
|
131
152
|
try:
|
|
132
153
|
cur["publish_fail_streak"] = max(1, min(10, int(patch.get("publish_fail_streak"))))
|
|
133
154
|
except (TypeError, ValueError):
|
|
134
155
|
return cur, "publish_fail_streak 必须是 1-10 的整数"
|
|
156
|
+
if "publish_fail_window_h" in patch:
|
|
157
|
+
try:
|
|
158
|
+
cur["publish_fail_window_h"] = max(1, min(168, float(patch.get("publish_fail_window_h"))))
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
return cur, "publish_fail_window_h 必须是 1-168 的数字"
|
|
135
161
|
if "notify_webhook" in patch:
|
|
136
162
|
cur["notify_webhook"] = str(patch.get("notify_webhook") or "").strip()[:300]
|
|
137
163
|
if "notify_base_url" in patch:
|
|
@@ -148,6 +174,8 @@ def save(patch):
|
|
|
148
174
|
cur["bench_auto_days"] = max(1, min(90, int(patch.get("bench_auto_days"))))
|
|
149
175
|
except (TypeError, ValueError):
|
|
150
176
|
return cur, "bench_auto_days 必须是 1-90 的整数"
|
|
177
|
+
if "bench_feedback_enabled" in patch:
|
|
178
|
+
cur["bench_feedback_enabled"] = bool(patch.get("bench_feedback_enabled"))
|
|
151
179
|
if "mcp_servers" in patch:
|
|
152
180
|
cur["mcp_servers"] = str(patch.get("mcp_servers") or "").strip()[:4000]
|
|
153
181
|
if "backup_remote_enabled" in patch:
|
|
@@ -1,271 +1,282 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""Schema 化设置:namespace 注册 + revision CAS + 脱敏 describe。
|
|
3
|
-
|
|
4
|
-
设计稿:docs/migration/05-model-seams.md §4C(按 Tutti 实际架构落地:
|
|
5
|
-
不替换现有 settings.py——它正被并行迭代且已有原子写+钳制;本模块提供
|
|
6
|
-
「schema 驱动 + revision CAS + secret 脱敏」能力,管理自己的
|
|
7
|
-
data/settings_v2.json,供新增配置项使用,存量 key-value 不迁移)。
|
|
8
|
-
|
|
9
|
-
参考 dsh packages/settings/settings/src/index.ts:419-637:
|
|
10
|
-
register(ns, schema, base) / mutate(ops) / describe({redactSecrets}) /
|
|
11
|
-
SettingsConflictError(revision 单调,乐观并发)。
|
|
12
|
-
|
|
13
|
-
冲突语义:mutate 带 expected_revision 时做 CAS;不带则最后写赢。
|
|
14
|
-
"""
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import json
|
|
18
|
-
import re
|
|
19
|
-
import threading
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
|
|
22
|
-
_LOCK = threading.RLock()
|
|
23
|
-
_FILE = None # init() 注入
|
|
24
|
-
_NAMESPACES = {} # ns -> {"fields": {path: FieldDef}, "validate": fn}
|
|
25
|
-
_VALUES = {} # ns -> {path: value}
|
|
26
|
-
_REVISIONS = {} # ns -> int
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class SettingsConflictError(Exception):
|
|
30
|
-
"""CAS 失败:调用方持有的是陈旧 revision。"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class FieldDef:
|
|
34
|
-
__slots__ = ("path", "ftype", "default", "description", "redact", "choices", "clamp")
|
|
35
|
-
|
|
36
|
-
def __init__(self, path, ftype, default, description="", redact=False,
|
|
37
|
-
choices=None, clamp=None):
|
|
38
|
-
self.path = path
|
|
39
|
-
self.ftype = ftype # "int" | "float" | "str" | "bool"
|
|
40
|
-
self.default = default
|
|
41
|
-
self.description = description
|
|
42
|
-
self.redact = redact # True = describe() 输出 __REDACTED__
|
|
43
|
-
self.choices = choices # 可选:合法值列表
|
|
44
|
-
self.clamp = clamp # 可选:(min, max),int/float 用
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def init(data_dir=None):
|
|
48
|
-
global _FILE, _NAMESPACES, _VALUES, _REVISIONS
|
|
49
|
-
with _LOCK:
|
|
50
|
-
base = Path(data_dir) if data_dir else Path(_default_dir())
|
|
51
|
-
base.mkdir(parents=True, exist_ok=True)
|
|
52
|
-
_FILE = base / "settings_v2.json"
|
|
53
|
-
_NAMESPACES, _VALUES, _REVISIONS = {}, {}, {}
|
|
54
|
-
try:
|
|
55
|
-
data = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
56
|
-
_VALUES = data.get("values") or {}
|
|
57
|
-
_REVISIONS = data.get("revisions") or {}
|
|
58
|
-
except (OSError, json.JSONDecodeError):
|
|
59
|
-
pass
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _default_dir():
|
|
63
|
-
from . import paths
|
|
64
|
-
return str(paths.DATA_DIR)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _persist():
|
|
68
|
-
if _FILE is None:
|
|
69
|
-
return
|
|
70
|
-
tmp = _FILE.with_suffix(".tmp")
|
|
71
|
-
tmp.write_text(json.dumps({"values": _VALUES, "revisions": _REVISIONS},
|
|
72
|
-
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
73
|
-
tmp.replace(_FILE)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# ---- 路径读写(支持 "a.b" 点路径;不实现数组下标——Tutti 配置用不到) ----
|
|
77
|
-
|
|
78
|
-
def _get_path(d, path):
|
|
79
|
-
cur = d
|
|
80
|
-
for seg in path.split("."):
|
|
81
|
-
if not isinstance(cur, dict) or seg not in cur:
|
|
82
|
-
return None
|
|
83
|
-
cur = cur[seg]
|
|
84
|
-
return cur
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _set_path(d, path, value):
|
|
88
|
-
segs = path.split(".")
|
|
89
|
-
cur = d
|
|
90
|
-
for seg in segs[:-1]:
|
|
91
|
-
cur = cur.setdefault(seg, {})
|
|
92
|
-
if not isinstance(cur, dict):
|
|
93
|
-
raise ValueError("路径冲突:%s" % path)
|
|
94
|
-
cur[segs[-1]] = value
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# ---- 注册 ----
|
|
98
|
-
|
|
99
|
-
def register_namespace(ns, fields, validate=None):
|
|
100
|
-
"""注册 namespace 与字段 schema。已注册的 namespace 不可重复注册。"""
|
|
101
|
-
with _LOCK:
|
|
102
|
-
if ns in _NAMESPACES:
|
|
103
|
-
raise ValueError("namespace 已注册:%s" % ns)
|
|
104
|
-
fmap = {}
|
|
105
|
-
for f in fields:
|
|
106
|
-
fmap[f.path] = f
|
|
107
|
-
if _get_path(_VALUES.get(ns, {}), f.path) is None:
|
|
108
|
-
_VALUES.setdefault(ns, {})
|
|
109
|
-
_set_path(_VALUES[ns], f.path, f.default)
|
|
110
|
-
_NAMESPACES[ns] = {"fields": fmap, "validate": validate}
|
|
111
|
-
_REVISIONS.setdefault(ns, 1)
|
|
112
|
-
_persist()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def coerce(field, value):
|
|
116
|
-
"""按字段类型强转 + 钳制 + choices 校验。返回 (规范化值, 错误)。"""
|
|
117
|
-
try:
|
|
118
|
-
if field.ftype == "int":
|
|
119
|
-
v = int(value)
|
|
120
|
-
elif field.ftype == "float":
|
|
121
|
-
v = float(value)
|
|
122
|
-
elif field.ftype == "bool":
|
|
123
|
-
v = bool(value) if not isinstance(value, str) else (
|
|
124
|
-
value.strip().lower() in ("1", "true", "yes", "on"))
|
|
125
|
-
else:
|
|
126
|
-
v = str(value)
|
|
127
|
-
except (TypeError, ValueError):
|
|
128
|
-
return None, "%s 必须是 %s" % (field.path, field.ftype)
|
|
129
|
-
if field.clamp:
|
|
130
|
-
lo, hi = field.clamp
|
|
131
|
-
v = max(lo, min(hi, v))
|
|
132
|
-
if field.choices is not None and v not in field.choices:
|
|
133
|
-
return None, "%s 必须是 %s 之一" % (field.path, field.choices)
|
|
134
|
-
return v, None
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
# ---- 读写 ----
|
|
138
|
-
|
|
139
|
-
def get(ns, path=None):
|
|
140
|
-
with _LOCK:
|
|
141
|
-
vals = _VALUES.get(ns)
|
|
142
|
-
if vals is None:
|
|
143
|
-
return None
|
|
144
|
-
return _get_path(vals, path) if path else dict(vals)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def revision(ns):
|
|
148
|
-
with _LOCK:
|
|
149
|
-
return _REVISIONS.get(ns, 0)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def names():
|
|
153
|
-
"""已注册 namespace 清单(GET /api/settings-v2 用)。"""
|
|
154
|
-
with _LOCK:
|
|
155
|
-
return sorted(_NAMESPACES)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def mutate(ns, ops, expected_revision=None):
|
|
159
|
-
"""写一组操作。ops = [{"op":"set","path":..., "value":...}, ...]。
|
|
160
|
-
|
|
161
|
-
值按注册的 FieldDef 强转/钳制/校验;namespace 有 validate 钩子时整体验证。
|
|
162
|
-
expected_revision 非 None 时做 CAS。成功返回新 revision。
|
|
163
|
-
"""
|
|
164
|
-
with _LOCK:
|
|
165
|
-
ndef = _NAMESPACES.get(ns)
|
|
166
|
-
if not ndef:
|
|
167
|
-
raise ValueError("namespace 未注册:%s" % ns)
|
|
168
|
-
if expected_revision is not None and _REVISIONS.get(ns) != expected_revision:
|
|
169
|
-
raise SettingsConflictError(
|
|
170
|
-
"%s 期望 rev %s,实际 %s" % (ns, expected_revision, _REVISIONS.get(ns)))
|
|
171
|
-
# 先全部校验,后统一写入(部分失败不落盘)
|
|
172
|
-
staged = []
|
|
173
|
-
for op in ops:
|
|
174
|
-
if op.get("op") != "set":
|
|
175
|
-
raise ValueError("仅支持 set 操作:%r" % op.get("op"))
|
|
176
|
-
path = op.get("path")
|
|
177
|
-
fdef = ndef["fields"].get(path)
|
|
178
|
-
if not fdef:
|
|
179
|
-
raise ValueError("未注册字段:%s" % path)
|
|
180
|
-
v, err = coerce(fdef, op.get("value"))
|
|
181
|
-
if err:
|
|
182
|
-
raise ValueError(err)
|
|
183
|
-
staged.append((path, v))
|
|
184
|
-
ns_values = dict(_VALUES.get(ns, {}))
|
|
185
|
-
for path, v in staged:
|
|
186
|
-
_set_path(ns_values, path, v)
|
|
187
|
-
if ndef["validate"]:
|
|
188
|
-
err = ndef["validate"](ns_values)
|
|
189
|
-
if err:
|
|
190
|
-
raise ValueError("namespace 校验失败:%s" % err)
|
|
191
|
-
_VALUES[ns] = ns_values
|
|
192
|
-
_REVISIONS[ns] = _REVISIONS.get(ns, 1) + 1
|
|
193
|
-
_persist()
|
|
194
|
-
return _REVISIONS[ns]
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def describe(ns, redact_secrets=True):
|
|
198
|
-
"""给 UI 的视图:默认把 redact 字段替换为 __REDACTED__(不泄露真值)。
|
|
199
|
-
|
|
200
|
-
fields 带回字段元数据(类型/说明/choices/clamp),前端调参卡按 schema
|
|
201
|
-
渲染控件,schema 变更零前端改动。"""
|
|
202
|
-
with _LOCK:
|
|
203
|
-
vals = json.loads(json.dumps(_VALUES.get(ns, {}))) # deep copy
|
|
204
|
-
ndef = _NAMESPACES.get(ns) or {"fields": {}}
|
|
205
|
-
fields = [{"path": f.path, "type": f.ftype, "description": f.description,
|
|
206
|
-
"choices": f.choices, "clamp": f.clamp}
|
|
207
|
-
for f in ndef["fields"].values()]
|
|
208
|
-
for f in ndef["fields"].values():
|
|
209
|
-
if f.redact and redact_secrets:
|
|
210
|
-
if _get_path(vals, f.path) not in (None, ""):
|
|
211
|
-
_set_path(vals, f.path, "__REDACTED__")
|
|
212
|
-
return {"ns": ns, "revision": _REVISIONS.get(ns, 0), "values": vals,
|
|
213
|
-
"fields": fields}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
# ---- 默认 namespace(编排阈值的 schema 化存放处;pipeline 可渐进接入) ----
|
|
217
|
-
|
|
218
|
-
def register_default_namespaces():
|
|
219
|
-
"""注册 Tutti 默认 namespace。幂等(已注册则跳过)。"""
|
|
220
|
-
if "orchestrator" in _NAMESPACES and "budget" in _NAMESPACES:
|
|
221
|
-
return
|
|
222
|
-
if "orchestrator" not in _NAMESPACES:
|
|
223
|
-
register_namespace(
|
|
224
|
-
"orchestrator",
|
|
225
|
-
fields=[
|
|
226
|
-
FieldDef("compaction.enabled", "bool",
|
|
227
|
-
"
|
|
228
|
-
FieldDef("compaction.pressure_threshold", "float", 0.8,
|
|
229
|
-
"触发压缩的压力比阈值",
|
|
230
|
-
clamp=(0.1, 0.99)),
|
|
231
|
-
FieldDef("compaction.retain_tail_tokens", "int", 8000,
|
|
232
|
-
"压缩时保留尾部预算(token 估算)",
|
|
233
|
-
clamp=(0, 100000)),
|
|
234
|
-
FieldDef("max_goal_rounds", "int", 5,
|
|
235
|
-
"Goal 续行上限",
|
|
236
|
-
clamp=(1, 20)),
|
|
237
|
-
FieldDef("outline_timeout_s", "int", 900,
|
|
238
|
-
"大纲/规划 CLI 兜底调用的超时秒数(env TUTTI_OUTLINE_TIMEOUT 优先)",
|
|
239
|
-
clamp=(60, 3600)),
|
|
240
|
-
],
|
|
241
|
-
validate=lambda v: None,
|
|
242
|
-
)
|
|
243
|
-
if "budget" not in _NAMESPACES:
|
|
244
|
-
register_namespace(
|
|
245
|
-
"budget",
|
|
246
|
-
fields=[
|
|
247
|
-
# §07 T2.1:0 = 不限;达到上限后 pipeline 停止后续真实调用(ENV_BLOCK)
|
|
248
|
-
FieldDef("max_tokens_per_run", "int", 0,
|
|
249
|
-
"单次运行 token 预算上限(0=不限;超额停止后续步骤,等自动续跑)",
|
|
250
|
-
clamp=(0, 100_000_000)),
|
|
251
|
-
# 花费硬顶(¥,按台账标定单价折算:手标单价优先,回落导入价表;
|
|
252
|
-
# 未标定单价的调用不记钱也不计入——宁缺毋滥):0 = 不限。
|
|
253
|
-
FieldDef("daily_cost_yuan", "float", 0.0,
|
|
254
|
-
"每日花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
|
|
255
|
-
clamp=(0, 1_000_000)),
|
|
256
|
-
FieldDef("monthly_cost_yuan", "float", 0.0,
|
|
257
|
-
"每月花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
|
|
258
|
-
clamp=(0, 1_000_000)),
|
|
259
|
-
],
|
|
260
|
-
validate=lambda v: None,
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Schema 化设置:namespace 注册 + revision CAS + 脱敏 describe。
|
|
3
|
+
|
|
4
|
+
设计稿:docs/migration/05-model-seams.md §4C(按 Tutti 实际架构落地:
|
|
5
|
+
不替换现有 settings.py——它正被并行迭代且已有原子写+钳制;本模块提供
|
|
6
|
+
「schema 驱动 + revision CAS + secret 脱敏」能力,管理自己的
|
|
7
|
+
data/settings_v2.json,供新增配置项使用,存量 key-value 不迁移)。
|
|
8
|
+
|
|
9
|
+
参考 dsh packages/settings/settings/src/index.ts:419-637:
|
|
10
|
+
register(ns, schema, base) / mutate(ops) / describe({redactSecrets}) /
|
|
11
|
+
SettingsConflictError(revision 单调,乐观并发)。
|
|
12
|
+
|
|
13
|
+
冲突语义:mutate 带 expected_revision 时做 CAS;不带则最后写赢。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_LOCK = threading.RLock()
|
|
23
|
+
_FILE = None # init() 注入
|
|
24
|
+
_NAMESPACES = {} # ns -> {"fields": {path: FieldDef}, "validate": fn}
|
|
25
|
+
_VALUES = {} # ns -> {path: value}
|
|
26
|
+
_REVISIONS = {} # ns -> int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SettingsConflictError(Exception):
|
|
30
|
+
"""CAS 失败:调用方持有的是陈旧 revision。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FieldDef:
|
|
34
|
+
__slots__ = ("path", "ftype", "default", "description", "redact", "choices", "clamp")
|
|
35
|
+
|
|
36
|
+
def __init__(self, path, ftype, default, description="", redact=False,
|
|
37
|
+
choices=None, clamp=None):
|
|
38
|
+
self.path = path
|
|
39
|
+
self.ftype = ftype # "int" | "float" | "str" | "bool"
|
|
40
|
+
self.default = default
|
|
41
|
+
self.description = description
|
|
42
|
+
self.redact = redact # True = describe() 输出 __REDACTED__
|
|
43
|
+
self.choices = choices # 可选:合法值列表
|
|
44
|
+
self.clamp = clamp # 可选:(min, max),int/float 用
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def init(data_dir=None):
|
|
48
|
+
global _FILE, _NAMESPACES, _VALUES, _REVISIONS
|
|
49
|
+
with _LOCK:
|
|
50
|
+
base = Path(data_dir) if data_dir else Path(_default_dir())
|
|
51
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
_FILE = base / "settings_v2.json"
|
|
53
|
+
_NAMESPACES, _VALUES, _REVISIONS = {}, {}, {}
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
56
|
+
_VALUES = data.get("values") or {}
|
|
57
|
+
_REVISIONS = data.get("revisions") or {}
|
|
58
|
+
except (OSError, json.JSONDecodeError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _default_dir():
|
|
63
|
+
from . import paths
|
|
64
|
+
return str(paths.DATA_DIR)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _persist():
|
|
68
|
+
if _FILE is None:
|
|
69
|
+
return
|
|
70
|
+
tmp = _FILE.with_suffix(".tmp")
|
|
71
|
+
tmp.write_text(json.dumps({"values": _VALUES, "revisions": _REVISIONS},
|
|
72
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
73
|
+
tmp.replace(_FILE)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---- 路径读写(支持 "a.b" 点路径;不实现数组下标——Tutti 配置用不到) ----
|
|
77
|
+
|
|
78
|
+
def _get_path(d, path):
|
|
79
|
+
cur = d
|
|
80
|
+
for seg in path.split("."):
|
|
81
|
+
if not isinstance(cur, dict) or seg not in cur:
|
|
82
|
+
return None
|
|
83
|
+
cur = cur[seg]
|
|
84
|
+
return cur
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _set_path(d, path, value):
|
|
88
|
+
segs = path.split(".")
|
|
89
|
+
cur = d
|
|
90
|
+
for seg in segs[:-1]:
|
|
91
|
+
cur = cur.setdefault(seg, {})
|
|
92
|
+
if not isinstance(cur, dict):
|
|
93
|
+
raise ValueError("路径冲突:%s" % path)
|
|
94
|
+
cur[segs[-1]] = value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---- 注册 ----
|
|
98
|
+
|
|
99
|
+
def register_namespace(ns, fields, validate=None):
|
|
100
|
+
"""注册 namespace 与字段 schema。已注册的 namespace 不可重复注册。"""
|
|
101
|
+
with _LOCK:
|
|
102
|
+
if ns in _NAMESPACES:
|
|
103
|
+
raise ValueError("namespace 已注册:%s" % ns)
|
|
104
|
+
fmap = {}
|
|
105
|
+
for f in fields:
|
|
106
|
+
fmap[f.path] = f
|
|
107
|
+
if _get_path(_VALUES.get(ns, {}), f.path) is None:
|
|
108
|
+
_VALUES.setdefault(ns, {})
|
|
109
|
+
_set_path(_VALUES[ns], f.path, f.default)
|
|
110
|
+
_NAMESPACES[ns] = {"fields": fmap, "validate": validate}
|
|
111
|
+
_REVISIONS.setdefault(ns, 1)
|
|
112
|
+
_persist()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def coerce(field, value):
|
|
116
|
+
"""按字段类型强转 + 钳制 + choices 校验。返回 (规范化值, 错误)。"""
|
|
117
|
+
try:
|
|
118
|
+
if field.ftype == "int":
|
|
119
|
+
v = int(value)
|
|
120
|
+
elif field.ftype == "float":
|
|
121
|
+
v = float(value)
|
|
122
|
+
elif field.ftype == "bool":
|
|
123
|
+
v = bool(value) if not isinstance(value, str) else (
|
|
124
|
+
value.strip().lower() in ("1", "true", "yes", "on"))
|
|
125
|
+
else:
|
|
126
|
+
v = str(value)
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
return None, "%s 必须是 %s" % (field.path, field.ftype)
|
|
129
|
+
if field.clamp:
|
|
130
|
+
lo, hi = field.clamp
|
|
131
|
+
v = max(lo, min(hi, v))
|
|
132
|
+
if field.choices is not None and v not in field.choices:
|
|
133
|
+
return None, "%s 必须是 %s 之一" % (field.path, field.choices)
|
|
134
|
+
return v, None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---- 读写 ----
|
|
138
|
+
|
|
139
|
+
def get(ns, path=None):
|
|
140
|
+
with _LOCK:
|
|
141
|
+
vals = _VALUES.get(ns)
|
|
142
|
+
if vals is None:
|
|
143
|
+
return None
|
|
144
|
+
return _get_path(vals, path) if path else dict(vals)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def revision(ns):
|
|
148
|
+
with _LOCK:
|
|
149
|
+
return _REVISIONS.get(ns, 0)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def names():
|
|
153
|
+
"""已注册 namespace 清单(GET /api/settings-v2 用)。"""
|
|
154
|
+
with _LOCK:
|
|
155
|
+
return sorted(_NAMESPACES)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def mutate(ns, ops, expected_revision=None):
|
|
159
|
+
"""写一组操作。ops = [{"op":"set","path":..., "value":...}, ...]。
|
|
160
|
+
|
|
161
|
+
值按注册的 FieldDef 强转/钳制/校验;namespace 有 validate 钩子时整体验证。
|
|
162
|
+
expected_revision 非 None 时做 CAS。成功返回新 revision。
|
|
163
|
+
"""
|
|
164
|
+
with _LOCK:
|
|
165
|
+
ndef = _NAMESPACES.get(ns)
|
|
166
|
+
if not ndef:
|
|
167
|
+
raise ValueError("namespace 未注册:%s" % ns)
|
|
168
|
+
if expected_revision is not None and _REVISIONS.get(ns) != expected_revision:
|
|
169
|
+
raise SettingsConflictError(
|
|
170
|
+
"%s 期望 rev %s,实际 %s" % (ns, expected_revision, _REVISIONS.get(ns)))
|
|
171
|
+
# 先全部校验,后统一写入(部分失败不落盘)
|
|
172
|
+
staged = []
|
|
173
|
+
for op in ops:
|
|
174
|
+
if op.get("op") != "set":
|
|
175
|
+
raise ValueError("仅支持 set 操作:%r" % op.get("op"))
|
|
176
|
+
path = op.get("path")
|
|
177
|
+
fdef = ndef["fields"].get(path)
|
|
178
|
+
if not fdef:
|
|
179
|
+
raise ValueError("未注册字段:%s" % path)
|
|
180
|
+
v, err = coerce(fdef, op.get("value"))
|
|
181
|
+
if err:
|
|
182
|
+
raise ValueError(err)
|
|
183
|
+
staged.append((path, v))
|
|
184
|
+
ns_values = dict(_VALUES.get(ns, {}))
|
|
185
|
+
for path, v in staged:
|
|
186
|
+
_set_path(ns_values, path, v)
|
|
187
|
+
if ndef["validate"]:
|
|
188
|
+
err = ndef["validate"](ns_values)
|
|
189
|
+
if err:
|
|
190
|
+
raise ValueError("namespace 校验失败:%s" % err)
|
|
191
|
+
_VALUES[ns] = ns_values
|
|
192
|
+
_REVISIONS[ns] = _REVISIONS.get(ns, 1) + 1
|
|
193
|
+
_persist()
|
|
194
|
+
return _REVISIONS[ns]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def describe(ns, redact_secrets=True):
|
|
198
|
+
"""给 UI 的视图:默认把 redact 字段替换为 __REDACTED__(不泄露真值)。
|
|
199
|
+
|
|
200
|
+
fields 带回字段元数据(类型/说明/choices/clamp),前端调参卡按 schema
|
|
201
|
+
渲染控件,schema 变更零前端改动。"""
|
|
202
|
+
with _LOCK:
|
|
203
|
+
vals = json.loads(json.dumps(_VALUES.get(ns, {}))) # deep copy
|
|
204
|
+
ndef = _NAMESPACES.get(ns) or {"fields": {}}
|
|
205
|
+
fields = [{"path": f.path, "type": f.ftype, "description": f.description,
|
|
206
|
+
"choices": f.choices, "clamp": f.clamp}
|
|
207
|
+
for f in ndef["fields"].values()]
|
|
208
|
+
for f in ndef["fields"].values():
|
|
209
|
+
if f.redact and redact_secrets:
|
|
210
|
+
if _get_path(vals, f.path) not in (None, ""):
|
|
211
|
+
_set_path(vals, f.path, "__REDACTED__")
|
|
212
|
+
return {"ns": ns, "revision": _REVISIONS.get(ns, 0), "values": vals,
|
|
213
|
+
"fields": fields}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---- 默认 namespace(编排阈值的 schema 化存放处;pipeline 可渐进接入) ----
|
|
217
|
+
|
|
218
|
+
def register_default_namespaces():
|
|
219
|
+
"""注册 Tutti 默认 namespace。幂等(已注册则跳过)。"""
|
|
220
|
+
if "orchestrator" in _NAMESPACES and "budget" in _NAMESPACES:
|
|
221
|
+
return
|
|
222
|
+
if "orchestrator" not in _NAMESPACES:
|
|
223
|
+
register_namespace(
|
|
224
|
+
"orchestrator",
|
|
225
|
+
fields=[
|
|
226
|
+
FieldDef("compaction.enabled", "bool", True,
|
|
227
|
+
"上下文压缩总开关(默认开启;可在设置页或用 TUTTI_COMPACTION=0 关闭)"),
|
|
228
|
+
FieldDef("compaction.pressure_threshold", "float", 0.8,
|
|
229
|
+
"触发压缩的压力比阈值",
|
|
230
|
+
clamp=(0.1, 0.99)),
|
|
231
|
+
FieldDef("compaction.retain_tail_tokens", "int", 8000,
|
|
232
|
+
"压缩时保留尾部预算(token 估算)",
|
|
233
|
+
clamp=(0, 100000)),
|
|
234
|
+
FieldDef("max_goal_rounds", "int", 5,
|
|
235
|
+
"Goal 续行上限",
|
|
236
|
+
clamp=(1, 20)),
|
|
237
|
+
FieldDef("outline_timeout_s", "int", 900,
|
|
238
|
+
"大纲/规划 CLI 兜底调用的超时秒数(env TUTTI_OUTLINE_TIMEOUT 优先)",
|
|
239
|
+
clamp=(60, 3600)),
|
|
240
|
+
],
|
|
241
|
+
validate=lambda v: None,
|
|
242
|
+
)
|
|
243
|
+
if "budget" not in _NAMESPACES:
|
|
244
|
+
register_namespace(
|
|
245
|
+
"budget",
|
|
246
|
+
fields=[
|
|
247
|
+
# §07 T2.1:0 = 不限;达到上限后 pipeline 停止后续真实调用(ENV_BLOCK)
|
|
248
|
+
FieldDef("max_tokens_per_run", "int", 0,
|
|
249
|
+
"单次运行 token 预算上限(0=不限;超额停止后续步骤,等自动续跑)",
|
|
250
|
+
clamp=(0, 100_000_000)),
|
|
251
|
+
# 花费硬顶(¥,按台账标定单价折算:手标单价优先,回落导入价表;
|
|
252
|
+
# 未标定单价的调用不记钱也不计入——宁缺毋滥):0 = 不限。
|
|
253
|
+
FieldDef("daily_cost_yuan", "float", 0.0,
|
|
254
|
+
"每日花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
|
|
255
|
+
clamp=(0, 1_000_000)),
|
|
256
|
+
FieldDef("monthly_cost_yuan", "float", 0.0,
|
|
257
|
+
"每月花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
|
|
258
|
+
clamp=(0, 1_000_000)),
|
|
259
|
+
],
|
|
260
|
+
validate=lambda v: None,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Revision 1 is the pre-release schema default (disabled). Values written
|
|
264
|
+
# by mutate() advance the namespace to revision 2+, so preserve an explicit
|
|
265
|
+
# user opt-out while migrating untouched installations to the new default.
|
|
266
|
+
with _LOCK:
|
|
267
|
+
orchestrator = _VALUES.get("orchestrator", {})
|
|
268
|
+
if (_REVISIONS.get("orchestrator") == 1 and
|
|
269
|
+
_get_path(orchestrator, "compaction.enabled") is False):
|
|
270
|
+
_set_path(orchestrator, "compaction.enabled", True)
|
|
271
|
+
_REVISIONS["orchestrator"] = 2
|
|
272
|
+
_persist()
|
|
273
|
+
if "cascade" not in _NAMESPACES:
|
|
274
|
+
register_namespace(
|
|
275
|
+
"cascade",
|
|
276
|
+
fields=[
|
|
277
|
+
# §07 T3.1:easy 任务先走低档模型,质量闸门不过沿 chain 升级
|
|
278
|
+
FieldDef("enabled", "bool", False,
|
|
279
|
+
"FrugalGPT 式级联:easy 任务按 tier 升序重排模型链(需供应商声明 tier)"),
|
|
280
|
+
],
|
|
281
|
+
validate=lambda v: None,
|
|
282
|
+
)
|