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/history.py
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""历史统计:从过往运行记录提取每个智能体在每种任务类型上的胜率,反哺路由。"""
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from . import store
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def agent_stats(limit=200):
|
|
9
|
-
"""返回 {agent_id: {task_type: {"runs": n, "wins": w}}}。"""
|
|
10
|
-
stats = {}
|
|
11
|
-
|
|
12
|
-
def bump(aid, ttype, win):
|
|
13
|
-
if not aid:
|
|
14
|
-
return
|
|
15
|
-
s = stats.setdefault(aid, {}).setdefault(ttype, {"runs": 0, "wins": 0})
|
|
16
|
-
s["runs"] += 1
|
|
17
|
-
if win:
|
|
18
|
-
s["wins"] += 1
|
|
19
|
-
|
|
20
|
-
for run in store.list_runs(limit):
|
|
21
|
-
if run.get("kind") != "orchestration":
|
|
22
|
-
continue
|
|
23
|
-
# 失败运行也要进历史(记为全负):以前只数 done——常挂的 CLI 永远
|
|
24
|
-
# 「无历史记录」满血参选,2026-09-17 opencode 当日秒挂 3 次仍被换将
|
|
25
|
-
# 选中。cancelled 不算(用户取消非智能体之过)。
|
|
26
|
-
if run.get("status") not in ("done", "failed"):
|
|
27
|
-
continue
|
|
28
|
-
verdict = run.get("verdict") or {}
|
|
29
|
-
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
30
|
-
ttype = (task or {}).get("type") or verdict.get("type")
|
|
31
|
-
if not ttype:
|
|
32
|
-
continue
|
|
33
|
-
win = run.get("status") == "done" and bool(
|
|
34
|
-
verdict.get("pass") or verdict.get("publishable"))
|
|
35
|
-
for s in run.get("steps") or []:
|
|
36
|
-
if s.get("role") in ("implement", "draft"):
|
|
37
|
-
bump(s.get("agent"), ttype, win)
|
|
38
|
-
return stats
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""历史统计:从过往运行记录提取每个智能体在每种任务类型上的胜率,反哺路由。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import store
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def agent_stats(limit=200):
|
|
9
|
+
"""返回 {agent_id: {task_type: {"runs": n, "wins": w}}}。"""
|
|
10
|
+
stats = {}
|
|
11
|
+
|
|
12
|
+
def bump(aid, ttype, win):
|
|
13
|
+
if not aid:
|
|
14
|
+
return
|
|
15
|
+
s = stats.setdefault(aid, {}).setdefault(ttype, {"runs": 0, "wins": 0})
|
|
16
|
+
s["runs"] += 1
|
|
17
|
+
if win:
|
|
18
|
+
s["wins"] += 1
|
|
19
|
+
|
|
20
|
+
for run in store.list_runs(limit):
|
|
21
|
+
if run.get("kind") != "orchestration":
|
|
22
|
+
continue
|
|
23
|
+
# 失败运行也要进历史(记为全负):以前只数 done——常挂的 CLI 永远
|
|
24
|
+
# 「无历史记录」满血参选,2026-09-17 opencode 当日秒挂 3 次仍被换将
|
|
25
|
+
# 选中。cancelled 不算(用户取消非智能体之过)。
|
|
26
|
+
if run.get("status") not in ("done", "failed"):
|
|
27
|
+
continue
|
|
28
|
+
verdict = run.get("verdict") or {}
|
|
29
|
+
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
30
|
+
ttype = (task or {}).get("type") or verdict.get("type")
|
|
31
|
+
if not ttype:
|
|
32
|
+
continue
|
|
33
|
+
win = run.get("status") == "done" and bool(
|
|
34
|
+
verdict.get("pass") or verdict.get("publishable"))
|
|
35
|
+
for s in run.get("steps") or []:
|
|
36
|
+
if s.get("role") in ("implement", "draft"):
|
|
37
|
+
bump(s.get("agent"), ttype, win)
|
|
38
|
+
return stats
|
package/app/core/hooks.py
CHANGED
|
@@ -1,162 +1,162 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""任务生命周期钩子(借鉴 ZCode hooks,收窄为三个事件——2026-09-22 用户立项):
|
|
3
|
-
|
|
4
|
-
- task_start 任务开跑前(编排/直连都触发):stdout 可注入文本,追加进任务上下文
|
|
5
|
-
- message_submit 用户消息送达前:stdout 可注入文本,随消息进提示词头
|
|
6
|
-
- run_end 每轮运行结束:纯副作用型(回写知识库等),输出被忽略
|
|
7
|
-
|
|
8
|
-
协议:钩子命令从 stdin 收 JSON(事件+上下文);stdout 若为 {"inject": "..."}
|
|
9
|
-
取该字段为注入文本,否则把非空 stdout 整体当注入文本。
|
|
10
|
-
失败/超时一律静默跳过(外部脚本不稳绝不拖死编排主流程)。
|
|
11
|
-
|
|
12
|
-
命令执行:shlex 分词(保 Windows 反斜杠)后走参数列表,不经 shell——
|
|
13
|
-
不支持管道/重定向等 shell 语法,需要时写 .py/.bat 包装脚本。
|
|
14
|
-
配置:DATA_DIR/hooks.json,纯本机设置。"""
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import json
|
|
18
|
-
import logging
|
|
19
|
-
import os
|
|
20
|
-
import shlex
|
|
21
|
-
import subprocess
|
|
22
|
-
|
|
23
|
-
from . import paths
|
|
24
|
-
|
|
25
|
-
log = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
EVENTS = ("task_start", "message_submit", "run_end")
|
|
28
|
-
_DEFAULT_TIMEOUT = 10
|
|
29
|
-
_FILE = None # 测试重绑(同 modelhub._FILE 惯例)
|
|
30
|
-
|
|
31
|
-
_CREATE_NO_WINDOW = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _path():
|
|
35
|
-
return _FILE or (paths.DATA_DIR / "hooks.json")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _default_list():
|
|
39
|
-
return []
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def load_list():
|
|
43
|
-
"""读钩子配置列表;损坏/缺省回落空表。"""
|
|
44
|
-
try:
|
|
45
|
-
data = json.loads(_path().read_text(encoding="utf-8"))
|
|
46
|
-
return data if isinstance(data, list) else _default_list()
|
|
47
|
-
except Exception:
|
|
48
|
-
return _default_list()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def save_list(items):
|
|
52
|
-
"""整表保存(UI 全量提交)。字段归一 + 事件白名单。"""
|
|
53
|
-
if not isinstance(items, list):
|
|
54
|
-
raise ValueError("hooks 必须是数组")
|
|
55
|
-
out = []
|
|
56
|
-
for i, h in enumerate(items):
|
|
57
|
-
if not isinstance(h, dict):
|
|
58
|
-
continue
|
|
59
|
-
name = str(h.get("name") or "").strip()[:60]
|
|
60
|
-
event = str(h.get("event") or "").strip()
|
|
61
|
-
cmd = str(h.get("cmd") or "").strip()
|
|
62
|
-
if not cmd:
|
|
63
|
-
continue
|
|
64
|
-
if event not in EVENTS:
|
|
65
|
-
raise ValueError("第 %d 条钩子 event 非法(%s)" % (i + 1, event))
|
|
66
|
-
try:
|
|
67
|
-
timeout = max(1, min(120, int(h.get("timeout_s") or _DEFAULT_TIMEOUT)))
|
|
68
|
-
except (TypeError, ValueError):
|
|
69
|
-
timeout = _DEFAULT_TIMEOUT
|
|
70
|
-
out.append({"id": str(h.get("id") or ("hk-%d" % (i + 1)))[:40],
|
|
71
|
-
"name": name or ("钩子 %d" % (i + 1)),
|
|
72
|
-
"event": event, "cmd": cmd,
|
|
73
|
-
"enabled": bool(h.get("enabled", True)),
|
|
74
|
-
"timeout_s": timeout})
|
|
75
|
-
tmp = _path().with_suffix(".tmp")
|
|
76
|
-
tmp.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
77
|
-
tmp.replace(_path())
|
|
78
|
-
return out
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _argv(cmd):
|
|
82
|
-
"""命令串 → 参数列表。Windows 反斜杠路径保真(posix=False),支持引号分组;
|
|
83
|
-
分词后剥掉 token 首尾引号(CreateProcess 不认字面引号路径)。"""
|
|
84
|
-
toks = shlex.split(str(cmd or ""), posix=False)
|
|
85
|
-
out = []
|
|
86
|
-
for t in toks:
|
|
87
|
-
if len(t) >= 2 and t[0] == t[-1] and t[0] in ('"', "'"):
|
|
88
|
-
t = t[1:-1]
|
|
89
|
-
if t:
|
|
90
|
-
out.append(t)
|
|
91
|
-
return out
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _run_one(h, payload):
|
|
95
|
-
"""跑单条钩子。返回注入文本或 None(失败/超时/空输出)。"""
|
|
96
|
-
try:
|
|
97
|
-
argv = _argv(h.get("cmd"))
|
|
98
|
-
except ValueError as e:
|
|
99
|
-
log.warning("hook[%s %s] 命令解析失败:%s", h.get("name"), h.get("event"), str(e)[:120])
|
|
100
|
-
return None
|
|
101
|
-
if not argv:
|
|
102
|
-
return None
|
|
103
|
-
try:
|
|
104
|
-
# Hook input/output is a UTF-8 JSON/text protocol. Python subprocesses on
|
|
105
|
-
# Windows otherwise inherit the OEM code page and their Chinese stdout is
|
|
106
|
-
# decoded as mojibake below. Non-Python hooks ignore this variable.
|
|
107
|
-
env = os.environ.copy()
|
|
108
|
-
env["PYTHONIOENCODING"] = "utf-8"
|
|
109
|
-
proc = subprocess.run(
|
|
110
|
-
argv, shell=False,
|
|
111
|
-
input=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
112
|
-
capture_output=True, timeout=int(h.get("timeout_s") or _DEFAULT_TIMEOUT),
|
|
113
|
-
creationflags=_CREATE_NO_WINDOW, env=env)
|
|
114
|
-
except subprocess.TimeoutExpired:
|
|
115
|
-
log.warning("hook[%s %s] 超时,已跳过", h.get("name"), h.get("event"))
|
|
116
|
-
return None
|
|
117
|
-
except Exception as e:
|
|
118
|
-
log.warning("hook[%s %s] 执行失败:%s", h.get("name"), h.get("event"), str(e)[:120])
|
|
119
|
-
return None
|
|
120
|
-
out = (proc.stdout or b"").decode("utf-8", "replace").strip()
|
|
121
|
-
if not out:
|
|
122
|
-
return None
|
|
123
|
-
try:
|
|
124
|
-
data = json.loads(out)
|
|
125
|
-
if isinstance(data, dict):
|
|
126
|
-
return str(data.get("inject") or "") or None
|
|
127
|
-
except Exception:
|
|
128
|
-
pass
|
|
129
|
-
return out
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def run_event(event, ctx=None, task_id=None, run_id=None, text=None):
|
|
133
|
-
"""触发事件:依次跑启用的钩子,合并注入文本。任何异常不外抛。
|
|
134
|
-
|
|
135
|
-
ctx 附带任务/运行摘要;text 仅 message_submit 时传用户消息原文。
|
|
136
|
-
返回拼接好的注入文本(可为空串)。"""
|
|
137
|
-
if event not in EVENTS:
|
|
138
|
-
return ""
|
|
139
|
-
try:
|
|
140
|
-
items = [h for h in load_list() if h.get("enabled") and h.get("event") == event]
|
|
141
|
-
except Exception:
|
|
142
|
-
return ""
|
|
143
|
-
if not items:
|
|
144
|
-
return ""
|
|
145
|
-
payload = {"event": event, "task_id": task_id or "", "run_id": run_id or "",
|
|
146
|
-
"text": text or ""}
|
|
147
|
-
if ctx is not None:
|
|
148
|
-
payload["ctx"] = ctx
|
|
149
|
-
outs = []
|
|
150
|
-
for h in items:
|
|
151
|
-
piece = _run_one(h, payload)
|
|
152
|
-
if piece:
|
|
153
|
-
outs.append(piece.strip())
|
|
154
|
-
return "\n\n".join(outs)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def test_one(cmd, event="task_start", timeout_s=None):
|
|
158
|
-
"""设置页「测试」:手动跑一条钩子,返回 (ok, 输出/错误人话)。"""
|
|
159
|
-
h = {"name": "test", "event": event if event in EVENTS else "task_start",
|
|
160
|
-
"cmd": cmd, "timeout_s": timeout_s or _DEFAULT_TIMEOUT}
|
|
161
|
-
piece = _run_one(h, {"event": h["event"], "task_id": "", "run_id": "", "text": ""})
|
|
162
|
-
return True, piece if piece else "(无输出,未注入)"
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""任务生命周期钩子(借鉴 ZCode hooks,收窄为三个事件——2026-09-22 用户立项):
|
|
3
|
+
|
|
4
|
+
- task_start 任务开跑前(编排/直连都触发):stdout 可注入文本,追加进任务上下文
|
|
5
|
+
- message_submit 用户消息送达前:stdout 可注入文本,随消息进提示词头
|
|
6
|
+
- run_end 每轮运行结束:纯副作用型(回写知识库等),输出被忽略
|
|
7
|
+
|
|
8
|
+
协议:钩子命令从 stdin 收 JSON(事件+上下文);stdout 若为 {"inject": "..."}
|
|
9
|
+
取该字段为注入文本,否则把非空 stdout 整体当注入文本。
|
|
10
|
+
失败/超时一律静默跳过(外部脚本不稳绝不拖死编排主流程)。
|
|
11
|
+
|
|
12
|
+
命令执行:shlex 分词(保 Windows 反斜杠)后走参数列表,不经 shell——
|
|
13
|
+
不支持管道/重定向等 shell 语法,需要时写 .py/.bat 包装脚本。
|
|
14
|
+
配置:DATA_DIR/hooks.json,纯本机设置。"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import subprocess
|
|
22
|
+
|
|
23
|
+
from . import paths
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
EVENTS = ("task_start", "message_submit", "run_end")
|
|
28
|
+
_DEFAULT_TIMEOUT = 10
|
|
29
|
+
_FILE = None # 测试重绑(同 modelhub._FILE 惯例)
|
|
30
|
+
|
|
31
|
+
_CREATE_NO_WINDOW = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _path():
|
|
35
|
+
return _FILE or (paths.DATA_DIR / "hooks.json")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _default_list():
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_list():
|
|
43
|
+
"""读钩子配置列表;损坏/缺省回落空表。"""
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(_path().read_text(encoding="utf-8"))
|
|
46
|
+
return data if isinstance(data, list) else _default_list()
|
|
47
|
+
except Exception:
|
|
48
|
+
return _default_list()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_list(items):
|
|
52
|
+
"""整表保存(UI 全量提交)。字段归一 + 事件白名单。"""
|
|
53
|
+
if not isinstance(items, list):
|
|
54
|
+
raise ValueError("hooks 必须是数组")
|
|
55
|
+
out = []
|
|
56
|
+
for i, h in enumerate(items):
|
|
57
|
+
if not isinstance(h, dict):
|
|
58
|
+
continue
|
|
59
|
+
name = str(h.get("name") or "").strip()[:60]
|
|
60
|
+
event = str(h.get("event") or "").strip()
|
|
61
|
+
cmd = str(h.get("cmd") or "").strip()
|
|
62
|
+
if not cmd:
|
|
63
|
+
continue
|
|
64
|
+
if event not in EVENTS:
|
|
65
|
+
raise ValueError("第 %d 条钩子 event 非法(%s)" % (i + 1, event))
|
|
66
|
+
try:
|
|
67
|
+
timeout = max(1, min(120, int(h.get("timeout_s") or _DEFAULT_TIMEOUT)))
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
timeout = _DEFAULT_TIMEOUT
|
|
70
|
+
out.append({"id": str(h.get("id") or ("hk-%d" % (i + 1)))[:40],
|
|
71
|
+
"name": name or ("钩子 %d" % (i + 1)),
|
|
72
|
+
"event": event, "cmd": cmd,
|
|
73
|
+
"enabled": bool(h.get("enabled", True)),
|
|
74
|
+
"timeout_s": timeout})
|
|
75
|
+
tmp = _path().with_suffix(".tmp")
|
|
76
|
+
tmp.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
77
|
+
tmp.replace(_path())
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _argv(cmd):
|
|
82
|
+
"""命令串 → 参数列表。Windows 反斜杠路径保真(posix=False),支持引号分组;
|
|
83
|
+
分词后剥掉 token 首尾引号(CreateProcess 不认字面引号路径)。"""
|
|
84
|
+
toks = shlex.split(str(cmd or ""), posix=False)
|
|
85
|
+
out = []
|
|
86
|
+
for t in toks:
|
|
87
|
+
if len(t) >= 2 and t[0] == t[-1] and t[0] in ('"', "'"):
|
|
88
|
+
t = t[1:-1]
|
|
89
|
+
if t:
|
|
90
|
+
out.append(t)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_one(h, payload):
|
|
95
|
+
"""跑单条钩子。返回注入文本或 None(失败/超时/空输出)。"""
|
|
96
|
+
try:
|
|
97
|
+
argv = _argv(h.get("cmd"))
|
|
98
|
+
except ValueError as e:
|
|
99
|
+
log.warning("hook[%s %s] 命令解析失败:%s", h.get("name"), h.get("event"), str(e)[:120])
|
|
100
|
+
return None
|
|
101
|
+
if not argv:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
# Hook input/output is a UTF-8 JSON/text protocol. Python subprocesses on
|
|
105
|
+
# Windows otherwise inherit the OEM code page and their Chinese stdout is
|
|
106
|
+
# decoded as mojibake below. Non-Python hooks ignore this variable.
|
|
107
|
+
env = os.environ.copy()
|
|
108
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
109
|
+
proc = subprocess.run(
|
|
110
|
+
argv, shell=False,
|
|
111
|
+
input=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
112
|
+
capture_output=True, timeout=int(h.get("timeout_s") or _DEFAULT_TIMEOUT),
|
|
113
|
+
creationflags=_CREATE_NO_WINDOW, env=env)
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
log.warning("hook[%s %s] 超时,已跳过", h.get("name"), h.get("event"))
|
|
116
|
+
return None
|
|
117
|
+
except Exception as e:
|
|
118
|
+
log.warning("hook[%s %s] 执行失败:%s", h.get("name"), h.get("event"), str(e)[:120])
|
|
119
|
+
return None
|
|
120
|
+
out = (proc.stdout or b"").decode("utf-8", "replace").strip()
|
|
121
|
+
if not out:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(out)
|
|
125
|
+
if isinstance(data, dict):
|
|
126
|
+
return str(data.get("inject") or "") or None
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run_event(event, ctx=None, task_id=None, run_id=None, text=None):
|
|
133
|
+
"""触发事件:依次跑启用的钩子,合并注入文本。任何异常不外抛。
|
|
134
|
+
|
|
135
|
+
ctx 附带任务/运行摘要;text 仅 message_submit 时传用户消息原文。
|
|
136
|
+
返回拼接好的注入文本(可为空串)。"""
|
|
137
|
+
if event not in EVENTS:
|
|
138
|
+
return ""
|
|
139
|
+
try:
|
|
140
|
+
items = [h for h in load_list() if h.get("enabled") and h.get("event") == event]
|
|
141
|
+
except Exception:
|
|
142
|
+
return ""
|
|
143
|
+
if not items:
|
|
144
|
+
return ""
|
|
145
|
+
payload = {"event": event, "task_id": task_id or "", "run_id": run_id or "",
|
|
146
|
+
"text": text or ""}
|
|
147
|
+
if ctx is not None:
|
|
148
|
+
payload["ctx"] = ctx
|
|
149
|
+
outs = []
|
|
150
|
+
for h in items:
|
|
151
|
+
piece = _run_one(h, payload)
|
|
152
|
+
if piece:
|
|
153
|
+
outs.append(piece.strip())
|
|
154
|
+
return "\n\n".join(outs)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_one(cmd, event="task_start", timeout_s=None):
|
|
158
|
+
"""设置页「测试」:手动跑一条钩子,返回 (ok, 输出/错误人话)。"""
|
|
159
|
+
h = {"name": "test", "event": event if event in EVENTS else "task_start",
|
|
160
|
+
"cmd": cmd, "timeout_s": timeout_s or _DEFAULT_TIMEOUT}
|
|
161
|
+
piece = _run_one(h, {"event": h["event"], "task_id": "", "run_id": "", "text": ""})
|
|
162
|
+
return True, piece if piece else "(无输出,未注入)"
|
package/app/core/jobs.py
CHANGED
|
@@ -750,6 +750,7 @@ def _maybe_auto_resume(run_id):
|
|
|
750
750
|
|
|
751
751
|
|
|
752
752
|
RESUME_WINDOW_HOURS = 24 # 启动恢复只看最近 24h 内中断的运行(更早的视为已放弃)
|
|
753
|
+
UNCLAIMED_QUEUE_MAX_AGE_S = RESUME_WINDOW_HOURS * 3600
|
|
753
754
|
|
|
754
755
|
|
|
755
756
|
def _recent(run):
|
|
@@ -887,15 +888,35 @@ def requeue_pending(limit=10, max_age_s=None):
|
|
|
887
888
|
kind = run.get("kind")
|
|
888
889
|
if run.get("status") != "queued" or kind not in ("orchestration", "mgmt"):
|
|
889
890
|
continue
|
|
891
|
+
# requeue_pending 也会在进程存活期间由巡检线程调用。load_all() 只
|
|
892
|
+
# 能清理启动时发现的 queued 残留;如果进程一直不重启,旧的测试/异常
|
|
893
|
+
# 创建记录会永远绕过 _recent() 留在「排队中」。超过恢复窗口的记录
|
|
894
|
+
# 已不具备自动恢复价值,直接收口并同步任务状态,保留用户手动重试入口。
|
|
895
|
+
# 明确的未来 resume_enqueue_at 属于有意退避,即使创建时间很老也不能提前
|
|
896
|
+
# 打断;到点后下一轮巡检会重新进入这里。
|
|
897
|
+
if _in_resume_backoff(run):
|
|
898
|
+
continue
|
|
899
|
+
if kind == "orchestration":
|
|
900
|
+
if not run.get("task_id") or not _recent(run):
|
|
901
|
+
# Keep malformed/old records eligible for the expiry path,
|
|
902
|
+
# but never let an old duplicate override a newer run.
|
|
903
|
+
if run.get("task_id") and _task_active_run(
|
|
904
|
+
run["task_id"], exclude_run_id=run["id"]):
|
|
905
|
+
continue
|
|
906
|
+
elif _task_active_run(run["task_id"], exclude_run_id=run["id"]):
|
|
907
|
+
continue # 同任务已有更活跃的运行,别收口/再排一份
|
|
908
|
+
if _age_s(run) >= UNCLAIMED_QUEUE_MAX_AGE_S:
|
|
909
|
+
store.update_run(
|
|
910
|
+
run["id"], expected_status="queued", status="failed",
|
|
911
|
+
ended_at=_now(),
|
|
912
|
+
error="排队记录已过期,服务未能在恢复窗口内启动,可重试",
|
|
913
|
+
)
|
|
914
|
+
continue
|
|
890
915
|
if max_age_s is not None and _age_s(run) < max_age_s:
|
|
891
916
|
continue
|
|
892
917
|
if kind == "orchestration":
|
|
893
918
|
if not run.get("task_id") or not _recent(run):
|
|
894
919
|
continue
|
|
895
|
-
if _in_resume_backoff(run):
|
|
896
|
-
continue # 退避窗口内的续跑副本:到点 Timer 自会入队
|
|
897
|
-
if _task_active_run(run["task_id"], exclude_run_id=run["id"]):
|
|
898
|
-
continue # 同任务已有更活跃的运行,别再排一份
|
|
899
920
|
else:
|
|
900
921
|
eid = run.get("entry_id")
|
|
901
922
|
if eid:
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Source hashing, chunking and quality reporting for the local knowledge index."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from . import paths, retrieval
|
|
11
|
+
|
|
12
|
+
_REPORT_FILE = paths.DATA_DIR / "knowledge_pipeline.json"
|
|
13
|
+
_LOCK = threading.RLock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def chunk_text(text, chunk_size=1200, overlap=120):
|
|
17
|
+
text = str(text or "")
|
|
18
|
+
try:
|
|
19
|
+
size = max(100, min(10000, int(chunk_size)))
|
|
20
|
+
overlap = max(0, min(size // 2, int(overlap)))
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
size, overlap = 1200, 120
|
|
23
|
+
if not text:
|
|
24
|
+
return []
|
|
25
|
+
out, start = [], 0
|
|
26
|
+
while start < len(text):
|
|
27
|
+
end = min(len(text), start + size)
|
|
28
|
+
part = text[start:end].strip()
|
|
29
|
+
if part:
|
|
30
|
+
out.append(part)
|
|
31
|
+
if end >= len(text):
|
|
32
|
+
break
|
|
33
|
+
start = end - overlap
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load():
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(_REPORT_FILE.read_text(encoding="utf-8"))
|
|
40
|
+
return data if isinstance(data, dict) else {}
|
|
41
|
+
except (OSError, TypeError, ValueError):
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ingest(source, text, metadata=None, *, chunk_size=1200, overlap=120):
|
|
46
|
+
source = str(source or "").strip()[:500]
|
|
47
|
+
text = str(text or "")
|
|
48
|
+
if len(text) > 2 * 1024 * 1024:
|
|
49
|
+
raise ValueError("text 过大(最大 2 MiB)")
|
|
50
|
+
if not source or not text.strip():
|
|
51
|
+
raise ValueError("source 和 text 不能为空")
|
|
52
|
+
source_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
53
|
+
ingest_id = "ingest-" + hashlib.sha256((source + "\0" + source_hash).encode("utf-8")).hexdigest()[:20]
|
|
54
|
+
chunks = chunk_text(text, chunk_size, overlap)
|
|
55
|
+
if metadata is not None and not isinstance(metadata, dict):
|
|
56
|
+
raise ValueError("metadata 必须是对象")
|
|
57
|
+
meta = {str(k)[:60]: str(v)[:500] for k, v in (metadata or {}).items()
|
|
58
|
+
if isinstance(k, (str, int)) and isinstance(v, (str, int, float, bool))}
|
|
59
|
+
indexed = []
|
|
60
|
+
for index, part in enumerate(chunks):
|
|
61
|
+
indexed.append(retrieval.upsert(
|
|
62
|
+
"%s#%d" % (source, index + 1), part,
|
|
63
|
+
dict(meta, source=source, source_sha256=source_hash,
|
|
64
|
+
ingest_id=ingest_id, chunk_index=index, chunk_count=len(chunks)),
|
|
65
|
+
doc_id="%s-%04d" % (ingest_id, index)))
|
|
66
|
+
report = {"ingest_id": ingest_id, "source": source, "source_sha256": source_hash,
|
|
67
|
+
"chunks": len(chunks), "indexed_chunks": len(indexed),
|
|
68
|
+
"chars": len(text), "created_at": time.time(),
|
|
69
|
+
"quality": {"non_empty_ratio": round(sum(bool(x.strip()) for x in chunks) /
|
|
70
|
+
float(max(1, len(chunks))), 4),
|
|
71
|
+
"unique_ratio": round(len(set(chunks)) / float(max(1, len(chunks))), 4),
|
|
72
|
+
"avg_chunk_chars": round(sum(map(len, chunks)) / float(max(1, len(chunks))), 2)}}
|
|
73
|
+
with _LOCK:
|
|
74
|
+
data = _load()
|
|
75
|
+
data[ingest_id] = report
|
|
76
|
+
_REPORT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
tmp = _REPORT_FILE.with_suffix(".tmp")
|
|
78
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
79
|
+
tmp.replace(_REPORT_FILE)
|
|
80
|
+
return report
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def quality_report(ingest_id):
|
|
84
|
+
with _LOCK:
|
|
85
|
+
return _load().get(str(ingest_id or ""), {"ingest_id": str(ingest_id or ""),
|
|
86
|
+
"indexed_chunks": 0, "quality": {}})
|