codebee 0.1.66 → 0.1.67

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 CHANGED
@@ -4,7 +4,19 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
- ## 未发布
7
+ ## v0.1.67(2026-09-24)
8
+
9
+ - **连载任务超时不再要人守着手动重试**:「任务总时限已到」此前是死终态——总时限默认 1 小时,供应商只剩慢链时一批十二章天然跑不完,用户只能反复点重试(2026-09-24 戍边骑奴 9-20 案)。现在超时的连载任务与失败一样自动续跑(继承已完成章、延迟 5 分钟退避、次数上限照旧);同因止损对超时做了进度感知——错误签名恒为同一句,但本轮比上轮多过审了章就不算「退避没换来结果」,两轮一章未进才止损。
10
+ - **任务总时限建完任务可以改了**:任务信息弹层新增「任务总时限」(30 分钟~24 小时档位),空闲时可选,下次运行/重试按新预算重新给满;`/api/tasks/<id>/params` 同步收 `timeout_s`(统一钳制 1 秒~7 天)。此前它只在建任务时按默认 1 小时写死,没有任何事后入口。
11
+ - **分卷连载大纲步崩溃修复(norm_volumes 缺实装)**:`aea5632` 半提交只把调用放进了 planner、实现从未进 volumes——带分卷的连载大纲步必炸 `AttributeError`(同晚四连败实案),不分卷路径不触发故潜伏两天。补齐实现(收模型卷名只留本批覆盖卷;现成卷规划表优先对齐主链边界)+ planner 透传 + 三组回归。
12
+ - **qwen 注入器内闸:anthropic 面 URL 拒写**:`_sync_qwen_settings` 本体加协议闸,anthropic 面(如 `.../api/anthropic`)绝不再写进 `OPENAI_BASE_URL`——史上某版写进去后 qwen 拿 openai 协议打 anthropic 路由、网关恒 404 被误判「网关限流」,每轮运行起点白烧 2-3 次重试(今晚实案)。外层调度已有协议闸,这里钉住注入器本体防复发。
13
+ - **「这个任务当时用的哪一版流程」有账可对了(流程修订钉住)**:建任务时把当时流程定义的内容指纹(digest)钉进任务快照;之后改流程再续跑/重试老任务,运行会留下明确诊断(「流程在任务创建后已修改,固化参数不受影响」)并计入运行警告——此前历史任务缺失字段的兜底取值会静默吃到新流程定义,出了结果差异只能靠猜。改流程的图标、说明文字这类展示位不制造漂移噪音。
14
+ - **每个步骤现在都记「实际用的是哪家上游」**:步骤起跑即落一行绑定速记(`厂商@网关地址` 形态,密钥绝不入账)。烧错配额、模型漂移、换将补位之后「这步到底走的谁」直接对账步骤行,不必再翻日志文本找审计行;计划选路(route_plan)与实际执行从此分开可见。
15
+ - **「已停止」不再冒充「真的停稳了」**:取消/超时杀进程树时,如果没能确认整棵树清空(Windows taskkill 卡死、POSIX 组杀失手),步骤会如实标注「进程树未确认清空:孙进程可能仍在运行」——只作诊断提示,不改变取消/超时的终态语义,也不影响密钥冷却等记账。
16
+ - **步骤卡能直接看到「实际用上游」徽章了**:模型徽章旁边新增上游徽章(虚线胶囊,`厂商@网关` 形态)——运行中和终态步骤行都不用翻日志就能对账这步走的谁。
17
+ - **两个窗口同时改任务参数,后写的不会再悄悄覆盖先写的**:对话条的编排模式/思考程度/对话模型现在带修订号提交,别的窗口先改过就拒绝过期写入并提示「参数已被其他窗口修改」,界面随后自动同步最新值——此前是「我以为改成功了,其实早被另一台设备覆盖」的静默假成功。
18
+ - **附件换过了,续跑不再装不知道**:附件(截图/文档/表格)收进任务时记内容指纹;续跑/重试时对账当前文件,被换过、删过都会在运行警告里点名(「附件在任务创建后已变化:xxx;本次执行使用当前文件内容」)——此前任务吃到「不是当初那份附件」毫无声息。老任务没记过指纹的不做推断、不打扰。
19
+ - **成品弹窗看得开 PDF 和 Excel 了**:点成品文件名预览,PDF 逐页渲染(按窗口宽度适配,大文件封顶 20 页并提示下载看全)、xlsx 表格带工作表页签(200 行 × 40 列内直接读,超出提示下载)。渲染器首次打开时联网加载(与 docx 预览同款,平时零请求);离线或加载失败自动回落为下载提示。老式 .xls/.doc 二进制不在预览之列。
8
20
 
9
21
  ## v0.1.66(2026-09-24)
10
22
 
@@ -0,0 +1,100 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Run acceptance matrix and evidence normalization.
3
+
4
+ The matrix keeps correctness, performance, boundary behavior, exception
5
+ handling, readability and security as separate conclusions. Missing evidence
6
+ is reported as ``not_evaluated``/``unknown`` rather than being inferred from a
7
+ successful process exit.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+
13
+ CASES = ("correctness", "performance", "boundary", "exception",
14
+ "readability", "security")
15
+
16
+
17
+ def _case(name, status, evidence=None, reason=""):
18
+ return {"case": name, "status": status,
19
+ "evidence": [evidence] if isinstance(evidence, dict) else [],
20
+ "reason": str(reason or "")[:300]}
21
+
22
+
23
+ def _quality_verdict(run):
24
+ verdict = run.get("verdict") or {}
25
+ for key in ("pass", "publishable", "verify_pass"):
26
+ if isinstance(verdict.get(key), bool):
27
+ return verdict.get(key), key
28
+ return None, ""
29
+
30
+
31
+ def evaluate_run(run, task=None):
32
+ """Build a deterministic acceptance result from persisted run evidence."""
33
+ run = run or {}
34
+ task = task or {}
35
+ status = str(run.get("status") or "")
36
+ verdict = run.get("verdict") or {}
37
+ out = []
38
+
39
+ correct, source = _quality_verdict(run)
40
+ if status in ("failed", "timeout", "cancelled"):
41
+ out.append(_case("correctness", "unknown",
42
+ {"source": "run.status", "value": status},
43
+ "任务终态未形成可判定的质量结论"))
44
+ elif correct is not None:
45
+ out.append(_case("correctness", "passed" if correct else "failed",
46
+ {"source": "run.verdict", "field": source,
47
+ "value": bool(correct)}))
48
+ else:
49
+ out.append(_case("correctness", "not_evaluated", reason="缺少验证或质量门证据"))
50
+
51
+ duration = run.get("duration_s")
52
+ if duration is None and run.get("started_at") and run.get("ended_at"):
53
+ duration = "recorded"
54
+ has_duration = isinstance(duration, (int, float)) and duration >= 0
55
+ has_usage = any(isinstance(run.get(key), (int, float)) and run.get(key) > 0
56
+ for key in ("tokens", "cost_usd"))
57
+ if has_duration or has_usage:
58
+ out.append(_case("performance", "passed", {
59
+ "source": "run.metrics", "duration_s": duration,
60
+ "tokens": run.get("tokens", 0), "cost_usd": run.get("cost_usd", 0),
61
+ }, "仅表示性能数据已记录,不代表达到业务阈值"))
62
+ else:
63
+ out.append(_case("performance", "not_evaluated", reason="缺少耗时或用量证据"))
64
+
65
+ boundary = verdict.get("boundary_checks")
66
+ if isinstance(boundary, list) and boundary and all(
67
+ isinstance(x, dict) and isinstance(x.get("passed"), bool) for x in boundary):
68
+ failed = [x for x in boundary if x.get("passed") is False]
69
+ out.append(_case("boundary", "failed" if failed else "passed",
70
+ {"source": "run.verdict.boundary_checks", "count": len(boundary),
71
+ "failed": len(failed)}))
72
+ else:
73
+ out.append(_case("boundary", "not_evaluated", reason="当前任务未声明边界检查证据"))
74
+
75
+ exception = verdict.get("exception")
76
+ if isinstance(exception, bool):
77
+ out.append(_case("exception", "passed" if exception else "failed",
78
+ {"source": "run.verdict.exception", "value": exception}))
79
+ elif status in ("failed", "timeout"):
80
+ out.append(_case("exception", "failed" if status in ("failed", "timeout") else "unknown",
81
+ {"source": "run.error", "status": status,
82
+ "error": str(run.get("error") or "")[:300]}))
83
+ elif status == "cancelled":
84
+ out.append(_case("exception", "unknown",
85
+ {"source": "run.status", "value": status}))
86
+ else:
87
+ out.append(_case("exception", "not_evaluated", reason="缺少独立异常处理证据"))
88
+
89
+ for name, key in (("readability", "readability"), ("security", "security")):
90
+ value = verdict.get(key)
91
+ if isinstance(value, bool):
92
+ out.append(_case(name, "passed" if value else "failed",
93
+ {"source": "run.verdict.%s" % key, "value": value}))
94
+ else:
95
+ out.append(_case(name, "not_evaluated", reason="未提供独立 %s 证据" % name))
96
+
97
+ # 总体通过要求六个维度都有明确通过证据;缺证据不能被两个局部绿灯掩盖。
98
+ return {"version": 1, "evaluated_at": time.time(),
99
+ "passed": bool(out and all(x["status"] == "passed" for x in out)),
100
+ "cases": out}
@@ -0,0 +1,110 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Content-addressed asset index.
3
+
4
+ The index owns references and revisions, not file contents. Files stay in the
5
+ task workspace; projects/runs can safely point to ``asset_id + revision_id``
6
+ without making a second copy of a document.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import hashlib
12
+ import mimetypes
13
+ import threading
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from . import paths
18
+
19
+ LOCK = threading.RLock()
20
+
21
+
22
+ def _path():
23
+ return paths.DATA_DIR / "assets.json"
24
+
25
+
26
+ def _load():
27
+ try:
28
+ value = json.loads(_path().read_text(encoding="utf-8"))
29
+ return value if isinstance(value, dict) else {"assets": {}}
30
+ except (OSError, ValueError):
31
+ return {"assets": {}}
32
+
33
+
34
+ def _save(data):
35
+ path = _path()
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ tmp = path.with_suffix(".tmp")
38
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
39
+ tmp.replace(path)
40
+
41
+
42
+ def register(workdir, relative_path, *, owner_type="", owner_id="", name="", mime=""):
43
+ """Index a workspace file and return its reference, or ``None`` if absent."""
44
+ root = Path(str(workdir or "")).resolve()
45
+ rel = str(relative_path or "").replace("\\", "/").lstrip("/")
46
+ if not rel or rel.startswith("../") or "/../" in rel:
47
+ return None
48
+ fp = (root / rel).resolve()
49
+ try:
50
+ if root not in fp.parents or not fp.is_file():
51
+ return None
52
+ digest_builder = hashlib.sha256()
53
+ size = 0
54
+ with fp.open("rb") as handle:
55
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
56
+ digest_builder.update(chunk)
57
+ size += len(chunk)
58
+ digest = digest_builder.hexdigest()
59
+ asset_id = "asset-" + digest[:16]
60
+ revision_id = "assetrev-" + digest[:16]
61
+ ref = {"asset_id": asset_id, "revision_id": revision_id,
62
+ "content_sha256": digest, "path": rel,
63
+ "name": str(name or fp.name)[:120],
64
+ "mime": str(mime or mimetypes.guess_type(fp.name)[0]
65
+ or "application/octet-stream")[:120],
66
+ "size": size, "updated_at": time.time()}
67
+ with LOCK:
68
+ data = _load()
69
+ assets = data.setdefault("assets", {})
70
+ row = assets.setdefault(asset_id, ref)
71
+ # The content hash identifies one immutable revision. Keep the
72
+ # first source path/name as canonical metadata so registering the
73
+ # same bytes from another workspace cannot rewrite an existing
74
+ # reference's path semantics.
75
+ row.update({k: ref[k] for k in
76
+ ("revision_id", "content_sha256", "size", "updated_at")})
77
+ for key in ("path", "name", "mime"):
78
+ if not row.get(key):
79
+ row[key] = ref[key]
80
+ owners = row.setdefault("owners", [])
81
+ owner = {"type": str(owner_type or "")[:40], "id": str(owner_id or "")[:100]}
82
+ if owner["id"] and owner not in owners:
83
+ owners.append(owner)
84
+ row["owners"] = owners[-50:]
85
+ _save(data)
86
+ return dict(row)
87
+ except (OSError, ValueError):
88
+ return None
89
+
90
+
91
+ def get(asset_id, revision_id=""):
92
+ with LOCK:
93
+ row = (_load().get("assets") or {}).get(str(asset_id or ""))
94
+ if not isinstance(row, dict):
95
+ return None
96
+ if revision_id and str(row.get("revision_id") or "") != str(revision_id):
97
+ return None
98
+ return dict(row)
99
+
100
+
101
+ def list_assets(*, owner_type="", owner_id="", limit=200):
102
+ with LOCK:
103
+ rows = list((_load().get("assets") or {}).values())
104
+ if owner_type or owner_id:
105
+ rows = [row for row in rows if any(
106
+ (not owner_type or owner_type == item.get("type")) and
107
+ (not owner_id or owner_id == item.get("id"))
108
+ for item in (row.get("owners") or []))]
109
+ rows.sort(key=lambda row: float(row.get("updated_at") or 0), reverse=True)
110
+ return [dict(row) for row in rows[:max(1, min(1000, int(limit or 200)))]]
@@ -13,6 +13,7 @@ commit_to_workdir 移入 <workdir>/_attachments/ 并把路径清单注入任务
13
13
  from __future__ import annotations
14
14
 
15
15
  import base64
16
+ import hashlib
16
17
  import html as _html
17
18
  import json
18
19
  import mimetypes
@@ -111,6 +112,89 @@ def norm_rel(a):
111
112
  return "_attachments/" + p.rsplit("/", 1)[-1]
112
113
 
113
114
 
115
+ def _digest_file(path):
116
+ """分块 sha256:附件几 MB 级,块读防一次性大内存。"""
117
+ h = hashlib.sha256()
118
+ with open(str(path), "rb") as fh:
119
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
120
+ h.update(chunk)
121
+ return h.hexdigest()
122
+
123
+
124
+ def verify_task(task, workdir=None):
125
+ """附件完整性对账(借鉴 WorkDSH 输入引用修订:任务输入钉精确修订而非
126
+ 可变 latest)。任务创建/追加时钉的 digest vs 当前文件内容:
127
+
128
+ 返回漂移清单 [{path, name, pinned, current, status}],status ∈
129
+ changed/missing/unreadable;无附件、无钉记(老数据)或无漂移返回 []。
130
+ 只诊断不阻断——用户可能有意换文件,但绝不静默。"""
131
+ root = Path(str(workdir or (task or {}).get("workdir") or "")).resolve()
132
+ out = []
133
+ for a in (task or {}).get("attachments") or []:
134
+ pinned = str(a.get("digest") or "")
135
+ rel = norm_rel(a)
136
+ if not pinned or not rel:
137
+ continue # 老数据无钉记:不做推断,不算漂移
138
+ fp = (root / rel).resolve()
139
+ try:
140
+ if root not in fp.parents:
141
+ out.append({"path": rel, "name": str(a.get("name") or rel)[:120],
142
+ "pinned": pinned, "current": "", "status": "unreadable"})
143
+ continue
144
+ if not fp.is_file():
145
+ out.append({"path": rel, "name": str(a.get("name") or rel)[:120],
146
+ "pinned": pinned, "current": "", "status": "missing"})
147
+ continue
148
+ cur = _digest_file(fp)
149
+ except OSError:
150
+ out.append({"path": rel, "name": str(a.get("name") or rel)[:120],
151
+ "pinned": pinned, "current": "", "status": "unreadable"})
152
+ continue
153
+ if cur != pinned:
154
+ out.append({"path": rel, "name": str(a.get("name") or rel)[:120],
155
+ "pinned": pinned, "current": cur, "status": "changed"})
156
+ return out
157
+
158
+
159
+ def asset_refs(workdir, items):
160
+ """Return stable asset references for files owned by a task.
161
+
162
+ The file remains in the task workspace; the reference carries only its
163
+ identity, revision digest and relative path. This lets project/task
164
+ records point at an asset without copying the asset body into metadata.
165
+ Missing files are skipped so legacy attachment records remain readable.
166
+ """
167
+ root = Path(str(workdir or "")).resolve()
168
+ out = []
169
+ for raw in items or []:
170
+ if isinstance(raw, dict) and str(raw.get("path") or "").replace("\\", "/").startswith("_attachments/"):
171
+ rel = norm_rel(raw)
172
+ else:
173
+ rel = str((raw or {}).get("path") if isinstance(raw, dict) else raw or "")
174
+ rel = rel.replace("\\", "/").lstrip("/")
175
+ if not rel:
176
+ continue
177
+ fp = (root / rel).resolve()
178
+ try:
179
+ if root not in fp.parents or not fp.is_file():
180
+ continue
181
+ digest = _digest_file(fp)
182
+ source = raw if isinstance(raw, dict) else {}
183
+ out.append({
184
+ "asset_id": "asset-" + digest[:16],
185
+ "revision_id": "assetrev-" + digest[:16],
186
+ "content_sha256": digest,
187
+ "path": rel,
188
+ "name": str(source.get("name") or fp.name)[:120],
189
+ "mime": str(source.get("mime") or mimetypes.guess_type(fp.name)[0]
190
+ or "application/octet-stream")[:120],
191
+ "size": fp.stat().st_size,
192
+ })
193
+ except (OSError, ValueError):
194
+ continue
195
+ return out
196
+
197
+
114
198
  def read_pending(fid):
115
199
  """读待提交区附件原文(输入条胶囊点击预览用)。id 是 16 位十六进制
116
200
  随机数且只认文件名,猜不中即空。返回 (bytes, mime, name);找不到
@@ -292,7 +376,10 @@ def commit_to_workdir(workdir, ids):
292
376
  item = {"name": name,
293
377
  "size": meta.get("size") or (adir / name).stat().st_size,
294
378
  "mime": meta.get("mime") or "application/octet-stream",
295
- "path": "_attachments/" + name}
379
+ "path": "_attachments/" + name,
380
+ # 内容指纹(借鉴 WorkDSH 输入引用修订):续跑/重试时
381
+ # verify_task 对账——文件被换过/删过给明确诊断,不静默
382
+ "digest": _digest_file(adir / name)}
296
383
  # Office zip 文档抽正文 → 伴生 .txt:无头 CLI 读不了二进制容器,
297
384
  # 有文本版所有智能体都能直接读;抽取失败只降级不拒收
298
385
  if Path(name).suffix.lower() in _TEXT_EXTRACT_EXT:
@@ -377,6 +464,10 @@ def items_from_paths(paths_, workdir):
377
464
  except OSError:
378
465
  size = 0
379
466
  item = {"name": Path(rel).name, "path": rel, "mime": mime, "size": size}
467
+ try:
468
+ item["digest"] = _digest_file(path) # 运行中追加同样钉指纹
469
+ except OSError:
470
+ pass
380
471
  side = path.with_name(path.name + ".txt")
381
472
  if side.is_file():
382
473
  item["text_path"] = rel + ".txt"
@@ -130,28 +130,114 @@ def _fq_content_tables():
130
130
 
131
131
 
132
132
  def _fq_options_block(goal):
133
- """番茄提示词的真实选项注入:按 goal 推断读者给出对应分类与标签表。"""
133
+ """番茄提示词的真实选项注入:按 goal 推断读者给出对应分类与标签表。
134
+
135
+ 2026-09-24 起首行加频道判定规则:素材明显属于另一频时 target_reader
136
+ 如实填、分类留空(规整层按该频表校验并兜底补齐),防「注入男频表+
137
+ 女频素材」时硬凑男频分类。"""
134
138
  reader = "女频" if "女" in (goal or "") else "男频"
135
139
  cats, theme, role, plot = _fq_tables(reader)
136
140
  c_plot, c_emo, c_char, c_world = _fq_content_tables()
137
- return ("目标读者(%s)可用的主分类表(必选且只能选一个):%s\n"
141
+ return ("先按素材判定目标读者(战争/军功/历史争霸/都市异能/系统→男频;"
142
+ "总裁豪门/宅斗宫斗/年代/婚恋情感线为主→女频),target_reader 如实填;"
143
+ "主分类从下方按目标读者 %s 预注入的表里选,素材明显属于另一频时"
144
+ "分类留空待系统按该频表补齐。主分类要贴世界观而非字面词——架空"
145
+ "王朝/军事/军功题材选历史古代或历史脑洞,西方奇幻仅限西式魔幻"
146
+ "世界观。\n"
147
+ "目标读者(%s)可用的主分类表(必选且只能选一个):%s\n"
138
148
  "阅读标签三组各最多选 2 个——主题组:%s\n角色组:%s\n情节组(部分,选最贴合的):%s\n"
139
149
  "内容标签四组——情节组(最多4个,部分):%s\n情感组(最多2个):%s\n"
140
150
  "人设组(最多4个,部分):%s\n世界观组(最多1个):%s"
141
- % (reader, "、".join(cats), "、".join(theme), "、".join(role),
151
+ % (reader, reader, "、".join(cats), "、".join(theme), "、".join(role),
142
152
  "、".join(plot[:50]), "、".join(c_plot[:60]), "、".join(c_emo),
143
153
  "、".join(c_char[:60]), "、".join(c_world)))
144
154
 
145
155
 
146
156
  def _qm_options_block(goal):
147
- """七猫提示词的真实选项注入:按频道给级联 + 四组标签池(每组必选 1-3)。"""
157
+ """七猫提示词的真实选项注入:双频道级联 + 频道判定规则 + 跨频道禁令。
158
+
159
+ 2026-09-24 前只按 goal 猜频道注入单频道表,但挡不住模型自带另一频道
160
+ 的分类知识跨频道自造组合(男频军事文被配成 女生/古代言情/权谋天下,
161
+ 级联还自洽)——必须双频道全量注入并明示判定规则。"""
148
162
  from . import bookmeta_catalog as cat
149
- channel = cat.QIMAO_CATS.get("女生" if "女" in (goal or "") else "男生")
150
- cascade = ["%s(二级:%s)" % (m, "、".join(v)) for m, v in channel.items()]
151
- pool = ["%s组(必选1-3个):%s" % (g, "、".join(t)) for g, t in cat.QIMAO_TAG_GROUPS.items()]
152
- return ("一级分类表(选一个):%s\n一级→二级级联(二级必须从所属一级下选):%s\n"
153
- "作品标签四组:%s"
154
- % ("、".join(channel), ";".join(cascade), ";".join(pool)))
163
+ cascades = ["%s频道(一级→二级):%s" % (ch, ";".join(
164
+ "%s(二级:%s)" % (m, "、".join(v)) for m, v in mains.items()))
165
+ for ch, mains in cat.QIMAO_CATS.items()]
166
+ pool = ["%s组(必选1-3个):%s" % (g, "、".join(t))
167
+ for g, t in cat.QIMAO_TAG_GROUPS.items()]
168
+ return ("先按素材判定目标读者频道:战争/军功/练兵/历史争霸/都市异能/"
169
+ "系统流/玄幻修仙→男生;总裁豪门/宅斗宫斗/年代/婚恋情感线为主→"
170
+ "女生。target_reader 必须填判定的频道,一级/二级分类只能从该频道"
171
+ "的级联表里选,严禁跨频道——如「权谋天下」只存在于女生·古代言情"
172
+ "下,男频军事历史文不得选用;「架空历史」属男生·历史。\n"
173
+ "%s\n作品标签四组(男女生同池):%s"
174
+ % ("。".join(cascades), ";".join(pool)))
175
+
176
+
177
+ # ---- 频道题材信号(2026-09-24 实案:男频军事文《戍边骑奴》被生成端配成
178
+ # 女生/古代言情/权谋天下——「权谋天下」字面贴合朝堂线但只存在于女生频道,
179
+ # 模型跨频道自造组合且级联自洽,目录校验拦不住,只能靠题材信号判频道。
180
+ # 必须保守:信号单侧命中才判,双侧命中/零命中一律不判(宁可不纠不可错纠)。
181
+ # 证据语料只用 goal+简介:分类字段是嫌疑输出、标签池男女同池,都不算证据。
182
+ _CH_SIGNAL_MALE = ("军功", "戍边", "骑奴", "特种兵", "练兵", "杀伐", "阵斩",
183
+ "百夫长", "大军", "蛮族", "边军", "边关", "边疆", "战场",
184
+ "争霸", "兵王", "战神", "赘婿", "武侠", "修仙", "修真",
185
+ "玄幻", "机甲", "星际", "电竞", "网游", "领主")
186
+ _CH_SIGNAL_FEMALE = ("言情", "总裁", "豪门", "宅斗", "宫斗", "嫡女", "庶女",
187
+ "甜宠", "虐恋", "宠妻", "宠夫", "王妃", "医妃", "皇后",
188
+ "公主", "丫鬟", "千金", "女配", "追妻", "闪婚", "隐婚",
189
+ "婆媳", "闺蜜", "女尊", "女强", "军婚", "宫闱")
190
+
191
+
192
+ def _channel_anchor(text):
193
+ """题材信号判频道:男/女信号词单侧命中返回 男生/女生,否则 None。"""
194
+ t = str(text or "")
195
+ if not t:
196
+ return None
197
+ male = any(w in t for w in _CH_SIGNAL_MALE)
198
+ female = any(w in t for w in _CH_SIGNAL_FEMALE)
199
+ if male and not female:
200
+ return "男生"
201
+ if female and not male:
202
+ return "女生"
203
+ return None
204
+
205
+
206
+ _READER_OF = {"fanqie": {"男生": "男频", "女生": "女频"},
207
+ "qimao": {"男频": "男生", "女频": "女生"}}
208
+
209
+
210
+ def _reader_anchor(task, platform, meta):
211
+ """频道锚:题材信号(goal+简介)优先,其次同书另一平台已定的读者。
212
+
213
+ 返回 (该平台的读者词, 依据说明) 或 (None, "")。"""
214
+ corpus = "。".join(x for x in ((task or {}).get("goal") or "",
215
+ (meta or {}).get("summary") or "") if x)
216
+ sig = _channel_anchor(corpus)
217
+ if sig:
218
+ # 信号词是男生/女生:七猫原样用,番茄翻成 男频/女频
219
+ own = sig if platform == "qimao" else {"男生": "男频", "女生": "女频"}[sig]
220
+ return own, "题材信号"
221
+ peer_plat = "qimao" if platform == "fanqie" else "fanqie"
222
+ peer = (((task or {}).get("book_meta") or {}).get(peer_plat) or {})
223
+ if peer.get("status") == "done":
224
+ mapped = _READER_OF[platform].get(
225
+ (peer.get("data") or {}).get("target_reader"))
226
+ if mapped:
227
+ return mapped, "同书另一平台(%s)已定读者" % PLATFORMS[peer_plat]["label"]
228
+ return None, ""
229
+
230
+
231
+ def _apply_fix_note(meta, source):
232
+ """把 _fill_required_fields 记录的纠偏说明并进 source。
233
+
234
+ meta 里的 _fixes 是临时键,随出随清,不落 book_meta 数据(否则
235
+ repair_existing 的幂等比较会每次不等、启动反复重写)。"""
236
+ fixes = meta.pop("_fixes", None)
237
+ if fixes:
238
+ source = (str(source or "") + ";" + ";".join(str(x) for x in fixes)
239
+ ).strip(";")
240
+ return source
155
241
 
156
242
 
157
243
  def _collect_material(task):
@@ -517,6 +603,28 @@ def _fill_required_fields(task, platform, meta, outline, material):
517
603
  # 标题/占位文本过不了提交且流程看不到报错——洗成正文,不够长从素材取
518
604
  meta["summary"] = _prose_summary(meta.get("summary"), material)
519
605
 
606
+ # 频道锚:题材信号/同书另一平台的读者与模型声称的频道冲突时以锚为准
607
+ # 翻转(2026-09-24 实案:男频军事文被配成 女生/古代言情/权谋天下,
608
+ # 级联自洽骗过目录校验)。翻转后旧频道分类作废,由下方素材补齐逻辑
609
+ # 按正确频道重选;纠偏说明记 _fixes 临时键,由调用方并进 source。
610
+ anchor, why = _reader_anchor(task, platform, meta)
611
+ if anchor and meta.get("target_reader") != anchor:
612
+ meta["_fixes"] = ["频道纠偏:%s→%s(依据:%s)"
613
+ % (meta.get("target_reader") or "未填", anchor, why)]
614
+ meta["target_reader"] = anchor
615
+ if platform == "qimao":
616
+ ch = cat.QIMAO_CATS.get(anchor) or {}
617
+ if meta.get("category_main") not in ch:
618
+ meta["category_main"] = ""
619
+ ch_main = meta.get("category_main")
620
+ if meta.get("category_sub") and not (
621
+ ch_main and meta["category_sub"] in (ch.get(ch_main) or [])):
622
+ meta["category_sub"] = ""
623
+ else:
624
+ fq_cats, _, _, _ = _fq_tables(anchor)
625
+ if meta.get("category") not in fq_cats:
626
+ meta["category"] = ""
627
+
520
628
  if platform == "fanqie":
521
629
  reader = meta.get("target_reader") or ("女频" if female else "男频")
522
630
  if reader not in ("男频", "女频"):
@@ -525,11 +633,22 @@ def _fill_required_fields(task, platform, meta, outline, material):
525
633
  cats, theme, role, plot = _fq_tables(reader)
526
634
  c_plot, c_emo, c_char, c_world = _fq_content_tables()
527
635
  if meta.get("category") not in cats:
528
- meta["category"] = _pick_option(
529
- material, cats,
530
- (("戍边", "古风世情"), ("边关", "古风世情"),
531
- ("军", "抗战谍战"), ("悬疑", "女频悬疑" if female else "悬疑脑洞"),
532
- ("种田", "种田"), ("都市", "都市脑洞")))
636
+ # 关键词兜底按频道给(2026-09-24 实案《戍边骑奴》:旧表不分频道,
637
+ # 「戍边→古风世情」是女频词,男频素材永远选不中,全部落到男频表
638
+ # 首项「西方奇幻」——架空王朝军事文被配成西式魔幻)
639
+ kw = (("历史", "历史古代"), ("古代", "历史古代"),
640
+ ("王朝", "历史脑洞"), ("架空", "历史脑洞"),
641
+ ("戍边", "历史古代"), ("边关", "历史古代"),
642
+ ("抗战", "抗战谍战"), ("谍战", "抗战谍战"),
643
+ ("军", "历史古代"), ("种田", "都市种田"),
644
+ ("都市", "都市脑洞"), ("悬疑", "悬疑脑洞")) if reader == "男频" else (
645
+ ("宅斗", "宫斗宅斗"), ("宫斗", "宫斗宅斗"),
646
+ ("总裁", "豪门总裁"), ("豪门", "豪门总裁"),
647
+ ("古代", "古风世情"), ("古言", "古言脑洞"),
648
+ ("年代", "年代"), ("种田", "种田"),
649
+ ("婚姻", "职场婚恋"), ("恋爱", "职场婚恋"),
650
+ ("悬疑", "女频悬疑"), ("快穿", "快穿"))
651
+ meta["category"] = _pick_option(material, cats, kw)
533
652
  groups = (("tags_theme", theme, 2), ("tags_role", role, 2),
534
653
  ("tags_plot", plot, 2), ("content_plot", c_plot, 4),
535
654
  ("content_emotion", c_emo, 2), ("content_character", c_char, 4),
@@ -542,12 +661,15 @@ def _fill_required_fields(task, platform, meta, outline, material):
542
661
  channel = cat.QIMAO_CATS.get(reader) or cat.QIMAO_CATS["男生"]
543
662
  main = meta.get("category_main")
544
663
  if main not in channel:
545
- main = _pick_option(
546
- material, list(channel),
547
- (("戍边", "古代言情"), ("边关", "古代言情"),
548
- ("古代", "古代言情"), ("军事", "军事"),
549
- ("军", "军事"), ("都市", "都市"),
550
- ("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")))
664
+ # 关键词兜底按频道给(2026-09-24 频道锚落地后读方可信,旧的
665
+ # 「戍边→古代言情」在男生频道下永远选不中白占位)
666
+ kw = (("戍边", "历史"), ("边关", "历史"), ("军", "军事"),
667
+ ("都市", "都市"), ("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")
668
+ ) if reader == "男生" else (
669
+ ("宅斗", "古代言情"), ("宫斗", "古代言情"),
670
+ ("古代", "古代言情"), ("总裁", "现代言情"),
671
+ ("年代", "现代言情"), ("都市", "现代言情"))
672
+ main = _pick_option(material, list(channel), kw)
551
673
  meta["category_main"] = main
552
674
  subs = channel.get(main) or []
553
675
  sub = meta.get("category_sub")
@@ -644,8 +766,9 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
644
766
  meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
645
767
  if meta:
646
768
  meta = _fill_tag_gaps(task, platform, meta, outline)
647
- return _fill_required_fields(task, platform, meta, outline, material), \
648
- "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
769
+ meta = _fill_required_fields(task, platform, meta, outline, material)
770
+ return meta, _apply_fix_note(meta, "编排者(%s · %s)"
771
+ % (prov.get("name", prov["id"]), model))
649
772
  orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
650
773
 
651
774
  if author_agent and author_agent.get("mode") == "real" and not template_only():
@@ -657,13 +780,14 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
657
780
  meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
658
781
  if meta:
659
782
  meta = _fill_tag_gaps(task, platform, meta, outline)
660
- return _fill_required_fields(task, platform, meta, outline, material), "llm(%s)" % author_agent["id"]
783
+ meta = _fill_required_fields(task, platform, meta, outline, material)
784
+ return meta, _apply_fix_note(meta, "llm(%s)" % author_agent["id"])
661
785
  orch_err = orch_err or str(res.get("error") or "")[:200]
662
786
 
663
787
  meta = _template_meta(task, platform, outline)
664
788
  meta = _fill_required_fields(task, platform, meta, outline, material)
665
789
  meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
666
- return meta, meta["source"]
790
+ return meta, _apply_fix_note(meta, meta["source"])
667
791
 
668
792
 
669
793
  def render_markdown(task, platform, meta):
@@ -773,16 +897,26 @@ def repair_existing():
773
897
  and _has_value(data.get("protagonist_1"))
774
898
  and _has_value(data.get("protagonist_2"))
775
899
  and _summary_ok(data.get("summary")))
900
+ # 频道失配也视为待修复(2026-09-24 实案:级联自洽的跨频道组合
901
+ # 字段全齐,required_ok 挡不住,只能靠题材信号识别)
902
+ anchor, _ = _reader_anchor(task, platform,
903
+ {"summary": data.get("summary") or ""})
904
+ if anchor and anchor != data.get("target_reader"):
905
+ required_ok = False
776
906
  if required_ok:
777
907
  continue
778
908
  try:
779
909
  material, outline = _collect_material(task)
780
910
  fixed = _fill_required_fields(task, platform, data, outline, material)
911
+ fixes = fixed.pop("_fixes", None) # 先清临时键再比幂等
781
912
  if fixed == entry["data"]:
782
913
  continue
783
914
  payload = dict(entry)
784
915
  payload["data"] = fixed
785
- payload["source"] = (str(entry.get("source") or "") + ";已补齐必填字段").lstrip(";")
916
+ note = "已补齐必填字段"
917
+ if fixes:
918
+ note += ";" + ";".join(str(x) for x in fixes)
919
+ payload["source"] = (str(entry.get("source") or "") + ";" + note).lstrip(";")
786
920
  store.set_book_meta(task["id"], platform, payload)
787
921
  try:
788
922
  fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]