Explorar o código

refactor(pipeline): unify legacy context windows

Ma hai 7 meses
pai
achega
ba7f7c5f08

+ 23 - 2
packages/core/src/__tests__/context-filter.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
 import { describe, expect, it } from "vitest";
-import { filterSummaries } from "../utils/context-filter.js";
+import { filterEmotionalArcs, filterSummaries } from "../utils/context-filter.js";
 
 
 describe("context-filter", () => {
 describe("context-filter", () => {
   it("filters old chapter summary rows even when titles start with 'Chapter'", () => {
   it("filters old chapter summary rows even when titles start with 'Chapter'", () => {
@@ -8,13 +8,34 @@ describe("context-filter", () => {
       "",
       "",
       "| 1 | Chapter 1 | Lin Yue | Old event | state-1 | side-quest-1 | tense | drama |",
       "| 1 | Chapter 1 | Lin Yue | Old event | state-1 | side-quest-1 | tense | drama |",
       "| 97 | Chapter 97 | Lin Yue | Recent event | state-97 | side-quest-97 | tense | drama |",
       "| 97 | Chapter 97 | Lin Yue | Recent event | state-97 | side-quest-97 | tense | drama |",
+      "| 98 | Chapter 98 | Lin Yue | New event | state-98 | side-quest-98 | tense | drama |",
       "| 100 | Chapter 100 | Lin Yue | Latest event | state-100 | mentor-oath advanced | tense | drama |",
       "| 100 | Chapter 100 | Lin Yue | Latest event | state-100 | mentor-oath advanced | tense | drama |",
     ].join("\n");
     ].join("\n");
 
 
     const filtered = filterSummaries(summaries, 101);
     const filtered = filterSummaries(summaries, 101);
 
 
     expect(filtered).not.toContain("| 1 | Chapter 1 |");
     expect(filtered).not.toContain("| 1 | Chapter 1 |");
-    expect(filtered).toContain("| 97 | Chapter 97 |");
+    expect(filtered).not.toContain("| 97 | Chapter 97 |");
+    expect(filtered).toContain("| 98 | Chapter 98 |");
     expect(filtered).toContain("| 100 | Chapter 100 |");
     expect(filtered).toContain("| 100 | Chapter 100 |");
   });
   });
+
+  it("keeps only the cadence-sized recent emotional arc rows by default", () => {
+    const arcs = [
+      "# Emotional Arcs",
+      "",
+      "| Character | Chapter | Emotional State | Trigger Event | Intensity (1-10) | Arc Direction |",
+      "| --- | --- | --- | --- | --- | --- |",
+      "| Lin Yue | 97 | guarded | old wound | 4 | holding |",
+      "| Lin Yue | 98 | tense | harbor clue | 6 | rising |",
+      "| Lin Yue | 99 | strained | mentor echo | 7 | tightening |",
+      "| Lin Yue | 100 | brittle | oath pressure | 8 | compressing |",
+    ].join("\n");
+
+    const filtered = filterEmotionalArcs(arcs, 101);
+
+    expect(filtered).not.toContain("| Lin Yue | 97 |");
+    expect(filtered).toContain("| Lin Yue | 98 |");
+    expect(filtered).toContain("| Lin Yue | 100 |");
+  });
 });
 });

+ 24 - 2
packages/core/src/__tests__/governed-working-set.test.ts

@@ -31,8 +31,8 @@ describe("governed-working-set", () => {
       language: "zh",
       language: "zh",
     });
     });
 
 
-    expect(filtered).toContain("| opening-call | 1 | mystery | open | 0 | 8 | 匿名来电开篇出现 |");
-    expect(filtered).toContain("| nearby-ledger | 4 | evidence | open | 0 | 12 | 近期开启的账本线 |");
+    expect(filtered).toContain("opening-call");
+    expect(filtered).toContain("nearby-ledger");
     expect(filtered).not.toContain("future-pr-machine");
     expect(filtered).not.toContain("future-pr-machine");
     expect(filtered).not.toContain("future-template");
     expect(filtered).not.toContain("future-template");
   });
   });
@@ -83,6 +83,28 @@ describe("governed-working-set", () => {
     expect(filtered).not.toContain("future-pr-machine");
     expect(filtered).not.toContain("future-pr-machine");
   });
   });
 
 
+  it("keeps slow-burn hooks in the governed working set when they are still within lifecycle visibility", () => {
+    const hooks = [
+      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| river-oath | 8 | relationship | progressing | 13 | Reveal why the river oath was broken | slow-burn | Long debt should stay visible through the middle game |",
+      "| future-pr-machine | 45 | system | open | 0 | Future hook should stay hidden | endgame | Future hook should stay hidden |",
+    ].join("\n");
+
+    const filtered = buildGovernedHookWorkingSet({
+      hooksMarkdown: hooks,
+      contextPackage: {
+        chapter: 20,
+        selectedContext: [],
+      },
+      chapterNumber: 20,
+      language: "en",
+    });
+
+    expect(filtered).toContain("river-oath");
+    expect(filtered).not.toContain("future-pr-machine");
+  });
+
   it("filters character matrix by exact governed character mentions instead of broad capitalized tokens", () => {
   it("filters character matrix by exact governed character mentions instead of broad capitalized tokens", () => {
     const matrix = [
     const matrix = [
       "# Character Matrix",
       "# Character Matrix",

+ 3 - 1
packages/core/src/utils/chapter-cadence.ts

@@ -30,6 +30,8 @@ export interface ChapterCadenceAnalysis {
   readonly titlePressure?: TitleCadencePressure;
   readonly titlePressure?: TitleCadencePressure;
 }
 }
 
 
+export const DEFAULT_CHAPTER_CADENCE_WINDOW = 4;
+
 const HIGH_TENSION_KEYWORDS = [
 const HIGH_TENSION_KEYWORDS = [
   "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重",
   "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重",
   "冷峻", "压迫", "阴沉", "焦灼", "窒息", "凛冽", "锋利",
   "冷峻", "压迫", "阴沉", "焦灼", "窒息", "凛冽", "锋利",
@@ -49,7 +51,7 @@ export function analyzeChapterCadence(params: {
 }): ChapterCadenceAnalysis {
 }): ChapterCadenceAnalysis {
   const recentRows = [...params.rows]
   const recentRows = [...params.rows]
     .sort((left, right) => left.chapter - right.chapter)
     .sort((left, right) => left.chapter - right.chapter)
-    .slice(-4);
+    .slice(-DEFAULT_CHAPTER_CADENCE_WINDOW);
 
 
   return {
   return {
     scenePressure: analyzeScenePressure(recentRows),
     scenePressure: analyzeScenePressure(recentRows),

+ 12 - 2
packages/core/src/utils/context-filter.ts

@@ -5,6 +5,8 @@
  * Every filter falls back to the full input if filtering would empty it.
  * Every filter falls back to the full input if filtering would empty it.
  */
  */
 
 
+import { DEFAULT_CHAPTER_CADENCE_WINDOW } from "./chapter-cadence.js";
+
 /** Filter pending_hooks: remove resolved/closed hooks. */
 /** Filter pending_hooks: remove resolved/closed hooks. */
 export function filterHooks(hooks: string): string {
 export function filterHooks(hooks: string): string {
   if (!hooks || hooks === "(文件尚未创建)") return hooks;
   if (!hooks || hooks === "(文件尚未创建)") return hooks;
@@ -15,7 +17,11 @@ export function filterHooks(hooks: string): string {
 }
 }
 
 
 /** Filter chapter_summaries: keep only the most recent N chapters. */
 /** Filter chapter_summaries: keep only the most recent N chapters. */
-export function filterSummaries(summaries: string, currentChapter: number, keepRecent = 5): string {
+export function filterSummaries(
+  summaries: string,
+  currentChapter: number,
+  keepRecent = DEFAULT_CHAPTER_CADENCE_WINDOW,
+): string {
   if (!summaries || summaries === "(文件尚未创建)") return summaries;
   if (!summaries || summaries === "(文件尚未创建)") return summaries;
   return filterTableRows(summaries, (row) => {
   return filterTableRows(summaries, (row) => {
     const match = row.match(/\|\s*(\d+)\s*\|/);
     const match = row.match(/\|\s*(\d+)\s*\|/);
@@ -34,7 +40,11 @@ export function filterSubplots(board: string): string {
 }
 }
 
 
 /** Filter emotional_arcs: keep only the most recent N chapters. */
 /** Filter emotional_arcs: keep only the most recent N chapters. */
-export function filterEmotionalArcs(arcs: string, currentChapter: number, keepRecent = 5): string {
+export function filterEmotionalArcs(
+  arcs: string,
+  currentChapter: number,
+  keepRecent = DEFAULT_CHAPTER_CADENCE_WINDOW,
+): string {
   if (!arcs || arcs === "(文件尚未创建)") return arcs;
   if (!arcs || arcs === "(文件尚未创建)") return arcs;
   return filterTableRows(arcs, (row) => {
   return filterTableRows(arcs, (row) => {
     const match = row.match(/\|\s*(\d+)\s*\|/);
     const match = row.match(/\|\s*(\d+)\s*\|/);

+ 19 - 1
packages/core/src/utils/governed-working-set.ts

@@ -1,9 +1,11 @@
 import type { ContextPackage } from "../models/input-governance.js";
 import type { ContextPackage } from "../models/input-governance.js";
 import {
 import {
   isHookWithinChapterWindow,
   isHookWithinChapterWindow,
+  isHookWithinLifecycleWindow,
   parsePendingHooksMarkdown,
   parsePendingHooksMarkdown,
   renderHookSnapshot,
   renderHookSnapshot,
 } from "./memory-retrieval.js";
 } from "./memory-retrieval.js";
+import { describeHookLifecycle } from "./hook-lifecycle.js";
 
 
 export function buildGovernedHookWorkingSet(params: {
 export function buildGovernedHookWorkingSet(params: {
   readonly hooksMarkdown: string;
   readonly hooksMarkdown: string;
@@ -33,7 +35,23 @@ export function buildGovernedHookWorkingSet(params: {
   const workingSet = hooks.filter((hook) =>
   const workingSet = hooks.filter((hook) =>
     selectedIds.has(hook.hookId)
     selectedIds.has(hook.hookId)
       || agendaIds.has(hook.hookId)
       || agendaIds.has(hook.hookId)
-      || isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent ?? 5),
+      || (
+        params.keepRecent !== undefined
+          ? isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent)
+          : isHookWithinLifecycleWindow(
+              hook,
+              params.chapterNumber,
+              describeHookLifecycle({
+                payoffTiming: hook.payoffTiming,
+                expectedPayoff: hook.expectedPayoff,
+                notes: hook.notes,
+                startChapter: Math.max(0, hook.startChapter),
+                lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
+                status: hook.status,
+                chapterNumber: params.chapterNumber,
+              }),
+            )
+      ),
   );
   );
 
 
   if (workingSet.length === 0 || workingSet.length >= hooks.length) {
   if (workingSet.length === 0 || workingSet.length >= hooks.length) {

+ 1 - 1
packages/core/src/utils/memory-retrieval.ts

@@ -501,7 +501,7 @@ function resolveRelevantHookStaleLimit(
   return 1;
   return 1;
 }
 }
 
 
-function isHookWithinLifecycleWindow(
+export function isHookWithinLifecycleWindow(
   hook: StoredHook,
   hook: StoredHook,
   chapterNumber: number,
   chapterNumber: number,
   lifecycle: ReturnType<typeof describeHookLifecycle>,
   lifecycle: ReturnType<typeof describeHookLifecycle>,