codebee 0.1.63 → 0.1.64
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 +8 -0
- package/README.md +5 -2
- package/app/core/artifacts.py +148 -0
- package/app/core/bookmeta.py +215 -19
- package/app/core/modelhub.py +76 -10
- package/app/core/pipeline.py +42 -3
- package/app/core/runner.py +8 -3
- package/app/core/store.py +13 -70
- package/app/core/zentao.py +386 -29
- package/app/main.py +8 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,14 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
6
6
|
|
|
7
7
|
## 未发布
|
|
8
8
|
|
|
9
|
+
- **禅道失败回写三连修(#27783 案)**:修复任务失败若是 CodeBee 自身崩溃(运行错误为内部异常形态,如 0.1.63 的 `_TRANSIENT` NameError),不再转派端负责人——只评论+群通知人工,评论注明「工具自身异常,不代表修复结论」;只评论(未配负责人/内部崩溃)时改为先取当前指派人再回写——禅道编辑接口缺 assignedTo 会清空指派,杜绝「指派给(空)」记录;负责人未配置的失败认领备注点名未转派原因。
|
|
10
|
+
|
|
11
|
+
## v0.1.64(2026-09-24)
|
|
12
|
+
|
|
13
|
+
- 禅道看图排查自动降级链:Bug 截图进排查与修复链,编排模型不会看图就自动换将并落标记,不再甩人工处理
|
|
14
|
+
- 排查读图守卫:读不到图留人工判断,不盲判定论(#27754 案)
|
|
15
|
+
- runner 瞬态错误热回退修复
|
|
16
|
+
|
|
9
17
|
## v0.1.63(2026-09-24)
|
|
10
18
|
|
|
11
19
|
- 0.1.62 被并行发布占位(旧 HEAD,不含 runner 修复);本版为完整版
|
package/README.md
CHANGED
|
@@ -21,9 +21,12 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
<!-- relnotes:start -->
|
|
25
|
+
### 最新版更新内容(v0.1.64)
|
|
25
26
|
|
|
26
|
-
-
|
|
27
|
+
- 禅道 Bug 截图自动进排查/修复链:模型不会看图就自动换将,读不到留人工不盲判
|
|
28
|
+
- runner 瞬态错误热回退修复
|
|
29
|
+
<!-- relnotes:end -->
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
---
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""工作目录成品扫描:剪枝遍历 + 短 TTL 缓存 + 在飞合并。
|
|
2
|
+
|
|
3
|
+
历史实现用 Path.rglob("*") 全量展开再逐文件 is_file()/stat() 过滤,几万文件的
|
|
4
|
+
工作目录单次要 1.6~4 秒;而详情页/侧栏在任务运行中是轮询调用——每次轮询都是
|
|
5
|
+
一次全仓扫描,UI 整体被拖卡(2026-09-24 详情页卡顿案)。这里改为 os.walk
|
|
6
|
+
剪枝(跳过类目录整个不进入),并给重复轮询加 (workdir, t0, 任务状态) 键的短 TTL 缓存
|
|
7
|
+
与在飞合并:并发同键只扫一次,后到者等结果。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
# 过滤语义与历史 rglob 版逐条对齐:
|
|
16
|
+
# - SKIP_DIRS:任意路径段(含文件名本身)命中即剔除;
|
|
17
|
+
# - BUILD_DIRS:仅祖先目录命中即剔除(文件名叫 dist 不受影响);
|
|
18
|
+
# - 点开头:仅祖先目录算「隐藏」(根下的 .gitignore 这类隐藏文件仍是候选)。
|
|
19
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
|
|
20
|
+
|
|
21
|
+
# 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
|
|
22
|
+
BUILD_DIRS = {
|
|
23
|
+
"target", "build", "dist", "out", "bin", "obj", # 通用构建输出
|
|
24
|
+
"surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
|
|
25
|
+
".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
|
|
26
|
+
"__MACOSX",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
|
|
30
|
+
# 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
|
|
31
|
+
_PROCESS_ARTIFACT_NAMES = {
|
|
32
|
+
".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
|
|
33
|
+
".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
|
|
34
|
+
}
|
|
35
|
+
_PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
|
|
36
|
+
_PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
|
|
37
|
+
|
|
38
|
+
SCAN_CAP = 800 # 防超大目录拖垮接口;截断后再排序取最新
|
|
39
|
+
CACHE_TTL_RUNNING = 2.0 # 任务在跑:轮询热,新鲜期要短
|
|
40
|
+
CACHE_TTL_IDLE = 10.0 # 终态任务:详情反复开关/检查器同刷,可稍长
|
|
41
|
+
_CACHE_KEEP = 128 # 缓存条目上限(按时间淘汰最旧)
|
|
42
|
+
|
|
43
|
+
_now = time.monotonic # 模块级别名,测试可替换时钟
|
|
44
|
+
|
|
45
|
+
_LOCK = threading.Lock()
|
|
46
|
+
_CACHE = {} # (root, t0, running) -> (时间戳, files);files 恒为全量候选
|
|
47
|
+
_INFLIGHT = {} # (root, t0, running) -> Lock;同键并发只跑一次扫描
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_process_artifact(rel_name):
|
|
51
|
+
"""判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
|
|
52
|
+
name = str(rel_name or "").replace("\\", "/")
|
|
53
|
+
base = name.rsplit("/", 1)[-1].lower()
|
|
54
|
+
if base in _PROCESS_ARTIFACT_NAMES:
|
|
55
|
+
return True
|
|
56
|
+
if base.startswith(".") and base.startswith(".aider"):
|
|
57
|
+
return True
|
|
58
|
+
if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
|
|
59
|
+
return True
|
|
60
|
+
if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _walk_files(root, t0):
|
|
66
|
+
"""剪枝遍历:跳过类目录整个不进入;返回 mtime>=t0 的候选(无序)。"""
|
|
67
|
+
files = []
|
|
68
|
+
for dp, dirnames, filenames in os.walk(root):
|
|
69
|
+
dirnames[:] = [d for d in dirnames
|
|
70
|
+
if d not in SKIP_DIRS and d not in BUILD_DIRS
|
|
71
|
+
and not d.startswith(".")]
|
|
72
|
+
done = False
|
|
73
|
+
for name in filenames:
|
|
74
|
+
if name in SKIP_DIRS: # 历史语义:文件名本身命中也剔除
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
st = os.stat(os.path.join(dp, name))
|
|
78
|
+
except OSError:
|
|
79
|
+
continue
|
|
80
|
+
if st.st_mtime < t0:
|
|
81
|
+
continue
|
|
82
|
+
rel_dir = os.path.relpath(dp, root)
|
|
83
|
+
rel = name if rel_dir == "." else \
|
|
84
|
+
rel_dir.replace("\\", "/") + "/" + name
|
|
85
|
+
if _is_process_artifact(rel):
|
|
86
|
+
continue
|
|
87
|
+
files.append({"name": rel,
|
|
88
|
+
"size": st.st_size, "mtime": int(st.st_mtime)})
|
|
89
|
+
if len(files) >= SCAN_CAP:
|
|
90
|
+
done = True
|
|
91
|
+
break
|
|
92
|
+
if done:
|
|
93
|
+
break
|
|
94
|
+
return files
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cache_get(key, ttl):
|
|
98
|
+
with _LOCK:
|
|
99
|
+
ent = _CACHE.get(key)
|
|
100
|
+
if ent and _now() - ent[0] < ttl:
|
|
101
|
+
return ent[1]
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cache_put(key, files):
|
|
106
|
+
with _LOCK:
|
|
107
|
+
_CACHE[key] = (_now(), files)
|
|
108
|
+
while len(_CACHE) > _CACHE_KEEP:
|
|
109
|
+
_CACHE.pop(min(_CACHE, key=lambda k: _CACHE[k][0]), None)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _inflight_lock(key):
|
|
113
|
+
with _LOCK:
|
|
114
|
+
lock = _INFLIGHT.get(key)
|
|
115
|
+
if lock is None:
|
|
116
|
+
lock = _INFLIGHT[key] = threading.Lock()
|
|
117
|
+
return lock
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def scan(root, t0, limit=200, running=False, cache=False):
|
|
121
|
+
"""扫工作目录成品,按 mtime 新→旧取前 limit 条,返回文件字典列表。
|
|
122
|
+
|
|
123
|
+
cache=True 时同键 (root, t0, running) 在 TTL 内直接复用上次结果,运行中
|
|
124
|
+
任务用短 TTL;无论是否缓存,同键在飞扫描都会合并——轮询方只会等一次扫描。
|
|
125
|
+
"""
|
|
126
|
+
# 运行态和终态必须分开缓存:任务刚结束时不能沿用运行中缓存里的旧
|
|
127
|
+
# 文件列表,否则终态较长 TTL 会把最后写入的产物遮住。
|
|
128
|
+
key = (str(root), t0, bool(running))
|
|
129
|
+
ttl = CACHE_TTL_RUNNING if running else CACHE_TTL_IDLE
|
|
130
|
+
if cache:
|
|
131
|
+
hit = _cache_get(key, ttl)
|
|
132
|
+
if hit is not None:
|
|
133
|
+
return hit[:limit]
|
|
134
|
+
klock = _inflight_lock(key)
|
|
135
|
+
try:
|
|
136
|
+
with klock:
|
|
137
|
+
if cache: # 排到锁时他人可能已扫完入缓存
|
|
138
|
+
hit = _cache_get(key, ttl)
|
|
139
|
+
if hit is not None:
|
|
140
|
+
return hit[:limit]
|
|
141
|
+
files = sorted(_walk_files(root, t0), key=lambda f: -f["mtime"])
|
|
142
|
+
if cache:
|
|
143
|
+
_cache_put(key, files)
|
|
144
|
+
finally:
|
|
145
|
+
with _LOCK:
|
|
146
|
+
if _INFLIGHT.get(key) is klock:
|
|
147
|
+
_INFLIGHT.pop(key, None)
|
|
148
|
+
return files[:limit]
|
package/app/core/bookmeta.py
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
用户在详情页「成果」分区点按钮触发(不做自动生成),后台线程调编排者模型
|
|
5
5
|
提炼 大纲 + 故事圣经 + 已有章节 的精华,按平台表单字段归一化后存任务
|
|
6
6
|
book_meta 字段,同时在工作目录落一份 Markdown 归档。降级链与大纲一致:
|
|
7
|
-
编排者 API → 作者 CLI →
|
|
7
|
+
编排者 API → 作者 CLI → 模板兜底(模板只做搬运归纳,必填字段仍保证可建书)。
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
import os
|
|
12
|
-
import
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
PLATFORMS = {
|
|
@@ -25,7 +26,8 @@ def template_only():
|
|
|
25
26
|
"""只走模板兜底(跳过编排者/作者 CLI):env TUTTI_BOOKMETA_TEMPLATE_ONLY=1。
|
|
26
27
|
|
|
27
28
|
离线环境、不想为开书资料再花一次模型调用、以及测试要确定性输出时使用;
|
|
28
|
-
|
|
29
|
+
产出仍是结构完整的字段,书名/简介直接取大纲与目标,平台必填字段由生成阶段
|
|
30
|
+
的官方目录兜底补齐。"""
|
|
29
31
|
return str(os.environ.get("TUTTI_BOOKMETA_TEMPLATE_ONLY") or "").strip() in ("1", "true", "yes")
|
|
30
32
|
|
|
31
33
|
|
|
@@ -376,7 +378,7 @@ def _material_text(task, outline):
|
|
|
376
378
|
return material
|
|
377
379
|
|
|
378
380
|
|
|
379
|
-
def _fill_tag_gaps(task, platform, meta, outline):
|
|
381
|
+
def _fill_tag_gaps(task, platform, meta, outline):
|
|
380
382
|
"""llm 归一化后仍为空的标签组:用素材词面扫描补一轮(命中多少算多少)。
|
|
381
383
|
|
|
382
384
|
模型偶尔整组漏给(实测 codex-cli 只填了内容标签前两组);空着用户就得
|
|
@@ -400,7 +402,137 @@ def _fill_tag_gaps(task, platform, meta, outline):
|
|
|
400
402
|
for key, table, limit in pairs:
|
|
401
403
|
if not meta.get(key):
|
|
402
404
|
meta[key] = _scan_table(material, table, limit)
|
|
403
|
-
return meta
|
|
405
|
+
return meta
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _material_names(material):
|
|
409
|
+
"""从圣经/大纲/章节里提取明确标注的人物名。
|
|
410
|
+
|
|
411
|
+
只接受带人物语义的短语(如“女主:阿禾”或“男主叫陆沉”),避免把
|
|
412
|
+
普通句子里的名词误当成主角。返回顺序去重后的最多两个名字。
|
|
413
|
+
"""
|
|
414
|
+
text = str(material or "")
|
|
415
|
+
out = []
|
|
416
|
+
patterns = (
|
|
417
|
+
r"(?:主角|主人公|男主(?:角)?|女主(?:角)?|男一|女一|姓名|名字)"
|
|
418
|
+
r"\s*(?:是|叫|为|:|:)\s*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z·]{1,7})",
|
|
419
|
+
r"人物\s*[::]\s*([^\n。;;]{2,40})",
|
|
420
|
+
)
|
|
421
|
+
for pat in patterns:
|
|
422
|
+
for m in re.finditer(pat, text):
|
|
423
|
+
raw = m.group(1)
|
|
424
|
+
chunks = re.split(r"[、,,/和及与]|\s+", raw)
|
|
425
|
+
for chunk in chunks:
|
|
426
|
+
name = re.sub(r"[^\u4e00-\u9fffA-Za-z·]", "", chunk)
|
|
427
|
+
if 2 <= len(name) <= 8 and name not in out:
|
|
428
|
+
out.append(name[:5])
|
|
429
|
+
if len(out) >= 2:
|
|
430
|
+
return out
|
|
431
|
+
return out
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _pick_option(material, table, keywords=()):
|
|
435
|
+
"""从官方选项表中按素材命中词选择一个,完全命中不到时用首项。"""
|
|
436
|
+
text = str(material or "")
|
|
437
|
+
for item in table:
|
|
438
|
+
if len(item) >= 2 and item in text:
|
|
439
|
+
return item
|
|
440
|
+
for key, item in keywords:
|
|
441
|
+
if key and key in text and item in table:
|
|
442
|
+
return item
|
|
443
|
+
return table[0] if table else ""
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _has_value(value):
|
|
447
|
+
"""判断建书字段是否真正有值(兼容列表和历史占位文本)。"""
|
|
448
|
+
placeholders = {"待补充", "(待补充)", "(待补充)"}
|
|
449
|
+
if isinstance(value, list):
|
|
450
|
+
return any((text := str(x or "").strip()) and text not in placeholders
|
|
451
|
+
for x in value)
|
|
452
|
+
text = str(value or "").strip()
|
|
453
|
+
return bool(text) and text not in placeholders
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _fill_required_fields(task, platform, meta, outline, material):
|
|
457
|
+
"""生成阶段的必填字段闸门。
|
|
458
|
+
|
|
459
|
+
``_norm`` 仍保持“表外值清空”的纯校验语义,供旧数据修复和单测使用;
|
|
460
|
+
这里仅在真正交付作品信息前补齐平台建书表单的必填项,避免 UI 展示一整
|
|
461
|
+
屏“待补充”而无法直接复制建书。所有分类/标签仍来自官方目录。
|
|
462
|
+
"""
|
|
463
|
+
from . import bookmeta_catalog as cat
|
|
464
|
+
|
|
465
|
+
material = str(material or "")
|
|
466
|
+
names = _material_names(material)
|
|
467
|
+
female_words = ("女频", "女生", "女主", "言情", "古言", "恋爱")
|
|
468
|
+
female = any(x in ((task.get("goal") or "") + material) for x in female_words)
|
|
469
|
+
defaults = ("沈知微", "顾砚川", "林知夏") if female else ("陆沉", "苏晚", "程野")
|
|
470
|
+
if not _has_value(meta.get("protagonist_1")):
|
|
471
|
+
meta["protagonist_1"] = names[0] if names else defaults[0]
|
|
472
|
+
if not _has_value(meta.get("protagonist_2")):
|
|
473
|
+
candidate = names[1] if len(names) > 1 else defaults[1]
|
|
474
|
+
if candidate == meta["protagonist_1"]:
|
|
475
|
+
candidate = next((name for name in defaults if name != meta["protagonist_1"]), defaults[2])
|
|
476
|
+
meta["protagonist_2"] = candidate
|
|
477
|
+
|
|
478
|
+
if platform == "fanqie":
|
|
479
|
+
reader = meta.get("target_reader") or ("女频" if female else "男频")
|
|
480
|
+
if reader not in ("男频", "女频"):
|
|
481
|
+
reader = "女频" if female else "男频"
|
|
482
|
+
meta["target_reader"] = reader
|
|
483
|
+
cats, theme, role, plot = _fq_tables(reader)
|
|
484
|
+
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
485
|
+
if meta.get("category") not in cats:
|
|
486
|
+
meta["category"] = _pick_option(
|
|
487
|
+
material, cats,
|
|
488
|
+
(("戍边", "古风世情"), ("边关", "古风世情"),
|
|
489
|
+
("军", "抗战谍战"), ("悬疑", "女频悬疑" if female else "悬疑脑洞"),
|
|
490
|
+
("种田", "种田"), ("都市", "都市脑洞")))
|
|
491
|
+
groups = (("tags_theme", theme, 2), ("tags_role", role, 2),
|
|
492
|
+
("tags_plot", plot, 2), ("content_plot", c_plot, 4),
|
|
493
|
+
("content_emotion", c_emo, 2), ("content_character", c_char, 4),
|
|
494
|
+
("content_world", c_world, 1))
|
|
495
|
+
else:
|
|
496
|
+
reader = meta.get("target_reader") or ("女生" if female else "男生")
|
|
497
|
+
if reader not in ("男生", "女生"):
|
|
498
|
+
reader = "女生" if female else "男生"
|
|
499
|
+
meta["target_reader"] = reader
|
|
500
|
+
channel = cat.QIMAO_CATS.get(reader) or cat.QIMAO_CATS["男生"]
|
|
501
|
+
main = meta.get("category_main")
|
|
502
|
+
if main not in channel:
|
|
503
|
+
main = _pick_option(
|
|
504
|
+
material, list(channel),
|
|
505
|
+
(("戍边", "古代言情"), ("边关", "古代言情"),
|
|
506
|
+
("古代", "古代言情"), ("军事", "军事"),
|
|
507
|
+
("军", "军事"), ("都市", "都市"),
|
|
508
|
+
("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")))
|
|
509
|
+
meta["category_main"] = main
|
|
510
|
+
subs = channel.get(main) or []
|
|
511
|
+
sub = meta.get("category_sub")
|
|
512
|
+
if sub not in subs:
|
|
513
|
+
located = cat.qimao_locate_sub(sub)
|
|
514
|
+
if located:
|
|
515
|
+
reader, main = located
|
|
516
|
+
meta["target_reader"] = reader
|
|
517
|
+
meta["category_main"] = main
|
|
518
|
+
subs = cat.QIMAO_CATS[reader][main]
|
|
519
|
+
else:
|
|
520
|
+
sub = ""
|
|
521
|
+
if sub not in subs:
|
|
522
|
+
meta["category_sub"] = _pick_option(material, subs)
|
|
523
|
+
groups = (("tags_style", cat.QIMAO_TAG_GROUPS["风格"], 3),
|
|
524
|
+
("tags_role", cat.QIMAO_TAG_GROUPS["角色"], 3),
|
|
525
|
+
("tags_plot", cat.QIMAO_TAG_GROUPS["情节"], 3),
|
|
526
|
+
("tags_bg", cat.QIMAO_TAG_GROUPS["背景"], 3))
|
|
527
|
+
for key, table, limit in groups:
|
|
528
|
+
values = meta.get(key)
|
|
529
|
+
if not isinstance(values, list):
|
|
530
|
+
values = []
|
|
531
|
+
values = [str(v).strip() for v in values if str(v or "").strip() in table]
|
|
532
|
+
if not values:
|
|
533
|
+
values = [_pick_option(material, table)]
|
|
534
|
+
meta[key] = values[:limit]
|
|
535
|
+
return meta
|
|
404
536
|
|
|
405
537
|
|
|
406
538
|
def store_read_bible(task):
|
|
@@ -455,11 +587,12 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
|
|
|
455
587
|
planner._log_usage("bookmeta", "bookmeta", task, res, model=model,
|
|
456
588
|
provider=prov.get("name", prov.get("id", "")),
|
|
457
589
|
provider_id=prov.get("id", ""))
|
|
458
|
-
if res["ok"]:
|
|
459
|
-
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
460
|
-
if meta:
|
|
461
|
-
|
|
462
|
-
|
|
590
|
+
if res["ok"]:
|
|
591
|
+
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
592
|
+
if meta:
|
|
593
|
+
meta = _fill_tag_gaps(task, platform, meta, outline)
|
|
594
|
+
return _fill_required_fields(task, platform, meta, outline, material), \
|
|
595
|
+
"编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
|
|
463
596
|
orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
|
|
464
597
|
|
|
465
598
|
if author_agent and author_agent.get("mode") == "real" and not template_only():
|
|
@@ -467,14 +600,16 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
|
|
|
467
600
|
workdir=task.get("workdir"), readonly=True,
|
|
468
601
|
timeout=BOOKMETA_TIMEOUT, log_path=log_path)
|
|
469
602
|
planner._log_usage("bookmeta", "bookmeta", task, res, agent=author_agent)
|
|
470
|
-
if res["ok"]:
|
|
471
|
-
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
472
|
-
if meta:
|
|
473
|
-
|
|
603
|
+
if res["ok"]:
|
|
604
|
+
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
605
|
+
if meta:
|
|
606
|
+
meta = _fill_tag_gaps(task, platform, meta, outline)
|
|
607
|
+
return _fill_required_fields(task, platform, meta, outline, material), "llm(%s)" % author_agent["id"]
|
|
474
608
|
orch_err = orch_err or str(res.get("error") or "")[:200]
|
|
475
609
|
|
|
476
|
-
meta = _template_meta(task, platform, outline)
|
|
477
|
-
meta
|
|
610
|
+
meta = _template_meta(task, platform, outline)
|
|
611
|
+
meta = _fill_required_fields(task, platform, meta, outline, material)
|
|
612
|
+
meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
|
|
478
613
|
return meta, meta["source"]
|
|
479
614
|
|
|
480
615
|
|
|
@@ -528,7 +663,7 @@ def generate_async(task_id, platform, author_agent=None):
|
|
|
528
663
|
store.set_book_meta(task_id, platform, entry)
|
|
529
664
|
|
|
530
665
|
|
|
531
|
-
def recover_orphans():
|
|
666
|
+
def recover_orphans():
|
|
532
667
|
"""启动收尸:后台生成线程随进程重启死掉后,落盘的 running 无人写终态——
|
|
533
668
|
前端永远「生成中」(按钮禁用)+ 接口幂等拒绝,卡死不可自愈。启动时把
|
|
534
669
|
遗留 running 统一改判 failed,交还「重试」按钮。返回改判条数。"""
|
|
@@ -542,4 +677,65 @@ def recover_orphans():
|
|
|
542
677
|
"error": "生成随服务重启中断,请点重试",
|
|
543
678
|
"at": time.strftime("%Y-%m-%d %H:%M:%S")})
|
|
544
679
|
n += 1
|
|
545
|
-
return n
|
|
680
|
+
return n
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def repair_existing():
|
|
684
|
+
"""补齐历史已生成记录中的必填字段。
|
|
685
|
+
|
|
686
|
+
旧版本允许分类/主角为空并把结果标成 done;只要字段缺失就按当前官方
|
|
687
|
+
目录和已有素材补一次,已有非空字段保持不变。返回修复条数,供启动日志
|
|
688
|
+
和升级诊断使用。
|
|
689
|
+
"""
|
|
690
|
+
from . import store
|
|
691
|
+
repaired = 0
|
|
692
|
+
for task in store.list_tasks(limit=10 ** 9):
|
|
693
|
+
book_meta = task.get("book_meta") or {}
|
|
694
|
+
for platform in PLATFORMS:
|
|
695
|
+
entry = book_meta.get(platform) or {}
|
|
696
|
+
if entry.get("status") != "done" or not isinstance(entry.get("data"), dict):
|
|
697
|
+
continue
|
|
698
|
+
data = dict(entry["data"])
|
|
699
|
+
if platform == "fanqie":
|
|
700
|
+
reader = data.get("target_reader")
|
|
701
|
+
cats, _, _, _ = _fq_tables(reader if reader in ("男频", "女频") else "男频")
|
|
702
|
+
required_ok = (
|
|
703
|
+
data.get("category") in cats
|
|
704
|
+
and all(_has_value(data.get(key)) for key in (
|
|
705
|
+
"tags_theme", "tags_role", "tags_plot", "content_plot",
|
|
706
|
+
"content_emotion", "content_character", "content_world"))
|
|
707
|
+
and _has_value(data.get("protagonist_1"))
|
|
708
|
+
and _has_value(data.get("protagonist_2")))
|
|
709
|
+
else:
|
|
710
|
+
from . import bookmeta_catalog as cat
|
|
711
|
+
reader = data.get("target_reader")
|
|
712
|
+
channel = cat.QIMAO_CATS.get(reader) or {}
|
|
713
|
+
main = data.get("category_main")
|
|
714
|
+
required_ok = (
|
|
715
|
+
main in channel
|
|
716
|
+
and data.get("category_sub") in (channel.get(main) or [])
|
|
717
|
+
and all(_has_value(data.get(key)) for key in (
|
|
718
|
+
"tags_style", "tags_role", "tags_plot", "tags_bg"))
|
|
719
|
+
and _has_value(data.get("protagonist_1"))
|
|
720
|
+
and _has_value(data.get("protagonist_2")))
|
|
721
|
+
if required_ok:
|
|
722
|
+
continue
|
|
723
|
+
try:
|
|
724
|
+
material, outline = _collect_material(task)
|
|
725
|
+
fixed = _fill_required_fields(task, platform, data, outline, material)
|
|
726
|
+
if fixed == entry["data"]:
|
|
727
|
+
continue
|
|
728
|
+
payload = dict(entry)
|
|
729
|
+
payload["data"] = fixed
|
|
730
|
+
payload["source"] = (str(entry.get("source") or "") + ";已补齐必填字段").lstrip(";")
|
|
731
|
+
store.set_book_meta(task["id"], platform, payload)
|
|
732
|
+
try:
|
|
733
|
+
fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
|
|
734
|
+
if fp.parent.is_dir():
|
|
735
|
+
fp.write_text(render_markdown(task, platform, fixed), encoding="utf-8")
|
|
736
|
+
except OSError:
|
|
737
|
+
pass
|
|
738
|
+
repaired += 1
|
|
739
|
+
except Exception:
|
|
740
|
+
continue
|
|
741
|
+
return repaired
|
package/app/core/modelhub.py
CHANGED
|
@@ -2037,6 +2037,35 @@ def _model_image_in(prov, model):
|
|
|
2037
2037
|
return False
|
|
2038
2038
|
|
|
2039
2039
|
|
|
2040
|
+
def vision_candidates(prefer_provider_id="", prefer_model=""):
|
|
2041
|
+
"""全库搜「能看图」的候选模型,供图类调用自动降级(编排模型不支持看图时
|
|
2042
|
+
自己换将,不甩人工)。返回有序 [(prov, model_name, inferred)]:
|
|
2043
|
+
声明 image_in 的在先;仅名字推断(_auto_image_in)的在后(inferred=True,
|
|
2044
|
+
调用方实测成功后应 set_model_caps 落标记)。编排者供应商排最前,
|
|
2045
|
+
编排模型本身若可看图排整个列表第一。"""
|
|
2046
|
+
out, seen = [], set()
|
|
2047
|
+
provs = [p for p in providers()
|
|
2048
|
+
if isinstance(p, dict) and p.get("enabled", True) and p.get("api_key")]
|
|
2049
|
+
provs.sort(key=lambda p: 0 if p.get("id") == prefer_provider_id else 1)
|
|
2050
|
+
for p in provs:
|
|
2051
|
+
for m in _enabled_models(p):
|
|
2052
|
+
name = str(m.get("name") or "")
|
|
2053
|
+
if not name or (p.get("id"), name) in seen:
|
|
2054
|
+
continue
|
|
2055
|
+
if bool(m.get("image_in")):
|
|
2056
|
+
inferred = False
|
|
2057
|
+
elif _auto_image_in(name):
|
|
2058
|
+
inferred = True
|
|
2059
|
+
else:
|
|
2060
|
+
continue
|
|
2061
|
+
seen.add((p.get("id"), name))
|
|
2062
|
+
out.append((p, name, inferred))
|
|
2063
|
+
if prefer_model:
|
|
2064
|
+
out.sort(key=lambda x: 0 if (x[0].get("id") == prefer_provider_id
|
|
2065
|
+
and x[1] == prefer_model) else 1)
|
|
2066
|
+
return out
|
|
2067
|
+
|
|
2068
|
+
|
|
2040
2069
|
def bind_agent(agent, difficulty="default", task_type="", role=""):
|
|
2041
2070
|
"""按绑定生成应用了供应商/模型覆盖的 agent 副本;无绑定时原样返回。
|
|
2042
2071
|
|
|
@@ -3096,32 +3125,68 @@ def resolve_orchestrator():
|
|
|
3096
3125
|
return prov, model
|
|
3097
3126
|
|
|
3098
3127
|
|
|
3099
|
-
def
|
|
3100
|
-
"""
|
|
3128
|
+
def _chat_user_content(proto, prompt, images):
|
|
3129
|
+
"""images=[(mime, b64)] 时构造多模态 user content;无图返回原样文本。
|
|
3130
|
+
|
|
3131
|
+
openai/anthropic 的 content 变数组块,google 走 parts;形状与
|
|
3132
|
+
builtin_agent 的对话消息一致(图只进 user 消息)。"""
|
|
3133
|
+
if not images:
|
|
3134
|
+
return [{"text": prompt}] if proto == "google" else prompt
|
|
3135
|
+
if proto == "google":
|
|
3136
|
+
parts = [{"text": prompt}]
|
|
3137
|
+
for mime, b64 in images:
|
|
3138
|
+
parts.append({"inline_data": {"mime_type": mime, "data": b64}})
|
|
3139
|
+
return parts
|
|
3140
|
+
blocks = [{"type": "text", "text": prompt}]
|
|
3141
|
+
for mime, b64 in images:
|
|
3142
|
+
if proto == "anthropic":
|
|
3143
|
+
blocks.append({"type": "image",
|
|
3144
|
+
"source": {"type": "base64", "media_type": mime,
|
|
3145
|
+
"data": b64}})
|
|
3146
|
+
else:
|
|
3147
|
+
blocks.append({"type": "image_url",
|
|
3148
|
+
"image_url": {"url": "data:%s;base64,%s" % (mime, b64)}})
|
|
3149
|
+
return blocks
|
|
3150
|
+
|
|
3151
|
+
|
|
3152
|
+
def _chat_cache_path(provider_id, model_name, prompt, max_tokens, reasoning_effort="",
|
|
3153
|
+
images=None):
|
|
3154
|
+
"""§07 T2.2:精确匹配响应缓存的落盘路径(只缓存 ok 的幂等调用)。
|
|
3155
|
+
images 参与键(同 prompt 不同图的响应可能不同)。"""
|
|
3101
3156
|
import hashlib as _h
|
|
3157
|
+
img_sig = ""
|
|
3158
|
+
if images:
|
|
3159
|
+
try:
|
|
3160
|
+
img_sig = _h.sha256("|".join("%s|%s" % (m, b) for m, b in images)
|
|
3161
|
+
.encode("utf-8")).hexdigest()[:16]
|
|
3162
|
+
except Exception:
|
|
3163
|
+
img_sig = "err"
|
|
3102
3164
|
key = "|".join([str(provider_id), str(model_name), str(max_tokens),
|
|
3103
|
-
str(reasoning_effort or ""), str(prompt)])
|
|
3165
|
+
str(reasoning_effort or ""), str(prompt), img_sig])
|
|
3104
3166
|
name = _h.sha256(key.encode("utf-8")).hexdigest()[:24]
|
|
3105
3167
|
return paths.DATA_DIR / "chat_cache" / (name + ".json")
|
|
3106
3168
|
|
|
3107
3169
|
|
|
3108
3170
|
def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_ttl=0,
|
|
3109
|
-
on_delta=None, reasoning_effort=""):
|
|
3171
|
+
on_delta=None, reasoning_effort="", images=None):
|
|
3110
3172
|
"""直连供应商 API 做一次对话(编排者规划 / 连通性测试)。
|
|
3111
3173
|
|
|
3112
3174
|
支持 anthropic / openai / google 三种协议;复用 SSRF 防护。
|
|
3175
|
+
images=[(mime, b64)] 时随 user 消息传图(三协议形状见 _chat_user_content;
|
|
3176
|
+
图只在非流式路径验证过,流式 on_delta 与图并用未验证)。
|
|
3113
3177
|
返回 {ok, text, tokens, usage, error};usage 为细分 {input, output, cached, reasoning, total}。
|
|
3114
|
-
cache_ttl>0 启用精确匹配响应缓存(key=供应商+模型+prompt+max_tokens
|
|
3115
|
-
ok 结果)——仅限幂等调用(连通性测试等);创作类调用不要开,否则同一 prompt
|
|
3178
|
+
cache_ttl>0 启用精确匹配响应缓存(key=供应商+模型+prompt+max_tokens+图签名,
|
|
3179
|
+
只缓存 ok 结果)——仅限幂等调用(连通性测试等);创作类调用不要开,否则同一 prompt
|
|
3116
3180
|
的二次请求会屏蔽模型的新输出。
|
|
3117
3181
|
on_delta 给定时走 SSE 流式:每收到一段增量文本回调一次。编排者直连调用
|
|
3118
3182
|
不经 run_process、原本生成全程日志只有一行标题,靠它把「正在吐字」实时
|
|
3119
3183
|
写进步骤日志(planner._log_streamer 节流落盘)。
|
|
3120
3184
|
"""
|
|
3185
|
+
images = [(str(m), str(b)) for m, b in (images or [])] or None
|
|
3121
3186
|
if cache_ttl > 0:
|
|
3122
3187
|
try:
|
|
3123
3188
|
cache_path = _chat_cache_path(provider_id, model_name, prompt, max_tokens,
|
|
3124
|
-
reasoning_effort)
|
|
3189
|
+
reasoning_effort, images)
|
|
3125
3190
|
if cache_path.is_file():
|
|
3126
3191
|
age = time.time() - cache_path.stat().st_mtime
|
|
3127
3192
|
if age <= cache_ttl:
|
|
@@ -3157,7 +3222,7 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
|
|
|
3157
3222
|
else:
|
|
3158
3223
|
url = base + "/v1beta/models/%s:generateContent" % model_name
|
|
3159
3224
|
headers = {"x-goog-api-key": use_key}
|
|
3160
|
-
body = {"contents": [{"parts":
|
|
3225
|
+
body = {"contents": [{"parts": _chat_user_content(proto, prompt, images)}],
|
|
3161
3226
|
"generationConfig": {"maxOutputTokens": max_tokens}}
|
|
3162
3227
|
else:
|
|
3163
3228
|
path = "/messages" if proto == "anthropic" else "/chat/completions"
|
|
@@ -3167,7 +3232,8 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
|
|
|
3167
3232
|
else:
|
|
3168
3233
|
headers = {"Authorization": "Bearer " + use_key}
|
|
3169
3234
|
body = {"model": model_name, "max_tokens": max_tokens,
|
|
3170
|
-
"messages": [{"role": "user",
|
|
3235
|
+
"messages": [{"role": "user",
|
|
3236
|
+
"content": _chat_user_content(proto, prompt, images)}]}
|
|
3171
3237
|
effort = str(reasoning_effort or "").strip().lower()
|
|
3172
3238
|
if proto == "openai" and effort in ("low", "medium", "high"):
|
|
3173
3239
|
body["reasoning_effort"] = effort
|
|
@@ -3255,7 +3321,7 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
|
|
|
3255
3321
|
if cache_ttl > 0:
|
|
3256
3322
|
try:
|
|
3257
3323
|
cache_path = _chat_cache_path(provider_id, model_name, prompt, max_tokens,
|
|
3258
|
-
reasoning_effort)
|
|
3324
|
+
reasoning_effort, images)
|
|
3259
3325
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3260
3326
|
tmp = cache_path.with_suffix(".tmp")
|
|
3261
3327
|
import json as _json
|