Sfoglia il codice sorgente

fix(v13): run promotion pass per-chapter in pipeline runner [hotfix 2]

Extract rerunPromotionPass() and deriveAdvancedCountsFromSummaries()
from consolidator into utils/hook-promotion.ts. Add a lightweight
per-chapter promotion check in _writeNextChapterLocked (after
review-revise-polish, before persistence) so promoted flags update
immediately rather than waiting for manual consolidation.
Ma 6 mesi fa
parent
commit
efe67aeaef

+ 7 - 62
packages/core/src/agents/consolidator.ts

@@ -151,15 +151,8 @@ export class ConsolidatorAgent extends BaseAgent {
 
   /**
    * Phase 7 hotfix 2 — re-run promotion for seeds whose advancedCount has
-   * crossed the 2-chapter threshold since architect seed time. Structural
-   * rules (core_hook / depends_on / cross_volume) are already applied at
-   * architect time and stay sticky (once promoted, always promoted).
-   *
-   * advancedCount sources (first-win):
-   *   1. Explicit hook.advancedCount (test fixture path — our runtime doesn't
-   *      yet persist this).
-   *   2. Derived from chapter_summaries.md: count chapters whose hookActivity
-   *      cell mentions the hook id.
+   * crossed the 2-chapter threshold since architect seed time. Delegates to
+   * the shared `rerunPromotionPass` in utils/hook-promotion.ts.
    *
    * Returns the number of hooks that flipped from promoted=false (or
    * undefined) to promoted=true this run.
@@ -173,62 +166,14 @@ export class ConsolidatorAgent extends BaseAgent {
     if (hooks.length === 0) return 0;
 
     const language: "zh" | "en" = /[\u4e00-\u9fff]/.test(raw) ? "zh" : "en";
-
     const summariesRaw = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
-    const derivedCounts = this.deriveAdvancedCountsFromSummaries(
-      summariesRaw,
-      hooks.map((h) => h.hookId),
-    );
-
-    let flipped = 0;
-    const nextHooks: StoredHook[] = hooks.map((hook) => {
-      if (hook.promoted === true) return hook;
-      const advanced = hook.advancedCount ?? derivedCounts.get(hook.hookId) ?? 0;
-      if (advanced >= 2) {
-        flipped += 1;
-        return { ...hook, promoted: true };
-      }
-      // Preserve existing `promoted` (possibly undefined for legacy
-      // 11/12-column ledgers) when the threshold is not met. We deliberately
-      // do NOT override architect-set promoted=false here — the structural
-      // rules ran at architect time; rewriting them would fight that.
-      return hook;
-    });
 
-    if (flipped === 0) return 0;
-
-    await writeFile(ledgerPath, renderHookSnapshot(nextHooks, language), "utf-8");
-    return flipped;
-  }
+    const { rerunPromotionPass } = await import("../utils/hook-promotion.js");
+    const result = rerunPromotionPass(hooks, summariesRaw);
+    if (!result.updated) return 0;
 
-  /**
-   * Count how many chapter summaries mention each hook id in the hookActivity
-   * column. Crude but sufficient: chapter_summaries is the consolidator's
-   * source of truth for "what happened per chapter" and every reducer writes
-   * hook activity into it.
-   */
-  private deriveAdvancedCountsFromSummaries(
-    summariesRaw: string,
-    hookIds: ReadonlyArray<string>,
-  ): Map<string, number> {
-    const counts = new Map<string, number>();
-    if (!summariesRaw.trim() || hookIds.length === 0) return counts;
-
-    const lines = summariesRaw.split("\n");
-    for (const hookId of hookIds) {
-      // Escape regex meta characters in the hook id defensively.
-      const escaped = hookId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-      const pattern = new RegExp(escaped, "i");
-      let count = 0;
-      for (const line of lines) {
-        if (!line.startsWith("|")) continue;
-        // Skip header / separator rows.
-        if (line.includes("---") || /\|\s*(章节|Chapter)\s*\|/i.test(line)) continue;
-        if (pattern.test(line)) count += 1;
-      }
-      if (count > 0) counts.set(hookId, count);
-    }
-    return counts;
+    await writeFile(ledgerPath, renderHookSnapshot([...result.hooks], language), "utf-8");
+    return result.flippedCount;
   }
 
   private parseVolumeBoundaries(outline: string): Array<{ name: string; startCh: number; endCh: number }> {

+ 24 - 0
packages/core/src/pipeline/runner.ts

@@ -1285,6 +1285,30 @@ export class PipelineRunner {
       }
     }
 
+    // 3c. Lightweight per-chapter promotion pass — check if any hooks should
+    // be promoted based on advanced_count derived from chapter_summaries.
+    // Runs BEFORE persistence so the reviewer of the NEXT chapter sees the
+    // updated ledger. No LLM calls — pure ledger parse + threshold check.
+    {
+      const { rerunPromotionPass } = await import("../utils/hook-promotion.js");
+      const { parsePendingHooksMarkdown, renderHookSnapshot } = await import("../utils/story-markdown.js");
+      const promotionStoryDir = join(bookDir, "story");
+      const ledgerPath = join(promotionStoryDir, "pending_hooks.md");
+      const ledgerRaw = await readFile(ledgerPath, "utf-8").catch(() => "");
+      if (ledgerRaw.trim()) {
+        const hooks = parsePendingHooksMarkdown(ledgerRaw);
+        if (hooks.length > 0) {
+          const summariesRaw = await readFile(join(promotionStoryDir, "chapter_summaries.md"), "utf-8").catch(() => "");
+          const promotionResult = rerunPromotionPass(hooks, summariesRaw);
+          if (promotionResult.updated) {
+            const ledgerLang: "zh" | "en" = /[\u4e00-\u9fff]/.test(ledgerRaw) ? "zh" : "en";
+            await writeFile(ledgerPath, renderHookSnapshot([...promotionResult.hooks], ledgerLang), "utf-8");
+            this.config.logger?.info(`[promotion] ${promotionResult.flippedCount} hook(s) promoted after chapter ${chapterNumber}`);
+          }
+        }
+      }
+    }
+
     // 4. Save the final chapter and truth files from a single persistence source
     this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" });
     this.logStage(stageLanguage, { zh: "生成最终真相文件", en: "rebuilding final truth files" });

+ 114 - 0
packages/core/src/utils/hook-promotion.ts

@@ -189,3 +189,117 @@ export function defaultHalfLifeChapters(
 export function resolveHalfLifeChapters(hook: StoredHook): number {
   return hook.halfLifeChapters ?? defaultHalfLifeChapters(hook.payoffTiming);
 }
+
+// ---------------------------------------------------------------------------
+// Shared advanced_count promotion pass (used by both consolidator and runner)
+// ---------------------------------------------------------------------------
+
+export interface PromotionPassResult {
+  /** Whether any hook flipped to promoted=true. */
+  readonly updated: boolean;
+  /** The (possibly updated) hooks array. */
+  readonly hooks: ReadonlyArray<StoredHook>;
+  /** Number of hooks that flipped from non-promoted to promoted. */
+  readonly flippedCount: number;
+}
+
+/**
+ * Derive hook advancement counts from chapter_summaries.md content.
+ *
+ * Counts how many chapter-summary data rows mention each hook id in the
+ * hookActivity column (index 5, 0-based — "伏笔动态" / "hookActivity").
+ */
+export function deriveAdvancedCountsFromSummaries(
+  summariesRaw: string,
+  hookIds: ReadonlyArray<string>,
+): Map<string, number> {
+  const counts = new Map<string, number>();
+  if (!summariesRaw.trim() || hookIds.length === 0) return counts;
+
+  const lines = summariesRaw.split("\n");
+  // Detect hookActivity column index from the header row.
+  const hookActivityIndex = detectHookActivityColumnIndex(lines);
+
+  for (const hookId of hookIds) {
+    const escaped = hookId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+    const pattern = new RegExp(`\\b${escaped}\\b`, "i");
+    let count = 0;
+    for (const line of lines) {
+      if (!line.startsWith("|")) continue;
+      // Skip header / separator rows.
+      if (line.includes("---") || /\|\s*(章节|Chapter)\s*\|/i.test(line)) continue;
+      // Only match in the hookActivity column, not the full row.
+      const cell = extractColumn(line, hookActivityIndex);
+      if (cell !== null && pattern.test(cell)) count += 1;
+    }
+    if (count > 0) counts.set(hookId, count);
+  }
+  return counts;
+}
+
+/**
+ * Find the 0-based column index of the hookActivity / 伏笔动态 header.
+ * Falls back to 5 (the standard position in our schema).
+ */
+function detectHookActivityColumnIndex(lines: ReadonlyArray<string>): number {
+  const DEFAULT_INDEX = 5;
+  for (const line of lines) {
+    if (!line.startsWith("|")) continue;
+    if (/\|\s*(章节|Chapter)\s*\|/i.test(line)) {
+      const cols = line.split("|").map((c) => c.trim());
+      const idx = cols.findIndex((c) => /^(伏笔动态|hookActivity)$/i.test(c));
+      return idx >= 0 ? idx : DEFAULT_INDEX;
+    }
+  }
+  return DEFAULT_INDEX;
+}
+
+/**
+ * Extract a single column value from a pipe-delimited table row.
+ * Returns null when the column index is out of range.
+ */
+function extractColumn(row: string, index: number): string | null {
+  const cols = row.split("|");
+  if (index >= 0 && index < cols.length) {
+    return cols[index]!.trim();
+  }
+  return null;
+}
+
+/**
+ * Lightweight promotion pass: read hooks + chapter_summaries, check
+ * advancedCount >= 2, flip promoted flag. No LLM calls.
+ *
+ * Returns the result without doing any I/O — caller decides whether to
+ * persist.
+ */
+export function rerunPromotionPass(
+  hooks: ReadonlyArray<StoredHook>,
+  summariesRaw: string,
+): PromotionPassResult {
+  if (hooks.length === 0) {
+    return { updated: false, hooks, flippedCount: 0 };
+  }
+
+  const derivedCounts = deriveAdvancedCountsFromSummaries(
+    summariesRaw,
+    hooks.map((h) => h.hookId),
+  );
+
+  let flipped = 0;
+  const nextHooks: StoredHook[] = hooks.map((hook) => {
+    if (hook.promoted === true) return hook;
+    const advanced = hook.advancedCount ?? derivedCounts.get(hook.hookId) ?? 0;
+    if (advanced >= 2) {
+      flipped += 1;
+      return { ...hook, promoted: true };
+    }
+    return hook;
+  });
+
+  return {
+    updated: flipped > 0,
+    hooks: nextHooks,
+    flippedCount: flipped,
+  };
+}