一、技能重组(动作-对象命名) - 旧目录 clean/confirm/continuation/db/detect/embed/… 重组为 clean-book-text/decide-candidate/write-next-chapter/access-database/ check-content-consistency/embed-knowledge/…(git 识别为 rename,内容保持) - agents/*.md、AGENTS.md/CLAUDE.md 收编、example_skill 登记表同步新名 二、先审后入创作闭环(本次核心) 正文接受从"机械门一过就写正典"改为"机械门+语义审查双通过+用户批准+单事务原子提交", DB 级兜底,编排层跳步即被硬拒。 - candidate_cas.py + example_candidate_cas(109):持久化 CAS 状态链 - fact_delta.py + example_fact_delta/example_fact_ledger(106):结构化事实增量, 模型只提六型闭集增量+正文证据引文,仅用户批准的增量随正文同事务入账本 - projection_registry.py + example_projection_run(107):投影登记与恢复 - acceptance_state.py:接受前置实时状态重读 - lesson_registry.py + example_lesson(108):经验升格链,禁止自动升格 - DDL 105:example_candidate 增 semantic_status/semantic_report_sha256 - write_canonical.accept:语义兜底+同事务合并增量+登记投影; run_writer_pipeline/persist_writer_run/run_writer_semantic_detector/step2 接入全链 - claude_runtime:兼容新 CLI modelUsage 信息字段 三、审查修复(独立子代理四维审查后) - 事实增量 propose→approve 翻态正道,不撞唯一键 - 冻结配置探针重刷(CLI 2.1.211→2.1.231 漂移),profileSha256/adapterVersion 再登记 - 可视化合同悬空路径/五六空间矛盾、 SoT 旧技能名漂移、行尾空白清理 测试:离线 65 套 + 真实库集成 5 套(CAS/接受故障注入/事实增量/投影/经验升格)+ 回放 79 项全绿。 创作内容(docs/design、生成正文 artifacts)按"框架与创作分开"未入本提交。
310 lines
15 KiB
Python
310 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""deconstruct-book Skill:窗级大纲聚合(创始人 2026-07-13 拍板)。
|
||
|
||
大纲不从单章按比例抽(单章对大纲层可能零贡献),而是:
|
||
- window:5–10 万字窗(多章细纲+正文)聚合抽取一次窗级大纲 → example_parse_outline;
|
||
- check:整本抽完后,逐窗拿细纲对账大纲(漏事件/矛盾),再做全书大纲连贯性纵览。
|
||
前置:窗内章须已有脚手架细纲(example_parse_scaffold),缺则报缺不硬抽。
|
||
"""
|
||
import json
|
||
import pathlib
|
||
import sys
|
||
|
||
import click
|
||
import psycopg
|
||
|
||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "call-content-model" / "scripts"))
|
||
from llm import chat_governed, extract_json, SensitiveError # noqa: E402
|
||
|
||
# 敏感/额度降级链已上收 llm.chat_governed(BUDGET_CHAIN 全局统一 + 额度治理);本模块不再自持降级链。
|
||
|
||
|
||
def chat_degrade(prompt, model):
|
||
"""薄封装 chat_governed(全局统一降级链 + 额度治理),保留 (content, usage) 返回契约。
|
||
全链耗尽(used 为 None)时抛 SensitiveError,保留原「交上层跳窗」语义(_do_window/check 靠它跳窗)。"""
|
||
content, usage, used = chat_governed(prompt, model=model, caller="parse-outline")
|
||
if used is None:
|
||
raise SensitiveError("chat_governed 全局降级链全部耗尽(内容安全或模型不可用)")
|
||
return content, usage
|
||
|
||
DSN = ("postgresql://root:f6710e2d0294eb1c10e26a805a64bc54@100.64.0.8:5433/muse-example"
|
||
"?keepalives=1&keepalives_idle=15&keepalives_interval=5&keepalives_count=3")
|
||
TENANT, ACTOR = 1, "1"
|
||
MAX_WINDOW_GENERATION_ATTEMPTS = 2
|
||
|
||
WINDOW_PROMPT = """你是长篇小说结构分析员。下面是《{title}》第 {a}–{b} 章的**逐章细纲**与**正文**。
|
||
把这一段(约 {wc} 字)聚合成一份「阶段大纲」,回答结构层的问题(细纲已有的章内细节不要复述):
|
||
- 阶段主线:这一段整体在推进什么,起点状态→终点状态;
|
||
- 关键转折:改变走向的少数几个节点(章号+一句话);
|
||
- 人物与关系变化:谁登场定位、谁的关系变了;
|
||
- 伏笔总账:这一段埋了什么(尚未回收)、收了什么(对应哪章埋的);
|
||
- 阶段末钩子:留给下一阶段的悬念。
|
||
|
||
**全文不得超过 {cap} 字**(约为正文的 0.4%;超标会被退回)。
|
||
输出规则(只输出一个 JSON 对象):{{"outline": "阶段大纲全文"}}
|
||
|
||
【逐章细纲】
|
||
{outlines}
|
||
|
||
【正文】
|
||
{text}"""
|
||
|
||
CHECK_PROMPT = """你是长篇小说结构审校员。下面是《{title}》第 {a}–{b} 章的「阶段大纲」与该段**全部逐章细纲**。
|
||
对账:大纲是否遗漏了细纲中的重大事件/转折/伏笔?是否与细纲存在矛盾(人物/因果/顺序)?
|
||
输出规则(只输出一个 JSON 对象):
|
||
{{"ok": true/false, "issues": ["每条一句话:遗漏/矛盾点(含章号)"]}}
|
||
没有问题时 issues 给空数组。轻微详略取舍不算问题,只报结构性遗漏与事实矛盾。
|
||
|
||
【阶段大纲】
|
||
{outline}
|
||
|
||
【逐章细纲】
|
||
{outlines}"""
|
||
|
||
COHERENCE_PROMPT = """你是长篇小说结构审校员。下面是《{title}》全书 {n} 个阶段大纲(按顺序)。
|
||
纵览检查:阶段之间是否连贯(前一段钩子是否被承接、伏笔跨段是否有账、主线是否断裂)?
|
||
输出规则(只输出一个 JSON 对象):{{"ok": true/false, "issues": ["跨段问题,一句话一条"]}}
|
||
|
||
{outlines}"""
|
||
|
||
|
||
def load_chapters(conn, work_id, need_scaffold=True):
|
||
"""拉全书章(含细纲)。返回 [(order_no, 章题, 正文, 细纲|None)]。"""
|
||
return conn.execute(
|
||
"""SELECT c.order_no, c.title, b.content_text, s.outline_text
|
||
FROM muse_content_chapter c
|
||
JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=FALSE
|
||
LEFT JOIN example_parse_scaffold s ON s.chapter_id=c.id AND s.deleted=FALSE
|
||
WHERE c.tenant_id=%s AND c.work_id=%s AND c.deleted=FALSE ORDER BY c.order_no""",
|
||
(TENANT, work_id)).fetchall()
|
||
|
||
|
||
def outline_coverage_gaps(windows, first_order, last_order):
|
||
"""计算指定章域内未被任何大纲窗覆盖的连续章段。"""
|
||
|
||
if first_order > last_order:
|
||
return []
|
||
cursor = first_order
|
||
gaps = []
|
||
for raw_start, raw_end in sorted(windows):
|
||
if raw_end < first_order or raw_start > last_order:
|
||
continue
|
||
start = max(first_order, raw_start)
|
||
end = min(last_order, raw_end)
|
||
if start > cursor:
|
||
gaps.append((cursor, start - 1))
|
||
cursor = max(cursor, end + 1)
|
||
if cursor > last_order:
|
||
break
|
||
if cursor <= last_order:
|
||
gaps.append((cursor, last_order))
|
||
return gaps
|
||
|
||
|
||
def ensure_outline_coverage(windows, first_order, last_order, *, context):
|
||
"""大纲窗覆盖不完整时失败关闭,禁止 cards/check 在缺窗上制造假绿。"""
|
||
|
||
gaps = outline_coverage_gaps(windows, first_order, last_order)
|
||
if not gaps:
|
||
return
|
||
rendered = [f"{start}-{end}" for start, end in gaps]
|
||
raise click.ClickException(
|
||
f"{context}大纲窗覆盖存在缝隙章段 {rendered};先用 window --from/--to 补窗,禁止继续出卡或终检"
|
||
)
|
||
|
||
|
||
def generate_outline_with_retry(prompt, model, *, max_attempts=MAX_WINDOW_GENERATION_ATTEMPTS):
|
||
"""有限重试大纲结构输出;治理链或结构重试耗尽后立即停止当前作品。"""
|
||
|
||
last_error = "输出为空"
|
||
for _attempt in range(1, max_attempts + 1):
|
||
try:
|
||
content, usage = chat_degrade(prompt, model)
|
||
except SensitiveError as error:
|
||
# chat_governed 内部已经完成网络重试和模型降级;到这里说明整条治理链耗尽。
|
||
raise click.ClickException(f"大纲生成治理链耗尽,停止当前作品:{str(error)[:180]}") from error
|
||
try:
|
||
data = extract_json(content)
|
||
except (RuntimeError, ValueError, json.JSONDecodeError) as error:
|
||
last_error = f"结构解析失败:{str(error)[:120]}"
|
||
continue
|
||
outline = (data.get("outline") or "").strip()
|
||
if outline:
|
||
return outline, usage
|
||
last_error = "outline 为空"
|
||
raise click.ClickException(
|
||
f"大纲结构输出连续 {max_attempts} 次失败({last_error}),停止当前作品并保留断点"
|
||
)
|
||
|
||
|
||
def order_outline_windows(windows):
|
||
"""按绝对章号排列大纲窗;window_no 仅是运行展示号,补窗后可能漂移。"""
|
||
|
||
return sorted(windows, key=lambda row: (row[2], row[3]))
|
||
|
||
|
||
def render_coherence_outlines(windows):
|
||
"""按章序重新编号阶段,避免补插窗的旧 window_no 误导跨段审校。"""
|
||
|
||
return "\n\n".join(
|
||
f"【阶段{stage_no}(第{row[2]}–{row[3]}章)】\n{row[4]}"
|
||
for stage_no, row in enumerate(order_outline_windows(windows), start=1)
|
||
)
|
||
|
||
|
||
@click.group()
|
||
def cli():
|
||
"""窗级大纲聚合与终检"""
|
||
|
||
|
||
@cli.command()
|
||
@click.option("--work-id", type=int, required=True)
|
||
@click.option("--window", type=int, default=80000, show_default=True, help="每窗目标字数(5–10万区间)")
|
||
@click.option("--model", default="MiniMax-M3", show_default=True)
|
||
@click.option("--from", "from_", type=int, default=1, help="起始章(断点续跑给上次结束章+1)")
|
||
@click.option("--to", "to_", type=int, help="结束章(限定已拆域,防止切窗吞进没有细纲的章;默认全书)")
|
||
def window(work_id, window, model, from_, to_):
|
||
"""按字数切窗聚合大纲(窗内章须已有细纲;已有窗大纲的窗跳过=断点续跑)。"""
|
||
with psycopg.connect(DSN) as conn:
|
||
title = conn.execute("SELECT title FROM muse_content_work WHERE id=%s", (work_id,)).fetchone()[0]
|
||
all_rows = load_chapters(conn, work_id)
|
||
last_order = all_rows[-1][0] if all_rows else 0 # 全书末章(书末残窗判断用)
|
||
rows = [r for r in all_rows if r[0] >= from_ and (to_ is None or r[0] <= to_)]
|
||
# 幂等键=from_order(内容锚定,B4-S3/fable 审查 E2):window_no 是流水号,
|
||
# 断点续跑或参数变化时会漂移导致覆盖错窗——只作展示序号,不作跳过依据
|
||
prior = conn.execute(
|
||
"SELECT from_order, window_no FROM example_parse_outline WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
|
||
(TENANT, work_id)).fetchall()
|
||
done = {r[0] for r in prior}
|
||
# 章序切窗(与清洗窗同思路:章对齐);全量重跑序号从头计,续跑接上次
|
||
win_no, cur, cur_len = (0 if from_ <= 1 else max((r[1] for r in prior), default=0)), [], 0
|
||
for no, ct, text, outline in rows:
|
||
cur.append((no, ct, text, outline))
|
||
cur_len += len(text)
|
||
if cur_len >= window:
|
||
win_no += 1
|
||
_do_window(conn, work_id, title, win_no, cur, model, done)
|
||
cur, cur_len = [], 0
|
||
# 尾段:已到全书末章 → 无论大小独立成窗(全书拆完时尾段必须落窗,否则永远留白);
|
||
# 未到书末 → 足够大才成窗,不足留待与后续章合窗
|
||
if cur and (cur[-1][0] == last_order or cur_len >= window * 0.4):
|
||
win_no += 1
|
||
_do_window(conn, work_id, title, win_no, cur, model, done)
|
||
elif cur:
|
||
click.echo(f"尾段 {len(cur)} 章({cur_len:,} 字)不足半窗且未到书末,留待与后续章合窗")
|
||
# 收尾缝隙自检(批7 实测:三本书都在 50 章分批断点处留了永久悬空段——
|
||
# "留待合窗"的尾段被下次续跑的 from 跳过。切完就报,别等体检才发现)
|
||
allw = conn.execute(
|
||
"""SELECT from_order, to_order FROM example_parse_outline
|
||
WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE ORDER BY from_order""",
|
||
(TENANT, work_id)).fetchall()
|
||
ensure_outline_coverage(
|
||
allw,
|
||
from_,
|
||
to_ if to_ is not None else last_order,
|
||
context="window 后",
|
||
)
|
||
|
||
|
||
def _do_window(conn, work_id, title, win_no, chs, model, done):
|
||
if chs[0][0] in done: # 幂等按窗起始章判断(不按流水号)
|
||
click.echo(f"win-{win_no:02d}(第{chs[0][0]}章起)已有大纲,跳过")
|
||
return
|
||
missing = [no for no, _, _, o in chs if not o]
|
||
if missing:
|
||
click.echo(f"win-{win_no:02d}(第{chs[0][0]}–{chs[-1][0]}章)缺细纲章 {missing[:10]}…,先补 scaffold")
|
||
return
|
||
wc = sum(len(t) for _, _, t, _ in chs)
|
||
cap = max(200, int(wc * 0.004))
|
||
outlines = "\n".join(f"第{no}章《{ct}》:{o}" for no, ct, _, o in chs)
|
||
text = "\n\n".join(f"【第{no}章 | {ct}】\n{t}" for no, ct, t, _ in chs)
|
||
ol, usage = generate_outline_with_retry(
|
||
WINDOW_PROMPT.format(
|
||
title=title, a=chs[0][0], b=chs[-1][0], wc=wc, cap=cap, outlines=outlines, text=text
|
||
),
|
||
model,
|
||
)
|
||
conn.execute(
|
||
"""INSERT INTO example_parse_outline (work_id, window_no, from_order, to_order, outline_text,
|
||
creator, updater, tenant_id)
|
||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||
ON CONFLICT (tenant_id, work_id, from_order)
|
||
DO UPDATE SET outline_text=EXCLUDED.outline_text, window_no=EXCLUDED.window_no,
|
||
to_order=EXCLUDED.to_order, deleted=FALSE, check_status='pending', updater=EXCLUDED.updater""",
|
||
(work_id, win_no, chs[0][0], chs[-1][0], ol, ACTOR, ACTOR, TENANT))
|
||
conn.commit()
|
||
click.echo(f"win-{win_no:02d}(第{chs[0][0]}–{chs[-1][0]}章 {wc:,}字)大纲 {len(ol)} 字 ✓ "
|
||
f"(in={usage.get('prompt_tokens')})")
|
||
|
||
|
||
@cli.command()
|
||
@click.option("--work-id", type=int, required=True)
|
||
@click.option("--model", default="MiniMax-M3", show_default=True)
|
||
def check(work_id, model):
|
||
"""终检:逐窗细纲对账 + 全书大纲连贯性纵览(结果写 check_status/check_note)。"""
|
||
with psycopg.connect(DSN) as conn:
|
||
title = conn.execute("SELECT title FROM muse_content_work WHERE id=%s", (work_id,)).fetchone()[0]
|
||
wins = conn.execute(
|
||
"""SELECT id, window_no, from_order, to_order, outline_text, check_status
|
||
FROM example_parse_outline
|
||
WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE ORDER BY from_order""",
|
||
(TENANT, work_id)).fetchall()
|
||
wins = order_outline_windows(wins)
|
||
chs = load_chapters(conn, work_id)
|
||
if chs:
|
||
ensure_outline_coverage(
|
||
[(row[2], row[3]) for row in wins],
|
||
chs[0][0],
|
||
chs[-1][0],
|
||
context="check 前",
|
||
)
|
||
n_issue = checked_n = 0
|
||
for oid, wno, a, b, ol, st in wins:
|
||
# 断点续跑:已检窗(checked/revised)不重刷,敏感崩溃后重启只补 pending
|
||
if st and st != "pending":
|
||
continue
|
||
outlines = "\n".join(f"第{no}章《{ct}》:{o}" for no, ct, _, o in chs if a <= no <= b and o)
|
||
try:
|
||
content, _ = chat_degrade(CHECK_PROMPT.format(
|
||
title=title, a=a, b=b, outline=ol, outlines=outlines), model)
|
||
except SensitiveError as e:
|
||
# 全降级链耗尽:保留失败定位并停止本书,禁止跳窗后伪造终检完成。
|
||
conn.execute(
|
||
"UPDATE example_parse_outline SET check_status='blocked', check_note=%s, updater=%s WHERE id=%s",
|
||
(f"敏感拦截: {str(e)[:180]}", ACTOR, oid))
|
||
conn.commit()
|
||
raise click.ClickException(f"win-{wno:02d} 对账治理链耗尽,停止当前作品") from e
|
||
data = extract_json(content)
|
||
issues = data.get("issues") or []
|
||
checked_n += 1
|
||
n_issue += len(issues)
|
||
conn.execute(
|
||
"UPDATE example_parse_outline SET check_status=%s, check_note=%s, updater=%s WHERE id=%s",
|
||
("checked" if not issues else "revised",
|
||
None if not issues else json.dumps(issues, ensure_ascii=False)[:1900], ACTOR, oid))
|
||
conn.commit()
|
||
click.echo(f"win-{wno:02d} 对账{'✓' if not issues else f' 发现 {len(issues)} 处'}"
|
||
+ "".join(f"\n · {i}" for i in issues[:5]))
|
||
# 全书连贯性纵览(一次调用,只给大纲串)
|
||
if len(wins) > 1:
|
||
allo = render_coherence_outlines(wins)
|
||
try:
|
||
content, _ = chat_degrade(COHERENCE_PROMPT.format(title=title, n=len(wins), outlines=allo), model)
|
||
data = extract_json(content)
|
||
for i in (data.get("issues") or []):
|
||
click.echo(f" [跨段] {i}")
|
||
except SensitiveError:
|
||
raise click.ClickException("跨段纵览治理链耗尽,停止当前作品")
|
||
click.echo(
|
||
f"终检运行完成:总计 {len(wins)} 窗,本次实际检查 {checked_n} 窗、发现 {n_issue} 处;"
|
||
"其余窗沿用既有 check_status/check_note"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
cli()
|
||
except (psycopg.Error, RuntimeError) as e:
|
||
click.echo(f"[错误] {type(e).__name__}: {e}", err=True)
|
||
sys.exit(1)
|