codebee 0.1.101 → 0.1.103

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.
Files changed (134) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -21
  3. package/README.md +3 -6
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -426
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/board.py +21 -17
  24. package/app/core/bookmeta.py +1036 -1036
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +13 -4
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +28 -6
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/checkpoints.py +117 -31
  32. package/app/core/cleanup.py +303 -303
  33. package/app/core/command_auth.py +302 -302
  34. package/app/core/compaction.py +201 -201
  35. package/app/core/contracts.py +255 -255
  36. package/app/core/covergen.py +356 -356
  37. package/app/core/defectretro.py +43 -43
  38. package/app/core/diagnostics.py +114 -114
  39. package/app/core/dispatch.py +1 -1
  40. package/app/core/dispatch_log.py +128 -128
  41. package/app/core/env_scrub.py +85 -85
  42. package/app/core/errorlog.py +137 -137
  43. package/app/core/eval_matrix.py +91 -82
  44. package/app/core/evaluation.py +174 -174
  45. package/app/core/failure_notify.py +290 -290
  46. package/app/core/flow_graph.py +33 -33
  47. package/app/core/goal_service.py +167 -167
  48. package/app/core/health.py +389 -389
  49. package/app/core/history.py +38 -38
  50. package/app/core/hooks.py +162 -162
  51. package/app/core/knowledge_pipeline.py +86 -86
  52. package/app/core/manager.py +2385 -2385
  53. package/app/core/mcp_client.py +7 -2
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/notify.py +201 -201
  56. package/app/core/novel_quality.py +181 -181
  57. package/app/core/openapi.py +217 -209
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +145 -145
  60. package/app/core/pipeline.py +6 -3
  61. package/app/core/policy.py +9 -0
  62. package/app/core/portguard.py +111 -111
  63. package/app/core/presence.py +77 -77
  64. package/app/core/preview.py +293 -293
  65. package/app/core/project_memory.py +206 -206
  66. package/app/core/prompt_guard.py +78 -78
  67. package/app/core/publish/__init__.py +7 -7
  68. package/app/core/publish/auto.py +659 -562
  69. package/app/core/publish/chaprows.py +138 -138
  70. package/app/core/publish/fanqie.py +16 -10
  71. package/app/core/publish/flow.py +29 -1
  72. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  73. package/app/core/publish/manager.py +1185 -1177
  74. package/app/core/publish/qimao.py +193 -193
  75. package/app/core/publish/volumes.py +108 -108
  76. package/app/core/publish/ws.py +139 -139
  77. package/app/core/quality_gate.py +201 -201
  78. package/app/core/redact.py +38 -38
  79. package/app/core/repeat_guard.py +129 -129
  80. package/app/core/retrieval.py +137 -137
  81. package/app/core/sandbox_runtime.py +95 -0
  82. package/app/core/selfupdate.py +384 -384
  83. package/app/core/session_log.py +161 -161
  84. package/app/core/sessions.py +312 -312
  85. package/app/core/settings.py +215 -215
  86. package/app/core/settings_schema.py +282 -282
  87. package/app/core/share_page.py +114 -114
  88. package/app/core/skill_scan.py +139 -139
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +13 -3
  97. package/app/core/story_tracking.py +275 -275
  98. package/app/core/task_compile.py +199 -199
  99. package/app/core/telemetry.py +292 -292
  100. package/app/core/tlsctx.py +64 -64
  101. package/app/core/token_meter.py +147 -147
  102. package/app/core/tool_outputs.py +107 -107
  103. package/app/core/tracing.py +207 -207
  104. package/app/core/upstream.py +41 -41
  105. package/app/core/usage.py +1038 -1038
  106. package/app/core/volumes.py +389 -389
  107. package/app/core/webhooks.py +89 -89
  108. package/app/core/wxdigest.py +865 -865
  109. package/app/core/zentao.py +2692 -2692
  110. package/app/main.py +1 -0
  111. package/app/mcp_server.py +244 -244
  112. package/app/output/_createbook_body.json +1 -0
  113. package/app/output/_probe_fq_click.py +50 -0
  114. package/app/output/_probe_fq_form.py +42 -0
  115. package/app/output/_probe_fq_modal.py +55 -0
  116. package/app/output/_probe_fq_points.py +71 -0
  117. package/app/output/_probe_fq_submit.py +34 -0
  118. package/app/output/_probe_fq_watch.py +62 -0
  119. package/app/output/workbench-clean-preview.jpg +0 -0
  120. package/app/output/workbench-reference-render.png +0 -0
  121. package/app/output/workbench-shell.png +0 -0
  122. package/app/pick_dialog.py +110 -110
  123. package/app/ui/app.js +165 -29
  124. package/app/ui/artdiff.js +74 -74
  125. package/app/ui/hive3d.js +491 -223
  126. package/app/ui/i18n.js +18 -1
  127. package/app/ui/icons/bee.svg +79 -79
  128. package/app/ui/index.html +24 -4
  129. package/app/ui/manifest.json +16 -16
  130. package/app/ui/qrcode.js +2297 -2297
  131. package/app/ui/style.css +548 -18
  132. package/bin/tutti.js +147 -147
  133. package/package.json +1 -1
  134. package/tools/wx_reader.py +261 -261
@@ -1,137 +1,137 @@
1
- # -*- coding: utf-8 -*-
2
- """多线剧情推演(inkos 借鉴 2026-09-23):写章前一次调用生成 2-3 条分支
3
- 节拍计划,自带推荐择优,选中分支注入本章 BEATS;全部分支追加审计文件
4
- .codebee/branch-plans.md 供回看。计划级赛马——比 prose 级 Best-of-N 省
5
- 一个数量级的 token。"""
6
- from __future__ import annotations
7
-
8
- import logging
9
- import os
10
-
11
- log = logging.getLogger(__name__)
12
-
13
- BRANCH_PLAN_PROMPT = """你是网文剧情策划。基于全书设定与上一章结尾,为下一章生成 __N__ 条**彼此方向不同**的剧情分支计划。
14
-
15
- 要求:
16
- - 每条分支给出:本章节拍(50-120 字:事件/冲突/推进)+ 章末钩子(一句话)+ 一句取舍理由(为什么选/不选它)
17
- - 分支之间要有真实的方向差异(不同冲突来源/不同人物决定/不同信息揭示),不是同一件事换措辞
18
- - 你是策划也是评委:综合大纲意图、前情、人物动机,推荐其中最有力的一条
19
-
20
- 只输出一个 ```json 代码块:
21
- {"branches": [{"beats": "…", "hook": "…", "why": "…"}, …], "pick": 0}
22
- pick = 推荐分支的下标(0 起)。
23
-
24
- ## 全书目标
25
- __GOAL__
26
-
27
- ## 大纲上下文(本章前后)
28
- __OUTLINE__
29
-
30
- ## 上一章结尾
31
- __PREV__
32
- """
33
-
34
-
35
- def plan_branches(run_id, task, i, goal, outline_txt, prev, n, *, log_path=None):
36
- """生成并择优一条分支计划。返回 (beats, hook) 或 None(失败/未启用走原大纲)。
37
-
38
- 编排者一次调用产 N 条+自荐 pick;失败静默回落原大纲节拍(推演是增强
39
- 不是闸门)。全部分支连同 pick 追加 .codebee/branch-plans.md 审计。"""
40
- if n < 2:
41
- return None
42
- # Token 压力守卫:压力 > 0.7 时跳过推演——每次推演是额外 4000
43
- # max_tokens 编排者调用;长篇后期预算紧张时推演是第一个该省的增强
44
- # (压缩守卫先跑、推演不跑,省下的预算留给正文章稿)。
45
- try:
46
- from . import token_meter
47
- if token_meter.pressure_ratio(run_id) > 0.7:
48
- return None
49
- except Exception:
50
- pass
51
- from . import modelhub, runner
52
- orch = modelhub.resolve_orchestrator()
53
- if not orch:
54
- return None
55
- prov, model = orch
56
- prompt = (BRANCH_PLAN_PROMPT
57
- .replace("__N__", str(n))
58
- .replace("__GOAL__", goal[:600])
59
- .replace("__OUTLINE__", outline_txt[:3000])
60
- .replace("__PREV__", (prev or "(开篇)")[-1200:]))
61
- res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
62
- if not res.get("ok"):
63
- return None
64
- data = runner.extract_json(res.get("text") or "")
65
- branches = (data or {}).get("branches") if isinstance(data, dict) else None
66
- if not isinstance(branches, list):
67
- return None
68
- usable = [b for b in branches
69
- if isinstance(b, dict) and str(b.get("beats") or "").strip()]
70
- if len(usable) < 2:
71
- return None
72
- try:
73
- pick = int(data.get("pick") or 0)
74
- except (TypeError, ValueError):
75
- pick = 0
76
- pick = max(0, min(pick, len(usable) - 1))
77
- chosen = usable[pick]
78
- try:
79
- from . import store as _store
80
- wd = _store.run_workdir(run_id) or ""
81
- except Exception:
82
- wd = ""
83
- _append_audit(wd, i, usable, pick)
84
- return (str(chosen["beats"]).strip()[:300],
85
- str(chosen.get("hook") or "").strip()[:80] or None)
86
-
87
-
88
- def _append_audit(workdir, chapter, branches, pick):
89
- """全部分支追加 .codebee/branch-plans.md(失败静默——审计是增强)。"""
90
- if not workdir:
91
- return
92
- try:
93
- p = _audit_path(workdir)
94
- os.makedirs(os.path.dirname(p), exist_ok=True)
95
- with open(p, "a", encoding="utf-8") as f:
96
- if f.tell() == 0:
97
- f.write("# 多线剧情推演(每章分支计划与择优,供回看)\n\n")
98
- f.write("## 第 %d 章\n" % chapter)
99
- for k, b in enumerate(branches):
100
- mark = "✅ " if k == pick else ""
101
- f.write("- %s%s(钩子:%s)——%s\n" % (
102
- mark, str(b.get("beats") or "")[:160],
103
- str(b.get("hook") or "")[:60],
104
- str(b.get("why") or "")[:120]))
105
- f.write("\n")
106
- except Exception:
107
- pass
108
-
109
-
110
- def _audit_path(workdir):
111
- """审计文件路径(越界拒绝:workdir 必须存在的目录)。"""
112
- from pathlib import Path
113
- root = Path(workdir).resolve()
114
- if not root.is_dir():
115
- raise ValueError("workdir 必须是已存在的目录")
116
- return root / ".codebee" / "branch-plans.md"
117
-
118
-
119
- def mark_stale(workdir, chapter):
120
- """正史重写后旧推演标过期(inkos 借鉴:分支计划随正史失效)。
121
-
122
- 修订步骤改写章稿后,本章的分支计划节头追加「(已过期,正史已重写)」——
123
- 防止后续翻看审计时把旧推演当成当前正史的来源。失败静默。"""
124
- if not workdir:
125
- return
126
- try:
127
- p = _audit_path(workdir)
128
- if not p.is_file():
129
- return
130
- txt = p.read_text(encoding="utf-8", errors="replace")
131
- header = "## 第 %d 章\n" % chapter
132
- stale_header = "## 第 %d 章(已过期,正史已重写)\n" % chapter
133
- if header in txt and stale_header not in txt:
134
- p.write_text(txt.replace(header, stale_header, 1),
135
- encoding="utf-8")
136
- except Exception:
137
- pass
1
+ # -*- coding: utf-8 -*-
2
+ """多线剧情推演(inkos 借鉴 2026-09-23):写章前一次调用生成 2-3 条分支
3
+ 节拍计划,自带推荐择优,选中分支注入本章 BEATS;全部分支追加审计文件
4
+ .codebee/branch-plans.md 供回看。计划级赛马——比 prose 级 Best-of-N 省
5
+ 一个数量级的 token。"""
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ BRANCH_PLAN_PROMPT = """你是网文剧情策划。基于全书设定与上一章结尾,为下一章生成 __N__ 条**彼此方向不同**的剧情分支计划。
14
+
15
+ 要求:
16
+ - 每条分支给出:本章节拍(50-120 字:事件/冲突/推进)+ 章末钩子(一句话)+ 一句取舍理由(为什么选/不选它)
17
+ - 分支之间要有真实的方向差异(不同冲突来源/不同人物决定/不同信息揭示),不是同一件事换措辞
18
+ - 你是策划也是评委:综合大纲意图、前情、人物动机,推荐其中最有力的一条
19
+
20
+ 只输出一个 ```json 代码块:
21
+ {"branches": [{"beats": "…", "hook": "…", "why": "…"}, …], "pick": 0}
22
+ pick = 推荐分支的下标(0 起)。
23
+
24
+ ## 全书目标
25
+ __GOAL__
26
+
27
+ ## 大纲上下文(本章前后)
28
+ __OUTLINE__
29
+
30
+ ## 上一章结尾
31
+ __PREV__
32
+ """
33
+
34
+
35
+ def plan_branches(run_id, task, i, goal, outline_txt, prev, n, *, log_path=None):
36
+ """生成并择优一条分支计划。返回 (beats, hook) 或 None(失败/未启用走原大纲)。
37
+
38
+ 编排者一次调用产 N 条+自荐 pick;失败静默回落原大纲节拍(推演是增强
39
+ 不是闸门)。全部分支连同 pick 追加 .codebee/branch-plans.md 审计。"""
40
+ if n < 2:
41
+ return None
42
+ # Token 压力守卫:压力 > 0.7 时跳过推演——每次推演是额外 4000
43
+ # max_tokens 编排者调用;长篇后期预算紧张时推演是第一个该省的增强
44
+ # (压缩守卫先跑、推演不跑,省下的预算留给正文章稿)。
45
+ try:
46
+ from . import token_meter
47
+ if token_meter.pressure_ratio(run_id) > 0.7:
48
+ return None
49
+ except Exception:
50
+ pass
51
+ from . import modelhub, runner
52
+ orch = modelhub.resolve_orchestrator()
53
+ if not orch:
54
+ return None
55
+ prov, model = orch
56
+ prompt = (BRANCH_PLAN_PROMPT
57
+ .replace("__N__", str(n))
58
+ .replace("__GOAL__", goal[:600])
59
+ .replace("__OUTLINE__", outline_txt[:3000])
60
+ .replace("__PREV__", (prev or "(开篇)")[-1200:]))
61
+ res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
62
+ if not res.get("ok"):
63
+ return None
64
+ data = runner.extract_json(res.get("text") or "")
65
+ branches = (data or {}).get("branches") if isinstance(data, dict) else None
66
+ if not isinstance(branches, list):
67
+ return None
68
+ usable = [b for b in branches
69
+ if isinstance(b, dict) and str(b.get("beats") or "").strip()]
70
+ if len(usable) < 2:
71
+ return None
72
+ try:
73
+ pick = int(data.get("pick") or 0)
74
+ except (TypeError, ValueError):
75
+ pick = 0
76
+ pick = max(0, min(pick, len(usable) - 1))
77
+ chosen = usable[pick]
78
+ try:
79
+ from . import store as _store
80
+ wd = _store.run_workdir(run_id) or ""
81
+ except Exception:
82
+ wd = ""
83
+ _append_audit(wd, i, usable, pick)
84
+ return (str(chosen["beats"]).strip()[:300],
85
+ str(chosen.get("hook") or "").strip()[:80] or None)
86
+
87
+
88
+ def _append_audit(workdir, chapter, branches, pick):
89
+ """全部分支追加 .codebee/branch-plans.md(失败静默——审计是增强)。"""
90
+ if not workdir:
91
+ return
92
+ try:
93
+ p = _audit_path(workdir)
94
+ os.makedirs(os.path.dirname(p), exist_ok=True)
95
+ with open(p, "a", encoding="utf-8") as f:
96
+ if f.tell() == 0:
97
+ f.write("# 多线剧情推演(每章分支计划与择优,供回看)\n\n")
98
+ f.write("## 第 %d 章\n" % chapter)
99
+ for k, b in enumerate(branches):
100
+ mark = "✅ " if k == pick else ""
101
+ f.write("- %s%s(钩子:%s)——%s\n" % (
102
+ mark, str(b.get("beats") or "")[:160],
103
+ str(b.get("hook") or "")[:60],
104
+ str(b.get("why") or "")[:120]))
105
+ f.write("\n")
106
+ except Exception:
107
+ pass
108
+
109
+
110
+ def _audit_path(workdir):
111
+ """审计文件路径(越界拒绝:workdir 必须存在的目录)。"""
112
+ from pathlib import Path
113
+ root = Path(workdir).resolve()
114
+ if not root.is_dir():
115
+ raise ValueError("workdir 必须是已存在的目录")
116
+ return root / ".codebee" / "branch-plans.md"
117
+
118
+
119
+ def mark_stale(workdir, chapter):
120
+ """正史重写后旧推演标过期(inkos 借鉴:分支计划随正史失效)。
121
+
122
+ 修订步骤改写章稿后,本章的分支计划节头追加「(已过期,正史已重写)」——
123
+ 防止后续翻看审计时把旧推演当成当前正史的来源。失败静默。"""
124
+ if not workdir:
125
+ return
126
+ try:
127
+ p = _audit_path(workdir)
128
+ if not p.is_file():
129
+ return
130
+ txt = p.read_text(encoding="utf-8", errors="replace")
131
+ header = "## 第 %d 章\n" % chapter
132
+ stale_header = "## 第 %d 章(已过期,正史已重写)\n" % chapter
133
+ if header in txt and stale_header not in txt:
134
+ p.write_text(txt.replace(header, stale_header, 1),
135
+ encoding="utf-8")
136
+ except Exception:
137
+ pass
@@ -441,13 +441,20 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
441
441
  # policy keeps the user-approved full-trust tier (2026-09-22) instead of
442
442
  # refusing every command.
443
443
  has_bwrap = os.name == "posix" and bool(shutil.which("bwrap"))
444
- if sandbox.get("network") is False and not has_bwrap:
444
+ if sandbox.get("network") is False and not has_bwrap and sandbox.get("backend") != "docker":
445
445
  return "(沙箱拒绝:当前系统没有可用的网络隔离后端,命令未执行)"
446
- if sandbox.get("env_allowlist"):
446
+ if sandbox.get("env_allowlist") and sandbox.get("backend") != "docker":
447
447
  # run_process 的 env 是注入语义(合并进完整父环境,供凭据注入),
448
448
  # 不是白名单替换——环境收窄在子进程侧无法兑现,宁拒不假装
449
449
  return "(沙箱拒绝:该任务带环境变量白名单,命令执行无法兑现该策略,未执行)"
450
- if has_bwrap:
450
+ if sandbox.get("backend") == "docker":
451
+ try:
452
+ from .sandbox_runtime import docker_argv, docker_env
453
+ argv = docker_argv(cmdline, sandbox=sandbox, workdir=cwd)
454
+ launch_env = docker_env(sandbox)
455
+ except Exception as exc:
456
+ return "(沙箱拒绝:%s,命令未执行)" % str(exc)[:240]
457
+ elif has_bwrap:
451
458
  argv = ["bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/"]
452
459
  # Mount only the runtime needed by ordinary commands. Do not expose the
453
460
  # entire host root read-only: read access can still leak user secrets.
@@ -468,10 +475,12 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
468
475
  argv.extend(["--dir", root_path])
469
476
  argv.extend(["--bind", root_path, root_path])
470
477
  argv.extend(["--chdir", cwd, "--", "/bin/sh", "-lc", cmdline])
478
+ launch_env = None
471
479
  else:
472
480
  argv = None # 全信任档:runner 按平台自包 cmd /c 或 /bin/sh -c
481
+ launch_env = None
473
482
  r = runner.run_process(argv=argv, shell_cmd=cmdline if argv is None else None,
474
- cwd=cwd, env=None,
483
+ cwd=cwd, env=launch_env,
475
484
  timeout=min(timeout, CMD_MAX_TIMEOUT),
476
485
  deadline=deadline, cancel_event=cancel_event)
477
486
  out = runner.clean_cli_text(
@@ -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