Преглед изворни кода

fix: normalize retry + hook agenda tiers + length out of reviser

1. Normalize retry: normalizeUntilInRange runs up to 2 passes before
   the loop AND after each revision inside the loop. Length is now
   a dedicated step, not mixed into reviser issues.

2. Length removed from assess() issues: normalize handles length,
   reviser handles quality. lengthInRange stays as isPassed() hard gate.

3. Hook agenda restructured from soft suggestions to tiered directives:
   - 兑现 (Resolve): must deliver concrete payoff this chapter
   - 推进 (Advance): must show visible progress
   - 逾期债务 (Stale Debt): overdue hooks, prioritize clearing
   - 禁开新坑 (Do Not Open): avoid new hooks in these families
   Removed soft "建议" language throughout.

All 516 tests pass.
Ma пре 7 месеци
родитељ
комит
ae144b5817

+ 4 - 4
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -1579,7 +1579,7 @@ describe("PipelineRunner", () => {
       await runner.writeNextChapter(bookId, 220);
 
       // v9: normalization happens once before the scoring loop, not after revision
-      expect(normalizeChapter).toHaveBeenCalledTimes(1);
+      expect(normalizeChapter).toHaveBeenCalled();
       expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
         chapterContent: overlongDraft,
         lengthSpec: expect.objectContaining({
@@ -1632,7 +1632,7 @@ describe("PipelineRunner", () => {
     try {
       await runner.writeNextChapter(bookId, 220);
 
-      expect(normalizeChapter).toHaveBeenCalledTimes(1);
+      expect(normalizeChapter).toHaveBeenCalled();
       expect(auditChapter.mock.calls[0]?.[1]).toBe(normalizedDraft);
     } finally {
       await rm(root, { recursive: true, force: true });
@@ -1680,7 +1680,7 @@ describe("PipelineRunner", () => {
     try {
       await runner.writeNextChapter(bookId, 220);
 
-      expect(normalizeChapter).toHaveBeenCalledTimes(1);
+      expect(normalizeChapter).toHaveBeenCalled();
       expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
         chapterContent: shortDraft,
         lengthSpec: expect.objectContaining({
@@ -1735,7 +1735,7 @@ describe("PipelineRunner", () => {
       const chapterIndex = await state.loadChapterIndex(bookId);
       const chapterMeta = chapterIndex.find((entry) => entry.number === 1);
 
-      expect(normalizeChapter).toHaveBeenCalledTimes(1);
+      expect(normalizeChapter).toHaveBeenCalled();
       expect((result as { lengthWarnings?: ReadonlyArray<string> }).lengthWarnings?.[0]).toContain(
         "超出硬区间",
       );

+ 12 - 11
packages/core/src/agents/planner.ts

@@ -666,26 +666,27 @@ export class PlannerAgent extends BaseAgent {
       intent.moodDirective ? `- mood: ${intent.moodDirective}` : undefined,
       intent.titleDirective ? `- title: ${intent.titleDirective}` : undefined,
     ].filter(Boolean).join("\n") || "- none";
+    const isEn = language === "en";
     const hookAgenda = [
-      "### Must Advance",
-      intent.hookAgenda.mustAdvance.length > 0
-        ? intent.hookAgenda.mustAdvance.map((item) => `- ${item}`).join("\n")
-        : "- none",
-      "",
-      "### Eligible Resolve",
+      isEn ? "### Resolve — must deliver a concrete payoff this chapter" : "### 兑现(本章必须落地具体回收)",
       intent.hookAgenda.eligibleResolve.length > 0
         ? intent.hookAgenda.eligibleResolve.map((item) => `- ${item}`).join("\n")
-        : "- none",
+        : isEn ? "- none this round" : "- 本轮无",
+      "",
+      isEn ? "### Advance — must show visible progress this chapter" : "### 推进(本章必须有可见进展)",
+      intent.hookAgenda.mustAdvance.length > 0
+        ? intent.hookAgenda.mustAdvance.map((item) => `- ${item}`).join("\n")
+        : isEn ? "- none this round" : "- 本轮无",
       "",
-      "### Stale Debt",
+      isEn ? "### Stale Debt — overdue hooks, prioritize clearing" : "### 逾期债务(积压伏笔,优先清理)",
       intent.hookAgenda.staleDebt.length > 0
         ? intent.hookAgenda.staleDebt.map((item) => `- ${item}`).join("\n")
-        : "- none",
+        : isEn ? "- none" : "- 无",
       "",
-      "### Avoid New Hook Families",
+      isEn ? "### Do Not Open — avoid new hooks in these families" : "### 禁开新坑(以下类型不得新增伏笔)",
       intent.hookAgenda.avoidNewHookFamilies.length > 0
         ? intent.hookAgenda.avoidNewHookFamilies.map((item) => `- ${item}`).join("\n")
-        : "- none",
+        : isEn ? "- none" : "- 无",
       "",
       this.renderHookBudget(activeHookCount, language),
     ].join("\n");

+ 37 - 21
packages/core/src/pipeline/chapter-review-cycle.ts

@@ -112,10 +112,31 @@ export async function runChapterReviewCycle(params: {
   }));
 
   // ---------------------------------------------------------------------------
-  // Initial length normalization (once, before entering the loop)
+  // Length normalization: dedicated step, runs until in softRange (max 2 passes)
+  // Length is NOT mixed into the reviser's issues — normalize handles it.
   // ---------------------------------------------------------------------------
-  const normalizedBeforeAudit = await params.normalizeDraftLengthIfNeeded(finalContent);
-  totalUsage = params.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
+  const normalizeUntilInRange = async (content: string): Promise<{
+    content: string;
+    wordCount: number;
+    applied: boolean;
+  }> => {
+    let current = content;
+    let applied = false;
+    for (let attempt = 0; attempt < 2; attempt++) {
+      const wc = countChapterLength(current, params.lengthSpec.countingMode);
+      if (!isOutsideSoftRange(wc, params.lengthSpec)) {
+        return { content: current, wordCount: wc, applied };
+      }
+      const result = await params.normalizeDraftLengthIfNeeded(current);
+      totalUsage = params.addUsage(totalUsage, result.tokenUsage);
+      current = result.content;
+      applied = applied || result.applied;
+    }
+    const finalWc = countChapterLength(current, params.lengthSpec.countingMode);
+    return { content: current, wordCount: finalWc, applied };
+  };
+
+  const normalizedBeforeAudit = await normalizeUntilInRange(finalContent);
   finalContent = normalizedBeforeAudit.content;
   finalWordCount = normalizedBeforeAudit.wordCount;
   normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied;
@@ -151,18 +172,8 @@ export async function runChapterReviewCycle(params: {
       ...(options?.extraIssues ?? []),
     ];
 
-    // Add length issue if out of range
-    if (!lengthInRange) {
-      const direction = wordCount < params.lengthSpec.softMin ? "short" : "long";
-      allIssues.push({
-        severity: "critical",
-        category: "length",
-        description: `Chapter word count ${wordCount} is outside target range ${params.lengthSpec.softMin}-${params.lengthSpec.softMax} (too ${direction}). Must be brought within range.`,
-        suggestion: direction === "long"
-          ? "Compress the chapter: trim redundant explanations, repeated actions, and weak-information sentences."
-          : "Expand key scenes with more sensory detail or dialogue to reach the target range.",
-      });
-    }
+    // Length is NOT added to reviser issues — normalize handles it as a dedicated step.
+    // lengthInRange is only used in isPassed() as a hard gate.
 
     const hasExtraCritical = (options?.extraIssues ?? []).some((i) => i.severity === "critical");
     const auditResult: AuditResult = {
@@ -226,13 +237,18 @@ export async function runChapterReviewCycle(params: {
       }
 
       params.assertChapterContentNotEmpty(reviseOutput.revisedContent, `repair iteration ${iteration + 1}`);
-      const revisedWordCount = countChapterLength(reviseOutput.revisedContent, params.lengthSpec.countingMode);
 
-      // Re-assess the revised content
-      const nextAssessment = await assess(reviseOutput.revisedContent, { temperature: 0 });
+      // Re-normalize after revision (REVISED_CONTENT can blow up length)
+      const postReviseNorm = await normalizeUntilInRange(reviseOutput.revisedContent);
+      normalizeApplied = normalizeApplied || postReviseNorm.applied;
+      const revisedContent = postReviseNorm.content;
+      const revisedWordCount = postReviseNorm.wordCount;
+
+      // Re-assess the revised + normalized content
+      const nextAssessment = await assess(revisedContent, { temperature: 0 });
 
       snapshots.push({
-        content: reviseOutput.revisedContent,
+        content: revisedContent,
         wordCount: revisedWordCount,
         auditResult: nextAssessment.auditResult,
         score: nextAssessment.score,
@@ -244,7 +260,7 @@ export async function runChapterReviewCycle(params: {
           zh: `修复后达到通过线(${nextAssessment.score} 分),退出循环`,
           en: `repair reached pass threshold (${nextAssessment.score}), exiting loop`,
         });
-        finalContent = reviseOutput.revisedContent;
+        finalContent = revisedContent;
         finalWordCount = revisedWordCount;
         postReviseCount = revisedWordCount;
         currentAudit = nextAssessment;
@@ -253,7 +269,7 @@ export async function runChapterReviewCycle(params: {
 
       // Check net improvement
       if (nextAssessment.score >= currentAudit.score + NET_IMPROVEMENT_EPSILON) {
-        finalContent = reviseOutput.revisedContent;
+        finalContent = revisedContent;
         finalWordCount = revisedWordCount;
         postReviseCount = revisedWordCount;
         currentAudit = nextAssessment;