feat(pipeline): align hook audit with lifecycle semantics

This commit is contained in:
Ma
2026-04-01 12:56:44 +08:00
parent 2638f22927
commit f0110e8d7b
5 changed files with 150 additions and 44 deletions
@@ -156,6 +156,12 @@ describe("ContinuityAuditor", () => {
expect(systemPrompt).toContain("Hook Check");
expect(systemPrompt).toContain("Outline Drift Check");
expect(systemPrompt).toContain("stays dormant long enough to feel abandoned");
expect(systemPrompt).toContain("holds one pressure shape across a run");
expect(systemPrompt).toContain("same mode long enough to flatten rhythm");
expect(systemPrompt).not.toContain("more than 5 chapters");
expect(systemPrompt).not.toContain("3 straight chapters");
expect(systemPrompt).not.toContain("3+ consecutive chapters");
expect(systemPrompt).not.toContain("伏笔检查");
expect(systemPrompt).not.toContain("大纲偏离检测");
@@ -10,6 +10,7 @@ function createHook(overrides: Partial<HookRecord> = {}): HookRecord {
status: overrides.status ?? "open",
lastAdvancedChapter: overrides.lastAdvancedChapter ?? 1,
expectedPayoff: overrides.expectedPayoff ?? "Reveal the hidden ledger",
payoffTiming: overrides.payoffTiming,
notes: overrides.notes ?? "Still unresolved",
};
}
@@ -49,18 +50,41 @@ describe("analyzeHookHealth", () => {
expect(issues.some((issue) => issue.category === "Hook Debt" && issue.description.includes("5 active hooks"))).toBe(true);
});
it("warns when no hook has materially advanced for several chapters", () => {
it("warns when a short-payoff hook is already under payoff pressure without real movement", () => {
const issues = analyzeHookHealth({
language: "en",
chapterNumber: 4,
hooks: [
createHook({
hookId: "H001",
startChapter: 1,
lastAdvancedChapter: 1,
payoffTiming: "immediate",
expectedPayoff: "Reveal the hidden ledger immediately after the theft.",
}),
],
});
expect(issues.some((issue) => issue.description.includes("payoff pressure"))).toBe(true);
});
it("does not warn when only endgame hooks are dormant before the story reaches late phase", () => {
const issues = analyzeHookHealth({
language: "en",
chapterNumber: 20,
targetChapters: 40,
hooks: [
createHook({ hookId: "H001", lastAdvancedChapter: 12 }),
createHook({ hookId: "H002", lastAdvancedChapter: 11 }),
createHook({
hookId: "H001",
startChapter: 10,
lastAdvancedChapter: 15,
payoffTiming: "endgame",
expectedPayoff: "Final reveal in the endgame.",
}),
],
noAdvanceWindow: 5,
});
expect(issues.some((issue) => issue.description.includes("No real hook advancement"))).toBe(true);
expect(issues).toHaveLength(0);
});
it("warns when stale hooks receive no disposition in the current chapter", () => {
+8 -8
View File
@@ -170,16 +170,16 @@ function buildDimensionNote(
: "检查视角切换是否有过渡、是否与设定视角一致";
case 24:
return language === "en"
? "Cross-check subplot_board and chapter_summaries: if any subplot goes unmentioned or unadvanced for more than 5 chapters -> warning. If subplots exist but none move in the last 3 chapters -> warning."
: "对照 subplot_board 和 chapter_summaries如果任何支线超过5章未被提及或推进→warning。如果存在支线但近3章完全没有任何支线推进→warning";
? "Cross-check subplot_board and chapter_summaries: flag any subplot that stays dormant long enough to feel abandoned, or a recent run where every subplot is only restated instead of genuinely moving."
: "对照 subplot_board 和 chapter_summaries标记那些沉寂到接近被遗忘的支线,或近期连续只被重复提及、没有真实推进的支线。";
case 25:
return language === "en"
? "Cross-check emotional_arcs and chapter_summaries: if a major character shows no emotional change for 3 straight chapters (no new pressure, release, or turn) -> warning. Distinguish unchanged circumstances from unchanged inner movement."
: "对照 emotional_arcs 和 chapter_summaries如果主要角色连续3章情绪状态无变化(没有新压力、释放、转变)→warning。注意区分'角色处境未变'和'角色内心未变'";
? "Cross-check emotional_arcs and chapter_summaries: flag any major character whose emotional line holds one pressure shape across a run instead of taking new pressure, release, reversal, or reinterpretation. Distinguish unchanged circumstances from unchanged inner movement."
: "对照 emotional_arcs 和 chapter_summaries标记主要角色在一段时间内始终停留在同一种情绪压力形态、没有新压力、释放、转折或重估的情况。注意区分'处境未变'和'内心未变'";
case 26:
return language === "en"
? "Cross-check chapter_summaries for chapter-type distribution: 3+ consecutive chapters of the same type -> warning. No payoff or climax chapter for 5+ chapters -> warning. Explicitly list the recent type sequence."
: "对照 chapter_summaries 的章节类型分布:连续≥3章相同类型(如连续3个事件章/战斗章/布局章)→warning。≥5章没有出现回收章或高潮章→warning。请明确列出最近章节的类型序列";
? "Cross-check chapter_summaries for chapter-type distribution: warn when the recent sequence stays in the same mode long enough to flatten rhythm, or when payoff / release beats disappear for too long. Explicitly list the recent type sequence."
: "对照 chapter_summaries 的章节类型分布:当近期章节长时间停留在同一种模式、把节奏压平,或回收/释放/高潮章节缺席过久时给出 warning。请明确列出最近章节的类型序列";
case 28:
return language === "en"
? "Check whether spinoff events contradict the mainline canon constraints."
@@ -198,8 +198,8 @@ function buildDimensionNote(
: "检查番外是否越权回收正传伏笔(warning级别)";
case 32:
return language === "en"
? "Check: does the chapter ending provide a hook? Has there been a meaningful payoff within the last 3-5 chapters? Is emotional pressure being suppressed for more than 3 chapters without release? Are reader expectation gaps accumulating or being satisfied?"
: "检查:章尾是否有钩子?最近3-5章内是否有爽点落地?是否存在超过3章的情绪压制无释放读者的情绪缺口是否在积累或被满足";
? "Check whether the ending renews curiosity, whether promised payoffs are landing on the cadence their hooks imply, whether pressure gets any release, and whether reader expectation gaps are accumulating faster than they are being satisfied."
: "检查:章尾是否重新点燃好奇心,已经承诺的回收是否按伏笔自身节奏落地,压力是否得到释放读者期待缺口是在持续累积还是在被满足";
case 33:
return language === "en"
? "Cross-check volume_outline: does this chapter match the planned beat for the current chapter range? Did it skip planned nodes or consume later nodes too early? Does actual pacing match the planned chapter span? If a beat planned for N chapters is consumed in 1-2 chapters -> critical."
+1
View File
@@ -327,6 +327,7 @@ export class WriterAgent extends BaseAgent {
? analyzeHookHealth({
language: resolvedLanguage,
chapterNumber,
targetChapters: book.targetChapters,
hooks: (runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot)!.hooks.hooks,
delta: resolvedRuntimeStateDelta,
existingHookIds: [...priorHookIds],
+106 -31
View File
@@ -1,10 +1,12 @@
import type { AuditIssue } from "../agents/continuity.js";
import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
import { classifyHookDisposition, collectStaleHookDebt } from "./hook-governance.js";
import { describeHookLifecycle, localizeHookPayoffTiming } from "./hook-lifecycle.js";
export function analyzeHookHealth(params: {
readonly language: "zh" | "en";
readonly chapterNumber: number;
readonly targetChapters?: number;
readonly hooks: ReadonlyArray<HookRecord>;
readonly delta?: Pick<RuntimeStateDelta, "chapter" | "hookOps">;
readonly existingHookIds?: ReadonlyArray<string>;
@@ -20,6 +22,19 @@ export function analyzeHookHealth(params: {
const issues: AuditIssue[] = [];
const activeHooks = params.hooks.filter((hook) => hook.status !== "resolved");
const lifecycleEntries = activeHooks.map((hook) => ({
hook,
lifecycle: describeHookLifecycle({
payoffTiming: hook.payoffTiming,
expectedPayoff: hook.expectedPayoff,
notes: hook.notes,
startChapter: hook.startChapter,
lastAdvancedChapter: hook.lastAdvancedChapter,
status: hook.status,
chapterNumber: params.chapterNumber,
targetChapters: params.targetChapters,
}),
}));
if (activeHooks.length > maxActiveHooks) {
issues.push(warning(
@@ -33,45 +48,58 @@ export function analyzeHookHealth(params: {
));
}
const latestRealAdvance = activeHooks.reduce(
(max, hook) => Math.max(max, hook.lastAdvancedChapter),
0,
);
if (activeHooks.length > 0 && params.chapterNumber - latestRealAdvance >= noAdvanceWindow) {
issues.push(warning(
params.language,
params.language === "en"
? `No real hook advancement has landed for ${params.chapterNumber - latestRealAdvance} chapters.`
: `已经连续 ${params.chapterNumber - latestRealAdvance} 章没有真实伏笔推进。`,
params.language === "en"
? "Schedule one old hook for real movement instead of opening parallel restatements."
: "下一章优先让一个旧伏笔发生真实推进,而不是继续平行重述。",
));
}
const staleHooks = collectStaleHookDebt({
const staleHookIds = new Set(collectStaleHookDebt({
hooks: activeHooks,
chapterNumber: params.chapterNumber,
targetChapters: params.targetChapters,
staleAfterChapters,
});
if (params.delta && staleHooks.length > 0) {
const untouchedStale = staleHooks.filter((hook) => {
const disposition = classifyHookDisposition({
hookId: hook.hookId,
delta: params.delta!,
});
return disposition === "none" || disposition === "mention";
});
}).map((hook) => hook.hookId));
const pressuredHooks = lifecycleEntries.filter(({ hook, lifecycle }) =>
staleHookIds.has(hook.hookId)
|| lifecycle.readyToResolve
|| lifecycle.overdue,
);
const unresolvedPressure = pressuredHooks.filter(({ hook }) => {
if (!params.delta) {
return true;
}
if (untouchedStale.length > 0) {
const disposition = classifyHookDisposition({
hookId: hook.hookId,
delta: params.delta,
});
return disposition === "none" || disposition === "mention";
});
if (unresolvedPressure.length > 0) {
issues.push(warning(
params.language,
buildPressureDescription({
language: params.language,
entries: unresolvedPressure,
mentionsCurrentChapter: Boolean(params.delta),
}),
params.language === "en"
? "Move one pressured hook with a real payoff, escalation, or explicit defer before opening adjacent debt."
: "先让一个已进入压力区的伏笔发生真实推进、回收或明确延后,再继续扩展同类债务。",
));
} else {
const latestRealAdvance = activeHooks.reduce(
(max, hook) => Math.max(max, hook.lastAdvancedChapter),
0,
);
if (
params.noAdvanceWindow !== undefined
&& activeHooks.length > 0
&& params.chapterNumber - latestRealAdvance >= noAdvanceWindow
) {
issues.push(warning(
params.language,
params.language === "en"
? `Stale hooks received no real disposition this chapter: ${untouchedStale.map((hook) => hook.hookId).join(", ")}.`
: `本章没有真正处理这些陈旧伏笔:${untouchedStale.map((hook) => hook.hookId).join("、")}`,
? `No real hook advancement has landed for ${params.chapterNumber - latestRealAdvance} chapters.`
: `已经连续 ${params.chapterNumber - latestRealAdvance} 章没有真实伏笔推进`,
params.language === "en"
? "Advance, resolve, or explicitly defer at least one stale hook."
: "至少推进、回收或明确延后一个旧伏笔。",
? "Schedule one old hook for real movement instead of opening parallel restatements."
: "下一章优先让一个旧伏笔发生真实推进,而不是继续平行重述。",
));
}
}
@@ -99,6 +127,53 @@ export function analyzeHookHealth(params: {
return issues;
}
function buildPressureDescription(params: {
readonly language: "zh" | "en";
readonly entries: ReadonlyArray<{
readonly hook: HookRecord;
readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
}>;
readonly mentionsCurrentChapter: boolean;
}): string {
const summarized = params.entries
.slice(0, 3)
.map(({ hook, lifecycle }) => {
const timing = localizeHookPayoffTiming(lifecycle.timing, params.language);
const pressure = localizePressureLabel(lifecycle, params.language);
return params.language === "en"
? `${hook.hookId} (${timing}, ${pressure})`
: `${hook.hookId}${timing}${pressure}`;
});
const suffix = params.entries.length > summarized.length
? params.language === "en"
? `, +${params.entries.length - summarized.length} more`
: `,另有 ${params.entries.length - summarized.length}`
: "";
if (params.language === "en") {
return params.mentionsCurrentChapter
? `Hooks are already under payoff pressure but this chapter left them untouched: ${summarized.join(", ")}${suffix}.`
: `Hooks are already under payoff pressure without recent movement: ${summarized.join(", ")}${suffix}.`;
}
return params.mentionsCurrentChapter
? `这些伏笔已经进入回收/推进压力,但本章没有真正处理:${summarized.join("、")}${suffix}`
: `这些伏笔已经进入回收/推进压力,但近期没有真实推进:${summarized.join("、")}${suffix}`;
}
function localizePressureLabel(
lifecycle: ReturnType<typeof describeHookLifecycle>,
language: "zh" | "en",
): string {
if (lifecycle.overdue) {
return language === "en" ? "overdue" : "已逾期";
}
if (lifecycle.readyToResolve) {
return language === "en" ? "ready to pay off" : "可回收";
}
return language === "en" ? "stale" : "陈旧";
}
function warning(
language: "zh" | "en",
description: string,