codebee 0.1.0
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/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""作品信息一键生成:按发布平台(番茄/七猫)产出建书表单要填的开书资料。
|
|
3
|
+
|
|
4
|
+
用户在详情页「成果」分区点按钮触发(不做自动生成),后台线程调编排者模型
|
|
5
|
+
提炼 大纲 + 故事圣经 + 已有章节 的精华,按平台表单字段归一化后存任务
|
|
6
|
+
book_meta 字段,同时在工作目录落一份 Markdown 归档。降级链与大纲一致:
|
|
7
|
+
编排者 API → 作者 CLI → 模板兜底(模板只做搬运归纳,字段留待用户补)。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
PLATFORMS = {
|
|
16
|
+
"fanqie": {"label": "番茄", "file": "作品信息-番茄.md"},
|
|
17
|
+
"qimao": {"label": "七猫", "file": "作品信息-七猫.md"},
|
|
18
|
+
}
|
|
19
|
+
# 单平台生成上限:编排者提炼一段简介级文本是轻任务,900s 足够含重试
|
|
20
|
+
BOOKMETA_TIMEOUT = 900
|
|
21
|
+
_FIRST_CH_HEAD = 1500 # 注入提示词的第一章开头字符数
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def template_only():
|
|
25
|
+
"""只走模板兜底(跳过编排者/作者 CLI):env TUTTI_BOOKMETA_TEMPLATE_ONLY=1。
|
|
26
|
+
|
|
27
|
+
离线环境、不想为开书资料再花一次模型调用、以及测试要确定性输出时使用;
|
|
28
|
+
产出仍是结构完整的字段,只是书名/简介直接取大纲与目标,其余留「待补充」。"""
|
|
29
|
+
return str(os.environ.get("TUTTI_BOOKMETA_TEMPLATE_ONLY") or "").strip() in ("1", "true", "yes")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def needs_book_meta(task):
|
|
33
|
+
"""这本书是否需要开书资料:连载首批(start_chapter <= 1)才需要。
|
|
34
|
+
|
|
35
|
+
开书资料属于「这本书」而不是某一批章节——续写批次沿用第一批的书名/简介/
|
|
36
|
+
标签即可,重复生成只会覆盖成同内容的近义版本。非连载任务一律不需要。"""
|
|
37
|
+
serial = (task or {}).get("serial")
|
|
38
|
+
if not isinstance(serial, dict):
|
|
39
|
+
return False
|
|
40
|
+
try:
|
|
41
|
+
return int(serial.get("start_chapter") or 1) <= 1
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# 各平台建书表单的字段展示顺序(前端弹框与 Markdown 归档共用)
|
|
47
|
+
FIELD_LABELS = {
|
|
48
|
+
"fanqie": [
|
|
49
|
+
("book_name", "作品名"), ("signing_mode", "签约模式"),
|
|
50
|
+
("target_reader", "目标读者"), ("read_tags", "阅读标签"),
|
|
51
|
+
("content_tags", "内容标签"), ("protagonist_1", "主角名1"),
|
|
52
|
+
("protagonist_2", "主角名2"), ("summary", "作品简介"),
|
|
53
|
+
],
|
|
54
|
+
"qimao": [
|
|
55
|
+
("book_name", "作品名称"), ("target_reader", "目标读者"),
|
|
56
|
+
("category_main", "一级分类"), ("category_sub", "二级分类"),
|
|
57
|
+
("tags", "作品标签"), ("protagonist_1", "主角名1"),
|
|
58
|
+
("protagonist_2", "主角名2"), ("status", "作品状态"),
|
|
59
|
+
("summary", "作品简介"),
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
PROMPT_HEAD = """你是网文编辑,熟悉 __PLATFORM__ 建书表单的填法。
|
|
64
|
+
|
|
65
|
+
__SKILLS__
|
|
66
|
+
请依据下方的小说素材(目标/故事圣经/大纲/已有章节开头),为这本书生成一套
|
|
67
|
+
「作品信息」,直接可用于在 __PLATFORM__ 创建作品。只输出一个 ```json 代码块,
|
|
68
|
+
不要输出其他内容。JSON 结构:
|
|
69
|
+
__SCHEMA__
|
|
70
|
+
硬性要求:
|
|
71
|
+
- 字段值必须贴合素材内容,不编造素材里没有的设定;简介 50-500 字,突出卖点与钩子;
|
|
72
|
+
- 标签选平台热门且与题材匹配的,不要堆砌;
|
|
73
|
+
- 题材健康,无违规内容,符合平台签约调性。
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
FANQIE_SCHEMA = ('{"book_name": "作品名(15字内)", "signing_mode": "连载模式 或 完本模式", '
|
|
77
|
+
'"target_reader": "男频 或 女频", "read_tags": ["阅读标签", …最多6个], '
|
|
78
|
+
'"content_tags": ["内容标签", …最多6个], "protagonist_1": "主角名1(5字内)", '
|
|
79
|
+
'"protagonist_2": "主角名2(5字内,没有就空串)", "summary": "作品简介(50-500字)"}')
|
|
80
|
+
|
|
81
|
+
QIMAO_SCHEMA = ('{"book_name": "作品名称(18字内)", "target_reader": "男生 或 女生", '
|
|
82
|
+
'"category_main": "一级分类", "category_sub": "二级分类", '
|
|
83
|
+
'"tags": ["作品标签", …最多6个], "protagonist_1": "主角名1(5字内)", '
|
|
84
|
+
'"protagonist_2": "主角名2(5字内,没有就空串)", "status": "连载中 或 已完结", '
|
|
85
|
+
'"summary": "作品简介(50-500字)"}')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collect_material(task):
|
|
89
|
+
"""开书资料素材:目标 + 附件上下文 + 故事圣经 + 最近一次大纲 + 第一章开头。"""
|
|
90
|
+
from . import store # 惰性导入,同 planner:避免测试环境导入顺序问题
|
|
91
|
+
parts = ["## 小说目标\n" + (task.get("goal") or "(无)")]
|
|
92
|
+
ctx = (task.get("context") or "").strip()
|
|
93
|
+
if ctx:
|
|
94
|
+
parts.append("## 背景与上下文\n" + ctx[:2000])
|
|
95
|
+
_, bible, _ = store.read_story_bible(task["id"])
|
|
96
|
+
if bible:
|
|
97
|
+
parts.append("## 故事圣经\n" + bible[:6000])
|
|
98
|
+
outline = None
|
|
99
|
+
for r in store.task_runs(task["id"]):
|
|
100
|
+
o = r.get("outline")
|
|
101
|
+
if o and o.get("chapters") and not o.get("degraded"):
|
|
102
|
+
outline = o
|
|
103
|
+
break
|
|
104
|
+
if outline:
|
|
105
|
+
lines = ["书名:%s" % (outline.get("book_title") or "(未定)")]
|
|
106
|
+
for k, c in enumerate(outline["chapters"]):
|
|
107
|
+
lines.append("第 %d 章《%s》:%s" % (k + 1, c.get("title", ""),
|
|
108
|
+
str(c.get("beats") or "")[:120]))
|
|
109
|
+
parts.append("## 已有大纲\n" + "\n".join(lines))
|
|
110
|
+
head = ""
|
|
111
|
+
try:
|
|
112
|
+
p = Path(task.get("workdir") or "") / "chapter-001.md"
|
|
113
|
+
if p.is_file():
|
|
114
|
+
from . import runner
|
|
115
|
+
head = runner.read_text_any_enc(p)[:_FIRST_CH_HEAD]
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
if head:
|
|
119
|
+
parts.append("## 第一章开头\n" + head)
|
|
120
|
+
return "\n\n".join(parts), outline
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _tags(v, limit=6):
|
|
124
|
+
if not isinstance(v, list):
|
|
125
|
+
return []
|
|
126
|
+
out = []
|
|
127
|
+
for x in v:
|
|
128
|
+
s = str(x).strip()[:12]
|
|
129
|
+
if s and s not in out:
|
|
130
|
+
out.append(s)
|
|
131
|
+
if len(out) >= limit:
|
|
132
|
+
break
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _name(v, limit):
|
|
137
|
+
return str(v or "").strip().replace("\n", " ")[:limit]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _summary(v):
|
|
141
|
+
return str(v or "").strip()[:500]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _norm(data, platform, goal=""):
|
|
145
|
+
"""规范化平台字段输出;不合规返回 None。"""
|
|
146
|
+
if not isinstance(data, dict):
|
|
147
|
+
return None
|
|
148
|
+
book_name = _name(data.get("book_name"), 15 if platform == "fanqie" else 18)
|
|
149
|
+
summary = _summary(data.get("summary"))
|
|
150
|
+
if not book_name and not summary:
|
|
151
|
+
return None
|
|
152
|
+
if platform == "fanqie":
|
|
153
|
+
reader = str(data.get("target_reader") or "").strip()
|
|
154
|
+
if reader not in ("男频", "女频"):
|
|
155
|
+
reader = "女频" if "女" in (goal or "") else "男频"
|
|
156
|
+
mode = str(data.get("signing_mode") or "").strip()
|
|
157
|
+
if mode not in ("连载模式", "完本模式"):
|
|
158
|
+
mode = "连载模式"
|
|
159
|
+
return {"book_name": book_name, "signing_mode": mode, "target_reader": reader,
|
|
160
|
+
"read_tags": _tags(data.get("read_tags")),
|
|
161
|
+
"content_tags": _tags(data.get("content_tags")),
|
|
162
|
+
"protagonist_1": _name(data.get("protagonist_1"), 5),
|
|
163
|
+
"protagonist_2": _name(data.get("protagonist_2"), 5),
|
|
164
|
+
"summary": summary}
|
|
165
|
+
reader = str(data.get("target_reader") or "").strip()
|
|
166
|
+
if reader not in ("男生", "女生"):
|
|
167
|
+
reader = "女生" if "女" in (goal or "") else "男生"
|
|
168
|
+
status = str(data.get("status") or "").strip()
|
|
169
|
+
if status not in ("连载中", "已完结"):
|
|
170
|
+
status = "连载中"
|
|
171
|
+
return {"book_name": book_name, "target_reader": reader,
|
|
172
|
+
"category_main": _name(data.get("category_main"), 10),
|
|
173
|
+
"category_sub": _name(data.get("category_sub"), 10),
|
|
174
|
+
"tags": _tags(data.get("tags")),
|
|
175
|
+
"protagonist_1": _name(data.get("protagonist_1"), 5),
|
|
176
|
+
"protagonist_2": _name(data.get("protagonist_2"), 5),
|
|
177
|
+
"status": status, "summary": summary}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _template_meta(task, platform, outline):
|
|
181
|
+
"""兜底模板:只做素材搬运归纳(书名/简介来自大纲与目标),其余留空待补。"""
|
|
182
|
+
goal = task.get("goal") or ""
|
|
183
|
+
book_name = str((outline or {}).get("book_title") or task.get("title") or "").strip()
|
|
184
|
+
summary = goal.splitlines()[0][:500] if goal.strip() else ""
|
|
185
|
+
if not summary:
|
|
186
|
+
summary = book_name or "(待补充)" # 全空素材也要有返回,_norm 才不会拒绝
|
|
187
|
+
return _norm({"book_name": book_name, "summary": summary}, platform, goal)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _resolve_author(task):
|
|
191
|
+
"""作者 CLI 兜底用:按「实现」角色选一个 real 智能体(同连载起草的路由口径)。"""
|
|
192
|
+
try:
|
|
193
|
+
from . import catalog, manager, registry, router
|
|
194
|
+
agents = registry.effective_agents(catalog.load(), manager.detect_all())
|
|
195
|
+
agent, _ = router.pick([a for a in agents if a.get("mode") == "real"],
|
|
196
|
+
"implement", task.get("type") or "serial_novel")
|
|
197
|
+
return agent
|
|
198
|
+
except Exception:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def make_book_meta(task, platform, author_agent=None, log_path=None):
|
|
203
|
+
"""生成一个平台的作品信息。返回 (meta dict, source 字符串);模板兜底永远有返回。"""
|
|
204
|
+
from . import modelhub, planner, runner, skills # 惰性导入,同 planner
|
|
205
|
+
material, outline = _collect_material(task)
|
|
206
|
+
schema = {"fanqie": FANQIE_SCHEMA, "qimao": QIMAO_SCHEMA}.get(platform) or ""
|
|
207
|
+
plat_label = PLATFORMS[platform]["label"]
|
|
208
|
+
sk_block, _ = skills.block_for(task)
|
|
209
|
+
prompt = (PROMPT_HEAD.replace("__PLATFORM__", plat_label)
|
|
210
|
+
.replace("__SKILLS__", sk_block)
|
|
211
|
+
.replace("__SCHEMA__", schema) + "\n\n## 小说素材\n" + material)
|
|
212
|
+
goal = task.get("goal") or ""
|
|
213
|
+
|
|
214
|
+
orch = None
|
|
215
|
+
if not template_only():
|
|
216
|
+
try:
|
|
217
|
+
orch = modelhub.resolve_orchestrator()
|
|
218
|
+
except Exception:
|
|
219
|
+
orch = None
|
|
220
|
+
orch_err = ""
|
|
221
|
+
if orch:
|
|
222
|
+
prov, model = orch
|
|
223
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
|
|
224
|
+
planner._log_usage("bookmeta", "bookmeta", task, res, model=model,
|
|
225
|
+
provider=prov.get("name", prov.get("id", "")),
|
|
226
|
+
provider_id=prov.get("id", ""))
|
|
227
|
+
if res["ok"]:
|
|
228
|
+
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
229
|
+
if meta:
|
|
230
|
+
return meta, "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
|
|
231
|
+
orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
|
|
232
|
+
|
|
233
|
+
if author_agent and author_agent.get("mode") == "real" and not template_only():
|
|
234
|
+
res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
|
|
235
|
+
workdir=task.get("workdir"), readonly=True,
|
|
236
|
+
timeout=BOOKMETA_TIMEOUT, log_path=log_path)
|
|
237
|
+
planner._log_usage("bookmeta", "bookmeta", task, res, agent=author_agent)
|
|
238
|
+
if res["ok"]:
|
|
239
|
+
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
240
|
+
if meta:
|
|
241
|
+
return meta, "llm(%s)" % author_agent["id"]
|
|
242
|
+
orch_err = orch_err or str(res.get("error") or "")[:200]
|
|
243
|
+
|
|
244
|
+
meta = _template_meta(task, platform, outline)
|
|
245
|
+
meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
|
|
246
|
+
return meta, meta["source"]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def render_markdown(task, platform, meta):
|
|
250
|
+
"""落工作目录的归档 Markdown(与前端弹框同一份字段顺序)。"""
|
|
251
|
+
plat = PLATFORMS[platform]["label"]
|
|
252
|
+
lines = ["# 作品信息(%s)" % plat, "",
|
|
253
|
+
"> 任务:%s 生成于 %s 来源:%s" % (
|
|
254
|
+
task.get("title") or task.get("id") or "",
|
|
255
|
+
time.strftime("%Y-%m-%d %H:%M"), meta.get("source") or "")]
|
|
256
|
+
lines.append("")
|
|
257
|
+
for key, label in FIELD_LABELS[platform]:
|
|
258
|
+
v = meta.get(key)
|
|
259
|
+
if isinstance(v, list):
|
|
260
|
+
v = "、".join(v) if v else "(待补充)"
|
|
261
|
+
elif key == "summary":
|
|
262
|
+
v = (v or "").strip() or "(待补充)"
|
|
263
|
+
else:
|
|
264
|
+
v = str(v or "").strip() or "(待补充)"
|
|
265
|
+
lines.append("- **%s**:%s" % (label, v))
|
|
266
|
+
lines.append("")
|
|
267
|
+
lines.append("> 各字段可直接复制进 %s 建书表单;简介请保持 50-500 字。" % plat)
|
|
268
|
+
return "\n".join(lines)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def generate_async(task_id, platform, author_agent=None):
|
|
272
|
+
"""后台线程入口:跑生成并把终态写回任务 book_meta;成功时落工作目录归档。
|
|
273
|
+
|
|
274
|
+
状态机:running(路由置入)→ done / failed。任何异常都落 failed,不留悬挂。"""
|
|
275
|
+
from . import store
|
|
276
|
+
task = store.get_task(task_id)
|
|
277
|
+
if not task:
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
meta, source = make_book_meta(task, platform, author_agent=author_agent)
|
|
281
|
+
entry = {"status": "done", "data": meta, "source": source,
|
|
282
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
283
|
+
fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
|
|
284
|
+
if str(fp.parent) and fp.parent.is_dir():
|
|
285
|
+
fp.write_text(render_markdown(task, platform, meta), encoding="utf-8")
|
|
286
|
+
entry["file"] = PLATFORMS[platform]["file"]
|
|
287
|
+
except Exception as e: # 兜底线程不能静默死掉
|
|
288
|
+
entry = {"status": "failed", "error": str(e)[:300],
|
|
289
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
290
|
+
cur = store.get_task(task_id)
|
|
291
|
+
if not cur:
|
|
292
|
+
return
|
|
293
|
+
prev = (cur.get("book_meta") or {}).get(platform) or {}
|
|
294
|
+
if prev.get("status") != "running": # 期间被删/被重置:丢弃结果
|
|
295
|
+
return
|
|
296
|
+
store.set_book_meta(task_id, platform, entry)
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
# 能力维度(先固化清单,schema 见设计稿)
|
|
17
|
+
DIMS = ("writing", "coding", "reasoning", "vision")
|
|
18
|
+
|
|
19
|
+
# 关键词分类表(覆盖 task type / 目标文本;可按需扩充)
|
|
20
|
+
_KEYWORDS = {
|
|
21
|
+
"writing": ("写", "文案", "大纲", "连载", "章节", "小说", "营销", "总结", "润色",
|
|
22
|
+
"novel", "draft", "review"),
|
|
23
|
+
"coding": ("代码", "实现", "修复", "重构", "测试", "bug", "code", "fix",
|
|
24
|
+
"refactor", "test", "编译", "报错"),
|
|
25
|
+
"reasoning": ("推理", "分析", "调研", "对比", "规划", "拆解", "评审",
|
|
26
|
+
"reason", "analyze", "plan", "orchestrate"),
|
|
27
|
+
"vision": ("图", "图片", "识别", "视觉", "ocr", "image", "vision", "截图"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def classify_task_type(task) -> str:
|
|
32
|
+
"""把任务分类到 DIMS 之一。task 显式带 task_type 时优先。"""
|
|
33
|
+
explicit = str((task or {}).get("task_type") or "").strip().lower()
|
|
34
|
+
if explicit in DIMS:
|
|
35
|
+
return explicit
|
|
36
|
+
text = "%s %s" % ((task or {}).get("type") or "",
|
|
37
|
+
(task or {}).get("goal") or "")
|
|
38
|
+
scores = {}
|
|
39
|
+
for dim, kws in _KEYWORDS.items():
|
|
40
|
+
scores[dim] = sum(1 for kw in kws if kw in text)
|
|
41
|
+
best = max(scores, key=lambda d: scores[d])
|
|
42
|
+
if scores[best] > 0:
|
|
43
|
+
return best
|
|
44
|
+
return "coding" # 默认兜底(CodeBee 主场景)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def provider_strengths(prov):
|
|
48
|
+
"""读供应商声明的能力维度(容错:非法值忽略)。"""
|
|
49
|
+
raw = (prov or {}).get("strengths")
|
|
50
|
+
if not isinstance(raw, list):
|
|
51
|
+
return []
|
|
52
|
+
return [s for s in raw if s in DIMS]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pick_by_strength(providers, task_type, *, enabled_only=True):
|
|
56
|
+
"""按能力维度挑供应商。
|
|
57
|
+
|
|
58
|
+
规则:
|
|
59
|
+
1. 声明了该维度的可用供应商优先(保持原有顺序);
|
|
60
|
+
2. 未声明的排后面(兜底);
|
|
61
|
+
3. 都没有声明时返回原顺序(行为同现状)。
|
|
62
|
+
返回 (排序后的供应商列表, 分类依据说明)。
|
|
63
|
+
"""
|
|
64
|
+
task_type = task_type if task_type in DIMS else classify_task_type({"goal": task_type})
|
|
65
|
+
pool = list(providers or [])
|
|
66
|
+
if enabled_only:
|
|
67
|
+
pool = [p for p in pool if p.get("enabled", True)]
|
|
68
|
+
strong = [p for p in pool if task_type in provider_strengths(p)]
|
|
69
|
+
rest = [p for p in pool if p not in strong]
|
|
70
|
+
ranked = strong + rest
|
|
71
|
+
reason = "%s:优先 %s" % (
|
|
72
|
+
task_type,
|
|
73
|
+
"、".join(p.get("id", "?") for p in strong) if strong else "(无声明,按原序兜底)")
|
|
74
|
+
return ranked, reason
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_binding_by_task(task, providers, bindings):
|
|
78
|
+
"""任务 → 绑定解析(能力维度优先,回落 default)。
|
|
79
|
+
|
|
80
|
+
bindings 形如 modelhub 的 {"writing": {...}, "coding": {...}, "default": {...}};
|
|
81
|
+
对应维度的绑定缺失/为空时回落 default。返回 (binding, task_type)。
|
|
82
|
+
"""
|
|
83
|
+
tt = classify_task_type(task)
|
|
84
|
+
b = (bindings or {}).get(tt)
|
|
85
|
+
if not b:
|
|
86
|
+
b = (bindings or {}).get("default")
|
|
87
|
+
return b, tt
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- §07 T3.1 级联
|
|
91
|
+
|
|
92
|
+
TIER_ORDER = {"budget": 0, "standard": 1, "premium": 2}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cascade_reorder(agent, tier_of):
|
|
96
|
+
"""FrugalGPT 式便宜优先:easy 任务把 call_chain 按 tier 升序稳定重排。
|
|
97
|
+
|
|
98
|
+
agent 来自 modelhub.bind_agent(可能带 call_chain);tier_of(chain_entry)
|
|
99
|
+
返回该条目的档位串("budget"/"standard"/"premium"或 None=standard)。
|
|
100
|
+
稳定排序保证同档内保持用户配置的原顺序。链长 <2 或非链式原样返回。
|
|
101
|
+
"""
|
|
102
|
+
chain = (agent or {}).get("call_chain") or []
|
|
103
|
+
if len(chain) < 2:
|
|
104
|
+
return agent
|
|
105
|
+
|
|
106
|
+
def key(entry):
|
|
107
|
+
return TIER_ORDER.get(tier_of(entry) or "standard", 1)
|
|
108
|
+
|
|
109
|
+
out = dict(agent)
|
|
110
|
+
out["call_chain"] = sorted(chain, key=key)
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def make_tier_lookup(providers):
|
|
115
|
+
"""由 providers 列表构造 chain_entry → tier 的查询函数。
|
|
116
|
+
|
|
117
|
+
tier 来源(按优先级):链条目 model 在供应商 models[] 里的 tier 字段 →
|
|
118
|
+
供应商级 tier → None(按 standard 兜底)。缺什么都不抛。
|
|
119
|
+
"""
|
|
120
|
+
by_id = {p.get("id"): p for p in (providers or []) if p.get("id")}
|
|
121
|
+
|
|
122
|
+
def tier_of(entry):
|
|
123
|
+
p = by_id.get((entry or {}).get("provider_id") or "")
|
|
124
|
+
if not p:
|
|
125
|
+
return None
|
|
126
|
+
for m in (p.get("models") or []):
|
|
127
|
+
if isinstance(m, dict) and m.get("name") == (entry or {}).get("model"):
|
|
128
|
+
return m.get("tier") or p.get("tier")
|
|
129
|
+
return p.get("tier")
|
|
130
|
+
return tier_of
|