codebee 0.1.69 → 0.1.70
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 +37 -0
- package/README.md +4 -3
- package/app/browser-final-1440.png +0 -0
- package/app/browser-final-390.png +0 -0
- package/app/browser-google-fallback.png +0 -0
- package/app/browser-google-test.png +0 -0
- package/app/browser-workbench-1440.png +0 -0
- package/app/browser-workbench-390.png +0 -0
- package/app/browser-workspace-google.png +0 -0
- package/app/browser-workspace-smoke.png +0 -0
- package/app/browser-workspace.png +0 -0
- package/app/core/audit.py +30 -0
- package/app/core/automation.py +107 -12
- package/app/core/benchstore.py +95 -0
- package/app/core/bookmeta.py +9 -4
- package/app/core/builtin_agent.py +472 -33
- package/app/core/catalog.py +7 -1
- package/app/core/dispatch.py +20 -3
- package/app/core/error_codes.py +11 -2
- package/app/core/evalbench.py +720 -0
- package/app/core/flows.py +228 -3
- package/app/core/jobs.py +22 -1
- package/app/core/manager.py +21 -0
- package/app/core/market_remote.py +10 -0
- package/app/core/mcp_client.py +287 -0
- package/app/core/modelhub.py +185 -2
- package/app/core/notify.py +118 -23
- package/app/core/novel_quality.py +189 -0
- package/app/core/paths.py +4 -0
- package/app/core/pipeline.py +426 -28
- package/app/core/planner.py +243 -22
- package/app/core/plugins.py +525 -0
- package/app/core/publish/browser.py +129 -13
- package/app/core/publish/fanqie.py +6 -0
- package/app/core/publish/flow.py +154 -5
- package/app/core/publish/flows-fanqie-calibrated.json +11 -7
- package/app/core/publish/ledger.py +29 -3
- package/app/core/publish/manager.py +134 -2
- package/app/core/publish/qimao.py +5 -0
- package/app/core/remotesync.py +121 -0
- package/app/core/runner.py +80 -5
- package/app/core/settings.py +39 -2
- package/app/core/settings_schema.py +8 -0
- package/app/core/store.py +30 -8
- package/app/core/usage.py +187 -14
- package/app/core/zentao.py +153 -4
- package/app/current-browser.png +0 -0
- package/app/main.py +279 -15
- package/app/mcp_server.py +175 -0
- package/app/pet.py +82 -21
- package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
- package/app/plugins/code-review-helper/skills/review.md +16 -0
- package/app/ui/app.js +1380 -73
- package/app/ui/i18n.js +248 -6
- package/app/ui/index.html +277 -13
- package/app/ui/style.css +786 -0
- package/package.json +2 -1
package/app/core/modelhub.py
CHANGED
|
@@ -2452,7 +2452,9 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2452
2452
|
|
|
2453
2453
|
call_chain 是跨厂商降级链(唯一真源):每条 {model, env, provider, [codex_provider]},
|
|
2454
2454
|
按序尝试;供应商失效(停用/删除/无密钥/协议不可注入)的条目被跳过,全链失效
|
|
2455
|
-
→ 整体回落 CLI 默认(None)。纯链条目(无供应商)不注入 env,只传 -m
|
|
2455
|
+
→ 整体回落 CLI 默认(None)。纯链条目(无供应商)不注入 env,只传 -m,
|
|
2456
|
+
并带 no_cred=True 标记供 runner 审计层点名(零凭据条目走本机登录态,
|
|
2457
|
+
本机无登录态必死在凭据闸门——2026-09-25 Mac claude 实案)。
|
|
2456
2458
|
难度路由只在无显式链时生效。
|
|
2457
2459
|
"""
|
|
2458
2460
|
# Local-credential-only CLIs must never expose a binding, including legacy
|
|
@@ -2487,7 +2489,11 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2487
2489
|
pid = (item.get("provider_id") or "").strip()
|
|
2488
2490
|
if not pid:
|
|
2489
2491
|
if model:
|
|
2490
|
-
|
|
2492
|
+
# 纯模型条目:零凭据注入,只传 -m 走 CLI 本机登录态。
|
|
2493
|
+
# no_cred 标记供 runner 审计行/登录指引点名(Mac claude 案:
|
|
2494
|
+
# 零注入死在登录闸门,起跑前无人知、死后只能靠 init JSON 反推)
|
|
2495
|
+
entries.append({"model": model, "env": {}, "provider": None,
|
|
2496
|
+
"no_cred": True})
|
|
2491
2497
|
continue
|
|
2492
2498
|
prov = provs.get(pid)
|
|
2493
2499
|
if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
|
|
@@ -2803,12 +2809,84 @@ def models_view():
|
|
|
2803
2809
|
if m.get("hidden"):
|
|
2804
2810
|
continue
|
|
2805
2811
|
pr = pricing.get(m["name"]) or {}
|
|
2812
|
+
if m.get("price_in") is not None or m.get("price_out") is not None:
|
|
2813
|
+
pr = {"in": m.get("price_in"), "out": m.get("price_out")} # 手标优先
|
|
2806
2814
|
rows.append(dict(base, name=m["name"], enabled=bool(m.get("enabled", True)),
|
|
2807
2815
|
priority=m.get("priority", 0),
|
|
2808
2816
|
price_in=pr.get("in"), price_out=pr.get("out")))
|
|
2809
2817
|
return rows
|
|
2810
2818
|
|
|
2811
2819
|
|
|
2820
|
+
def model_price(model_name, provider=""):
|
|
2821
|
+
"""模型单价(¥ / 百万 tokens):指定供应商的手标价优先,回落 CCSwitch
|
|
2822
|
+
导入价表;都没有返回 None——不猜价,台账不记钱、预算不计入。
|
|
2823
|
+
|
|
2824
|
+
手标是「按供应商」标定的(同名模型在不同网关价格可不同),因此指定
|
|
2825
|
+
供应商未标价时不继承别家手标,直接看价表。
|
|
2826
|
+
列名沿用台账 cost_usd 字段,语义自本函数起 = 费用(¥)。
|
|
2827
|
+
"""
|
|
2828
|
+
def _manual(prov_id):
|
|
2829
|
+
with _LOCK:
|
|
2830
|
+
for p in _load().get("providers", []):
|
|
2831
|
+
if not prov_id or p.get("id") != prov_id:
|
|
2832
|
+
continue
|
|
2833
|
+
for m in p.get("models") or []:
|
|
2834
|
+
if m.get("name") != model_name:
|
|
2835
|
+
continue
|
|
2836
|
+
try:
|
|
2837
|
+
pin = float(m.get("price_in"))
|
|
2838
|
+
pout = float(m.get("price_out"))
|
|
2839
|
+
except (TypeError, ValueError):
|
|
2840
|
+
continue
|
|
2841
|
+
if pin >= 0 and pout >= 0 and (pin or pout):
|
|
2842
|
+
return {"in": pin, "out": pout}
|
|
2843
|
+
return None
|
|
2844
|
+
|
|
2845
|
+
try:
|
|
2846
|
+
pr = _manual(provider)
|
|
2847
|
+
if pr:
|
|
2848
|
+
return pr
|
|
2849
|
+
with _LOCK:
|
|
2850
|
+
raw = (_load().get("pricing") or {}).get(model_name) or {}
|
|
2851
|
+
pin, pout = float(raw.get("in") or 0), float(raw.get("out") or 0)
|
|
2852
|
+
if pin > 0 or pout > 0:
|
|
2853
|
+
return {"in": pin, "out": pout}
|
|
2854
|
+
except Exception:
|
|
2855
|
+
pass
|
|
2856
|
+
return None
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def set_model_price(provider_id, name, price_in, price_out):
|
|
2860
|
+
"""手标模型单价(¥/百万 tokens;空串=清除标定,回落价表/未标价)。"""
|
|
2861
|
+
def _num(v):
|
|
2862
|
+
v = str(v if v is not None else "").strip()
|
|
2863
|
+
return float(v) if v != "" else None
|
|
2864
|
+
try:
|
|
2865
|
+
pin, pout = _num(price_in), _num(price_out)
|
|
2866
|
+
except (TypeError, ValueError):
|
|
2867
|
+
return "单价必须是数字"
|
|
2868
|
+
if (pin is not None and pin < 0) or (pout is not None and pout < 0):
|
|
2869
|
+
return "单价不能为负"
|
|
2870
|
+
with _LOCK:
|
|
2871
|
+
data = _load()
|
|
2872
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
2873
|
+
if not prov:
|
|
2874
|
+
return "供应商不存在"
|
|
2875
|
+
m = next((x for x in prov.get("models") or [] if x.get("name") == name), None)
|
|
2876
|
+
if not m:
|
|
2877
|
+
return "模型不存在"
|
|
2878
|
+
if pin is None:
|
|
2879
|
+
m.pop("price_in", None)
|
|
2880
|
+
else:
|
|
2881
|
+
m["price_in"] = pin
|
|
2882
|
+
if pout is None:
|
|
2883
|
+
m.pop("price_out", None)
|
|
2884
|
+
else:
|
|
2885
|
+
m["price_out"] = pout
|
|
2886
|
+
_save(data)
|
|
2887
|
+
return None
|
|
2888
|
+
|
|
2889
|
+
|
|
2812
2890
|
def reorder_models(provider_id, ordered_names):
|
|
2813
2891
|
"""按给定名称顺序重设优先级(列表中未出现的模型排在最后,保持相对顺序)。"""
|
|
2814
2892
|
with _LOCK:
|
|
@@ -3103,6 +3181,111 @@ def test_model(provider_id, model_name, key_id=""):
|
|
|
3103
3181
|
return finish(last)
|
|
3104
3182
|
|
|
3105
3183
|
|
|
3184
|
+
def generate_once(provider_id, model_name, prompt, max_tokens=2048, timeout_s=120):
|
|
3185
|
+
"""单轮真实生成(评测基准台等需要「自定义 prompt 实答」的场景)。
|
|
3186
|
+
|
|
3187
|
+
与 test_model 同一套 wire 解析(auto 探测/多 KEY 回退/KEY 健康记账),
|
|
3188
|
+
但发的是真实生成请求并取回正文。返回
|
|
3189
|
+
{ok, text, usage{input,output}, protocol, key_id, latency_ms, error};
|
|
3190
|
+
任何失败折叠进 ok=False + error,绝不抛出。
|
|
3191
|
+
"""
|
|
3192
|
+
import time as _t
|
|
3193
|
+
with _LOCK:
|
|
3194
|
+
prov = next((p for p in providers() if p.get("id") == provider_id), None)
|
|
3195
|
+
if not prov:
|
|
3196
|
+
return {"ok": False, "text": "", "usage": None, "error": "供应商不存在"}
|
|
3197
|
+
keys = _chain_keys(prov)
|
|
3198
|
+
if not keys:
|
|
3199
|
+
return {"ok": False, "text": "", "usage": None, "error": "无可用 API KEY"}
|
|
3200
|
+
allow_private = bool(prov.get("allow_private"))
|
|
3201
|
+
t0 = _t.time()
|
|
3202
|
+
|
|
3203
|
+
def out(ok, text="", usage=None, protocol="", key_id="", error=""):
|
|
3204
|
+
return {"ok": ok, "text": text, "usage": usage, "protocol": protocol,
|
|
3205
|
+
"key_id": key_id, "error": error,
|
|
3206
|
+
"latency_ms": int((_t.time() - t0) * 1000)}
|
|
3207
|
+
|
|
3208
|
+
last_err = "无可用 wire"
|
|
3209
|
+
for key_index, kk in enumerate(keys):
|
|
3210
|
+
probe_key_id = (kk.get("id") or "") if key_index > 0 else ""
|
|
3211
|
+
protos, note = _ensure_wire_candidates(provider_id, prov, model_name,
|
|
3212
|
+
key_id=probe_key_id,
|
|
3213
|
+
force_probe=bool(probe_key_id))
|
|
3214
|
+
if not protos:
|
|
3215
|
+
last_err = "无已验证的兼容 wire" + ("(%s)" % note if note else "")
|
|
3216
|
+
note_key_error(provider_id, kk.get("id") or "", last_err)
|
|
3217
|
+
continue
|
|
3218
|
+
for proto, pbase in protos:
|
|
3219
|
+
base = (pbase or "").rstrip("/")
|
|
3220
|
+
if proto == "google":
|
|
3221
|
+
url = base + ("/models/%s:generateContent" % model_name
|
|
3222
|
+
if base.endswith("/v1beta")
|
|
3223
|
+
else "/v1beta/models/%s:generateContent" % model_name)
|
|
3224
|
+
headers = {"x-goog-api-key": kk["key"]}
|
|
3225
|
+
body = {"contents": [{"parts": [{"text": prompt}]}],
|
|
3226
|
+
"generationConfig": {"maxOutputTokens": max_tokens}}
|
|
3227
|
+
else:
|
|
3228
|
+
path = "/messages" if proto == "anthropic" else "/chat/completions"
|
|
3229
|
+
url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
|
|
3230
|
+
if proto == "anthropic":
|
|
3231
|
+
headers = {"x-api-key": kk["key"], "anthropic-version": "2023-06-01"}
|
|
3232
|
+
else:
|
|
3233
|
+
headers = {"Authorization": "Bearer " + kk["key"]}
|
|
3234
|
+
body = {"model": model_name, "max_tokens": max_tokens,
|
|
3235
|
+
"messages": [{"role": "user", "content": prompt}]}
|
|
3236
|
+
status, data, err = _post_json_http(url, headers, body, allow_private,
|
|
3237
|
+
timeout=timeout_s)
|
|
3238
|
+
if status == 0:
|
|
3239
|
+
last_err = err or "连接失败"
|
|
3240
|
+
note_key_error(provider_id, kk.get("id") or "", last_err)
|
|
3241
|
+
continue
|
|
3242
|
+
if not (200 <= status < 300):
|
|
3243
|
+
last_err = "HTTP %s %s" % (status, _response_error_text(data) or "")
|
|
3244
|
+
note_key_error(provider_id, kk.get("id") or "", last_err)
|
|
3245
|
+
continue
|
|
3246
|
+
text, usage_d = _extract_generation(proto, data)
|
|
3247
|
+
if not (text or "").strip():
|
|
3248
|
+
last_err = "HTTP 200 空响应"
|
|
3249
|
+
note_key_error(provider_id, kk.get("id") or "", last_err)
|
|
3250
|
+
continue
|
|
3251
|
+
note_key_ok(provider_id, kk.get("id") or "")
|
|
3252
|
+
return out(True, text, usage_d, proto, kk.get("id") or "")
|
|
3253
|
+
return out(False, error=last_err)
|
|
3254
|
+
|
|
3255
|
+
|
|
3256
|
+
def _extract_generation(proto, data):
|
|
3257
|
+
"""从三族 wire 的生成响应里取正文与 token 细分。取不到返回 ("", None)。"""
|
|
3258
|
+
if not isinstance(data, dict):
|
|
3259
|
+
return "", None
|
|
3260
|
+
text, usage_d = "", None
|
|
3261
|
+
try:
|
|
3262
|
+
if proto == "openai":
|
|
3263
|
+
msg = ((data.get("choices") or [{}])[0] or {}).get("message") or {}
|
|
3264
|
+
c = msg.get("content")
|
|
3265
|
+
text = c if isinstance(c, str) else "".join(
|
|
3266
|
+
p.get("text") or "" for p in c) if isinstance(c, list) else ""
|
|
3267
|
+
u = data.get("usage") or {}
|
|
3268
|
+
usage_d = {"input": u.get("prompt_tokens") or 0,
|
|
3269
|
+
"output": u.get("completion_tokens") or 0}
|
|
3270
|
+
elif proto == "anthropic":
|
|
3271
|
+
text = "".join(b.get("text") or "" for b in data.get("content") or []
|
|
3272
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
3273
|
+
u = data.get("usage") or {}
|
|
3274
|
+
usage_d = {"input": u.get("input_tokens") or 0,
|
|
3275
|
+
"output": u.get("output_tokens") or 0}
|
|
3276
|
+
elif proto == "google":
|
|
3277
|
+
cand = ((data.get("candidates") or [{}])[0] or {})
|
|
3278
|
+
text = "".join(p.get("text") or "" for p in (cand.get("content") or {}).get("parts") or [])
|
|
3279
|
+
u = data.get("usageMetadata") or {}
|
|
3280
|
+
usage_d = {"input": u.get("promptTokenCount") or 0,
|
|
3281
|
+
"output": u.get("candidatesTokenCount") or 0}
|
|
3282
|
+
except Exception:
|
|
3283
|
+
pass
|
|
3284
|
+
if usage_d is not None and not any(isinstance(v, (int, float)) for v in usage_d.values()):
|
|
3285
|
+
usage_d = None
|
|
3286
|
+
return text, usage_d
|
|
3287
|
+
|
|
3288
|
+
|
|
3106
3289
|
# ---------------------------------------------------------------- wire 协议适配
|
|
3107
3290
|
# 聚合中转网关(new-api/one-api 系)通常同一密钥同时开 openai(/chat/completions)
|
|
3108
3291
|
# 与 anthropic(/v1/messages) 两面 wire。在「模型接入」页用 1 token 最小对话实测,
|
package/app/core/notify.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""
|
|
2
|
+
"""运行结果推送(借鉴 agency-orchestrator 的 --notify):任务跑完把结果摘要
|
|
3
3
|
推到钉钉/飞书/企业微信群机器人,配合定时自动化就是「AI 团队每天定点交活」。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
两类通道:
|
|
6
|
+
- 群机器人 webhook 一个地址全包——按域名自动适配三种机器人格式;也接受
|
|
7
|
+
任意 https 地址(按钉钉 text 形状发,自建 n8n 等自选)。
|
|
8
|
+
- 个人推送通道(Bark / ntfy / Server酱 / Telegram Bot):推到私人手机,
|
|
9
|
+
无人值守场景「半夜失败/待裁决」的刚需;群机器人是给群里看的,这是给你看的。
|
|
7
10
|
推送在后台线程、失败只记日志——通知永远不影响任务本身。
|
|
8
11
|
"""
|
|
9
12
|
from __future__ import annotations
|
|
@@ -36,29 +39,117 @@ def _webhook():
|
|
|
36
39
|
return ""
|
|
37
40
|
|
|
38
41
|
|
|
39
|
-
def
|
|
40
|
-
"""curl
|
|
42
|
+
def _send(url, json_body=None, form=None):
|
|
43
|
+
"""curl 子进程发送(Python 不经手响应体);网络失败只记日志。返回 bool。
|
|
44
|
+
|
|
45
|
+
json_body=JSON 体;form=表单体(值需 UTF-8 百分号编码,urlencode 负责)。
|
|
46
|
+
中文一律走请求体(Server酱表单 / ntfy JSON publish),不走 HTTP 头——
|
|
47
|
+
头里的非 ASCII 会被网关拒收(历史坑)。
|
|
48
|
+
"""
|
|
41
49
|
from . import runner
|
|
42
50
|
import json as _json
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
argv
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
argv = ["curl", "-sS", "--max-time", "20"]
|
|
52
|
+
if json_body is not None:
|
|
53
|
+
argv += ["-H", "Content-Type: application/json",
|
|
54
|
+
"-d", _json.dumps(json_body, ensure_ascii=False)]
|
|
55
|
+
elif form is not None:
|
|
56
|
+
from urllib.parse import urlencode
|
|
57
|
+
argv += ["-H", "Content-Type: application/x-www-form-urlencoded",
|
|
58
|
+
"-d", urlencode(form)]
|
|
59
|
+
argv.append(url)
|
|
60
|
+
r = runner.run_process(argv=argv, timeout=30)
|
|
49
61
|
return bool(r["ok"])
|
|
50
62
|
|
|
51
63
|
|
|
52
|
-
def
|
|
53
|
-
|
|
64
|
+
def _post(hook, text):
|
|
65
|
+
return _send(hook, json_body=_payload_for(hook, text))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _personal_channels():
|
|
69
|
+
"""读设置里的个人推送配置,返回 [(通道名, 发送函数(title, body)), ...]。
|
|
70
|
+
|
|
71
|
+
只收录配置完整的通道;键名与 settings.DEFAULTS 对齐。
|
|
72
|
+
"""
|
|
73
|
+
from . import settings
|
|
74
|
+
s = settings.load()
|
|
75
|
+
chs = []
|
|
76
|
+
|
|
77
|
+
bark_key = str(s.get("notify_bark_key") or "").strip()
|
|
78
|
+
if bark_key:
|
|
79
|
+
server = (str(s.get("notify_bark_server") or "").strip().rstrip("/")
|
|
80
|
+
or "https://api.day.app")
|
|
81
|
+
chs.append(("bark", lambda t, b: _send(
|
|
82
|
+
server + "/push",
|
|
83
|
+
json_body={"device_key": bark_key, "title": t, "body": b})))
|
|
84
|
+
|
|
85
|
+
ntfy = str(s.get("notify_ntfy_topic") or "").strip()
|
|
86
|
+
if ntfy:
|
|
87
|
+
from urllib.parse import urlparse
|
|
88
|
+
u = urlparse(ntfy if "://" in ntfy else "https://ntfy.sh/" + ntfy.lstrip("/"))
|
|
89
|
+
topic = u.path.strip("/")
|
|
90
|
+
if topic:
|
|
91
|
+
server = "%s://%s" % (u.scheme, u.netloc)
|
|
92
|
+
chs.append(("ntfy", lambda t, b: _send(
|
|
93
|
+
server + "/",
|
|
94
|
+
json_body={"topic": topic, "title": t, "message": b})))
|
|
95
|
+
|
|
96
|
+
sc_key = str(s.get("notify_serverchan_key") or "").strip()
|
|
97
|
+
if sc_key:
|
|
98
|
+
chs.append(("serverchan", lambda t, b: _send(
|
|
99
|
+
"https://sctapi.ftqq.com/%s.send" % sc_key,
|
|
100
|
+
form={"title": t, "desp": b})))
|
|
101
|
+
|
|
102
|
+
tg_token = str(s.get("notify_telegram_token") or "").strip()
|
|
103
|
+
tg_chat = str(s.get("notify_telegram_chat_id") or "").strip()
|
|
104
|
+
if tg_token and tg_chat:
|
|
105
|
+
chs.append(("telegram", lambda t, b: _send(
|
|
106
|
+
"https://api.telegram.org/bot%s/sendMessage" % tg_token,
|
|
107
|
+
json_body={"chat_id": tg_chat, "text": (t + "\n" + b).strip()})))
|
|
108
|
+
return chs
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _split_title(text):
|
|
112
|
+
"""首行作标题(≤40 字),其余作正文;单行文本标题正文同文。"""
|
|
113
|
+
lines = [ln for ln in str(text).splitlines() if ln.strip()]
|
|
114
|
+
title = (lines[0].strip() if lines else "CodeBee")[:40]
|
|
115
|
+
body = "\n".join(ln.strip() for ln in lines[1:]).strip() or title
|
|
116
|
+
return title, body
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def push_text_ex(text):
|
|
120
|
+
"""推一条文本到全部已配置通道,返回 {通道名: 是否成功}。
|
|
121
|
+
|
|
122
|
+
未配置任何通道返回 {};单通道失败不影响其他通道。
|
|
123
|
+
"""
|
|
124
|
+
results = {}
|
|
54
125
|
hook = _webhook()
|
|
55
|
-
if
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
126
|
+
if hook:
|
|
127
|
+
if not hook.startswith("https://"):
|
|
128
|
+
log.warning("notify webhook 必须是 https")
|
|
129
|
+
results["webhook"] = False
|
|
130
|
+
else:
|
|
131
|
+
try:
|
|
132
|
+
results["webhook"] = _post(hook, text)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
log.warning("notify push failed: %s", e)
|
|
135
|
+
results["webhook"] = False
|
|
136
|
+
try:
|
|
137
|
+
title, body = _split_title(text)
|
|
138
|
+
except Exception:
|
|
139
|
+
title, body = "CodeBee", str(text)
|
|
140
|
+
for name, fn in _personal_channels():
|
|
141
|
+
try:
|
|
142
|
+
results[name] = bool(fn(title, body))
|
|
143
|
+
except Exception as e:
|
|
144
|
+
log.warning("notify %s push failed: %s", name, e)
|
|
145
|
+
results[name] = False
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def push_text(text):
|
|
150
|
+
"""推一条文本;任一通道成功即 True;全未配置/全失败返回 False,不抛错。"""
|
|
60
151
|
try:
|
|
61
|
-
return
|
|
152
|
+
return any(push_text_ex(text).values())
|
|
62
153
|
except Exception as e:
|
|
63
154
|
log.warning("notify push failed: %s", e)
|
|
64
155
|
return False
|
|
@@ -71,13 +162,10 @@ def push_run_async(run_id):
|
|
|
71
162
|
|
|
72
163
|
|
|
73
164
|
def push_run(run_id):
|
|
74
|
-
"""组装 run
|
|
165
|
+
"""组装 run 结果摘要并推送(群 webhook 或任一个人通道配置了才推)。
|
|
75
166
|
|
|
76
167
|
摘要末尾附分享页路径(notify_base_url 设置非空时拼完整链接,否则只给
|
|
77
168
|
run_id 供在本机 CodeBee 界面查找)。"""
|
|
78
|
-
hook = _webhook()
|
|
79
|
-
if not hook:
|
|
80
|
-
return False
|
|
81
169
|
from . import store
|
|
82
170
|
run = store.get_run(run_id) or {}
|
|
83
171
|
if not run:
|
|
@@ -100,6 +188,13 @@ def push_run(run_id):
|
|
|
100
188
|
lines.append("📄 详情:%s/api/runs/%s/share" % (base, run_id))
|
|
101
189
|
except Exception:
|
|
102
190
|
pass
|
|
191
|
+
# 分支待裁决:任务跑完有改动等人拍板时点名提醒(手机端看到即可去详情处理)
|
|
192
|
+
try:
|
|
193
|
+
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
194
|
+
if task and task.get("git_state") == "isolated":
|
|
195
|
+
lines.append("⚠ 任务分支待裁决(合并/丢弃)")
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
103
198
|
return push_text("\n".join(lines))
|
|
104
199
|
|
|
105
200
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Deterministic quality checks for signing-oriented Chinese web fiction.
|
|
3
|
+
|
|
4
|
+
The LLM remains responsible for literary judgment. This module supplies
|
|
5
|
+
stable rubric defaults and small text signals so reviewers can cite evidence
|
|
6
|
+
for the common rejection reasons instead of relying on vague impressions.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SIGNING_RUBRIC = [
|
|
14
|
+
"开篇吸引力",
|
|
15
|
+
"情节推进",
|
|
16
|
+
"文风统一",
|
|
17
|
+
"情感细腻度",
|
|
18
|
+
"节奏控制",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
OPENING_DIM = "开篇吸引力"
|
|
22
|
+
SIGNING_CHECKPOINT_WORDS = 20000 # 平台口径:前 2 万字用于签约评估
|
|
23
|
+
|
|
24
|
+
_SENTENCE_RE = re.compile(r"[^。!?!?;;\n]+[。!?!?;;]?")
|
|
25
|
+
_DIALOGUE_RE = re.compile(r"[“‘\"「『](.*?)[”’\"」』]", re.S)
|
|
26
|
+
_EMOTION_RE = re.compile(r"哭|泪|笑|怒|怕|颤|抖|咬|攥|握紧|心跳|呼吸|喉咙|指甲|沉默")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def rubric_for(task):
|
|
30
|
+
"""Return the configured rubric, or the signing rubric for serial novels."""
|
|
31
|
+
task = task if isinstance(task, dict) else {}
|
|
32
|
+
raw = task.get("rubric")
|
|
33
|
+
if isinstance(raw, str):
|
|
34
|
+
raw = [x.strip() for x in raw.replace(",", ",").split(",") if x.strip()]
|
|
35
|
+
if isinstance(raw, (list, tuple)):
|
|
36
|
+
values = [str(x).strip() for x in raw if str(x).strip()]
|
|
37
|
+
if values:
|
|
38
|
+
return values[:8]
|
|
39
|
+
if str(task.get("type") or "").lower() == "serial_novel":
|
|
40
|
+
return list(SIGNING_RUBRIC)
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def opening_requirements(chapter, target_words=None):
|
|
45
|
+
"""Return concise, chapter-aware acceptance requirements for prompts."""
|
|
46
|
+
try:
|
|
47
|
+
chapter = int(chapter)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
chapter = 1
|
|
50
|
+
if chapter == 1:
|
|
51
|
+
try:
|
|
52
|
+
short = int(target_words or 0) < 1200
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
short = False
|
|
55
|
+
core = ("前 1200 字" if short else "前 2000 字")
|
|
56
|
+
return ("黄金一章门禁:前 100 字让读者知道主角是谁;前 200 字出现异常、欲望或冲突;"
|
|
57
|
+
"前 600 字把困境具体化;%s 展示核心卖点;结尾必须留下可兑现的钩子。" % core
|
|
58
|
+
+ "禁止用世界观、环境或人物关系长铺垫开场。")
|
|
59
|
+
return "本章门禁:开头尽快承接上一章并制造新变化;结尾必须留下下一步行动或可兑现的章末钩子。"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def signal_summary(text, chapter=None):
|
|
63
|
+
"""Extract explainable text signals; no claim is made that they are scores."""
|
|
64
|
+
text = str(text or "").replace("\r\n", "\n").strip()
|
|
65
|
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
|
66
|
+
sentences = [s.strip() for s in _SENTENCE_RE.findall(text) if s.strip()]
|
|
67
|
+
sentence_lengths = [len(re.sub(r"\s+", "", s)) for s in sentences]
|
|
68
|
+
dialogue_chars = sum(len(x) for x in _DIALOGUE_RE.findall(text))
|
|
69
|
+
emotion_hits = len(_EMOTION_RE.findall(text))
|
|
70
|
+
chars = len(re.sub(r"\s+", "", text))
|
|
71
|
+
avg = round(sum(sentence_lengths) / len(sentence_lengths), 1) if sentence_lengths else 0.0
|
|
72
|
+
long_sentences = sum(1 for n in sentence_lengths if n >= 55)
|
|
73
|
+
hints = []
|
|
74
|
+
if chapter == 1 and chars and chars < 600:
|
|
75
|
+
hints.append("首章正文不足 600 字,无法证明前 600 字已进入具体困境")
|
|
76
|
+
if avg >= 35 or long_sentences:
|
|
77
|
+
hints.append("存在偏长句,复查长定语、重复说明与动作拆分")
|
|
78
|
+
if len(paragraphs) >= 4 and chars / len(paragraphs) > 220:
|
|
79
|
+
hints.append("段落偏长,复查手机阅读下的节奏与信息密度")
|
|
80
|
+
if text and dialogue_chars == 0:
|
|
81
|
+
hints.append("缺少对白证据,复查是否用场景/行动推进冲突")
|
|
82
|
+
if text and emotion_hits == 0:
|
|
83
|
+
hints.append("缺少可见的情绪动作或生理反应,复查复杂情感是否写得过于概括")
|
|
84
|
+
return {
|
|
85
|
+
"chapter": chapter,
|
|
86
|
+
"chars": chars,
|
|
87
|
+
"paragraphs": len(paragraphs),
|
|
88
|
+
"sentences": len(sentences),
|
|
89
|
+
"avg_sentence_chars": avg,
|
|
90
|
+
"long_sentences": long_sentences,
|
|
91
|
+
"dialogue_chars": dialogue_chars,
|
|
92
|
+
"emotion_action_hits": emotion_hits,
|
|
93
|
+
"hints": hints[:6],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def format_signal_summary(summary):
|
|
98
|
+
"""Render signal_summary output as a compact prompt block."""
|
|
99
|
+
if not isinstance(summary, dict):
|
|
100
|
+
return ""
|
|
101
|
+
parts = [
|
|
102
|
+
"文本信号(仅作证据,不替代文学判断):约 %s 字;%s 段;%s 句;平均句长 %s 字;长句 %s;对白 %s 字;情绪动作线索 %s 处。"
|
|
103
|
+
% (summary.get("chars", 0), summary.get("paragraphs", 0),
|
|
104
|
+
summary.get("sentences", 0), summary.get("avg_sentence_chars", 0),
|
|
105
|
+
summary.get("long_sentences", 0), summary.get("dialogue_chars", 0),
|
|
106
|
+
summary.get("emotion_action_hits", 0))]
|
|
107
|
+
hints = summary.get("hints") or []
|
|
108
|
+
if hints:
|
|
109
|
+
parts.append("确定性复查提示:" + ";".join(str(x) for x in hints))
|
|
110
|
+
return "\n".join(parts)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def style_anchor_block(anchor):
|
|
114
|
+
"""Render the book-level style anchor as a prompt block; empty when absent."""
|
|
115
|
+
anchor = str(anchor or "").strip()
|
|
116
|
+
if not anchor:
|
|
117
|
+
return ""
|
|
118
|
+
return ("## 全书文风锚(大纲裁定,所有章节起草/修订/评审必须遵守;"
|
|
119
|
+
"与其冲突处以本锚为准)\n%s\n" % anchor[:400])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def fingerprint(summary):
|
|
123
|
+
"""Normalize a signal_summary into per-1000-char style metrics."""
|
|
124
|
+
if not isinstance(summary, dict):
|
|
125
|
+
return {}
|
|
126
|
+
chars = max(1, int(summary.get("chars") or 0))
|
|
127
|
+
return {
|
|
128
|
+
"avg_sentence_chars": float(summary.get("avg_sentence_chars") or 0.0),
|
|
129
|
+
"dialogue_per_k": round(1000.0 * float(summary.get("dialogue_chars") or 0) / chars, 1),
|
|
130
|
+
"emotion_per_k": round(1000.0 * float(summary.get("emotion_action_hits") or 0) / chars, 1),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _median(values):
|
|
135
|
+
xs = sorted(float(v) for v in values if isinstance(v, (int, float)))
|
|
136
|
+
if not xs:
|
|
137
|
+
return 0.0
|
|
138
|
+
mid = len(xs) // 2
|
|
139
|
+
return xs[mid] if len(xs) % 2 else round((xs[mid - 1] + xs[mid]) / 2.0, 1)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def style_deviation_notes(baselines, summary, min_baseline=2):
|
|
143
|
+
"""Cross-chapter style drift warnings from deterministic fingerprints.
|
|
144
|
+
|
|
145
|
+
baselines: signal_summary dicts of the book's previous chapters. Needs at
|
|
146
|
+
least ``min_baseline`` chapters before speaking (开篇几章没有稳定基线);
|
|
147
|
+
all notes are evidence hints for reviewers/revisers, never scores.
|
|
148
|
+
"""
|
|
149
|
+
notes = []
|
|
150
|
+
base = [fingerprint(b) for b in (baselines or []) if isinstance(b, dict)]
|
|
151
|
+
base = [b for b in base if b]
|
|
152
|
+
if len(base) < min_baseline:
|
|
153
|
+
return notes
|
|
154
|
+
cur = fingerprint(summary)
|
|
155
|
+
if not cur:
|
|
156
|
+
return notes
|
|
157
|
+
avg_base = _median([b["avg_sentence_chars"] for b in base])
|
|
158
|
+
if avg_base >= 12 and cur["avg_sentence_chars"] > avg_base * 1.45:
|
|
159
|
+
notes.append("本章平均句长 %s 字,此前各章中位 %s 字——句式明显变长,"
|
|
160
|
+
"复查长定语、说明性插叙与节奏拖沓"
|
|
161
|
+
% (cur["avg_sentence_chars"], avg_base))
|
|
162
|
+
elif avg_base >= 12 and cur["avg_sentence_chars"] < avg_base * 0.62:
|
|
163
|
+
notes.append("本章平均句长 %s 字,此前各章中位 %s 字——句式明显变碎,"
|
|
164
|
+
"复查是否为凑字数拆散动作与因果"
|
|
165
|
+
% (cur["avg_sentence_chars"], avg_base))
|
|
166
|
+
dial_base = _median([b["dialogue_per_k"] for b in base])
|
|
167
|
+
if dial_base >= 40 and cur["dialogue_per_k"] < dial_base * 0.45:
|
|
168
|
+
notes.append("对白占比骤降(本章 %s/千字,此前中位 %s/千字)——"
|
|
169
|
+
"复查是否换成了大段叙述,文风与推进方式是否漂移"
|
|
170
|
+
% (cur["dialogue_per_k"], dial_base))
|
|
171
|
+
emo_base = _median([b["emotion_per_k"] for b in base])
|
|
172
|
+
if emo_base >= 1.5 and cur["emotion_per_k"] < emo_base * 0.4:
|
|
173
|
+
notes.append("情绪动作密度骤降(本章 %s/千字,此前中位 %s/千字)——"
|
|
174
|
+
"复查复杂情感是否退回「很伤心/很愤怒」式概括"
|
|
175
|
+
% (cur["emotion_per_k"], emo_base))
|
|
176
|
+
return notes
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def opening_below(means, threshold):
|
|
180
|
+
"""True when the signing opening dimension is scored below the line."""
|
|
181
|
+
try:
|
|
182
|
+
value = float((means or {}).get(OPENING_DIM))
|
|
183
|
+
except (TypeError, ValueError):
|
|
184
|
+
return False
|
|
185
|
+
try:
|
|
186
|
+
line = float(threshold)
|
|
187
|
+
except (TypeError, ValueError):
|
|
188
|
+
return False
|
|
189
|
+
return value < line
|
package/app/core/paths.py
CHANGED
|
@@ -66,3 +66,7 @@ LOG_TAIL_CHARS = 4000 # API 返回日志时的截断长度
|
|
|
66
66
|
def ensure_dirs():
|
|
67
67
|
for p in (DATA_DIR, TASKS_DIR, RUNS_DIR):
|
|
68
68
|
p.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
# 内置默认保存路径(settings.builtin_workdir)启动即建,用户没自定义时
|
|
70
|
+
# 「打开目录」/选目录不会撞上不存在的路径。这里不能 import settings(会环),
|
|
71
|
+
# 且即使用户自定义了默认路径,多建一个空 workspace 也无害。
|
|
72
|
+
(DATA_DIR / "workspace").mkdir(parents=True, exist_ok=True)
|