237 lines
12 KiB
Python
237 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""parse-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] / "llm" / "scripts"))
|
||
from llm import chat, extract_json, SensitiveError # noqa: E402
|
||
|
||
# 敏感降级链(与 parse_llm 同款;窗大纲输入含正文,批7实测撞 new_sensitive 直接崩进程)
|
||
FALLBACK_MODELS = ["MiniMax-M2.7", "deepseek-v4-flash"]
|
||
|
||
|
||
def chat_degrade(prompt, model):
|
||
"""chat 带敏感降级:主模型被拦依次换备选;全链被拦原样抛 SensitiveError 交上层跳窗。"""
|
||
chain = [model] + FALLBACK_MODELS
|
||
for mi, m in enumerate(chain):
|
||
try:
|
||
content, usage = chat(prompt, model=m)
|
||
if m != model:
|
||
print(f"[敏感降级成功] 本条由 {m} 产出", file=sys.stderr)
|
||
return content, usage
|
||
except SensitiveError:
|
||
if mi < len(chain) - 1:
|
||
print(f"[敏感] {m} 拒绝,降级到 {chain[mi + 1]}", file=sys.stderr)
|
||
continue
|
||
raise
|
||
|
||
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"
|
||
|
||
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()
|
||
|
||
|
||
@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()
|
||
gaps = [f"{b1 + 1}-{a2 - 1}" for (_, b1), (a2, _) in zip(allw, allw[1:]) if a2 > b1 + 1]
|
||
if gaps:
|
||
click.echo(f"⚠ 本书窗覆盖存在缝隙章段 {gaps}:用 --from/--to 锁定缝隙补切"
|
||
f"(段太小不足半窗时把 --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)
|
||
try:
|
||
content, usage = chat_degrade(WINDOW_PROMPT.format(
|
||
title=title, a=chs[0][0], b=chs[-1][0], wc=wc, cap=cap, outlines=outlines, text=text), model)
|
||
except SensitiveError as e:
|
||
# 全链被拦:跳过本窗继续后续窗(窗大纲窗间独立,不必停书),重跑自动补
|
||
click.echo(f"win-{win_no:02d} 敏感全链被拦,跳过(重跑补): {str(e)[:80]}")
|
||
return
|
||
data = extract_json(content)
|
||
ol = (data.get("outline") or "").strip()
|
||
if not ol:
|
||
click.echo(f"win-{win_no:02d} 大纲为空,跳过(重跑补)")
|
||
return
|
||
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 window_no""",
|
||
(TENANT, work_id)).fetchall()
|
||
chs = load_chapters(conn, work_id)
|
||
n_issue = 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:
|
||
# 全降级链被拦:标记跳窗继续,不让单窗炸掉整书终检(批9实测超神窗20崩全程)
|
||
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()
|
||
click.echo(f"win-{wno:02d} 对账✗ 全链敏感拦截,跳过")
|
||
continue
|
||
data = extract_json(content)
|
||
issues = data.get("issues") or []
|
||
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 = "\n\n".join(f"【阶段{w[1]}(第{w[2]}–{w[3]}章)】\n{w[4]}" for w in 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:
|
||
click.echo(" [跨段] 全链敏感拦截,纵览跳过")
|
||
click.echo(f"终检完成:{len(wins)} 窗,细纲对账发现 {n_issue} 处")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
cli()
|
||
except (psycopg.Error, RuntimeError) as e:
|
||
click.echo(f"[错误] {type(e).__name__}: {e}", err=True)
|
||
sys.exit(1)
|