hook-lifecycle.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import type { HookPayoffTiming } from "../models/runtime-state.js";
  2. import {
  3. HOOK_ACTIVITY_THRESHOLDS,
  4. HOOK_PHASE_THRESHOLDS,
  5. HOOK_PHASE_WEIGHT,
  6. HOOK_PRESSURE_WEIGHTS,
  7. HOOK_TIMING_PROFILES,
  8. type HookPhase,
  9. } from "./hook-policy.js";
  10. const LABELS: Record<"zh" | "en", Record<HookPayoffTiming, string>> = {
  11. en: {
  12. immediate: "immediate",
  13. "near-term": "near-term",
  14. "mid-arc": "mid-arc",
  15. "slow-burn": "slow-burn",
  16. endgame: "endgame",
  17. },
  18. zh: {
  19. immediate: "立即",
  20. "near-term": "近期",
  21. "mid-arc": "中程",
  22. "slow-burn": "慢烧",
  23. endgame: "终局",
  24. },
  25. };
  26. const TIMING_ALIASES: Array<[HookPayoffTiming, RegExp]> = [
  27. ["immediate", /^(?:立即|马上|当章|本章|下一章|immediate|instant|next(?:\s+chapter|\s+beat)?|right\s+away)$/i],
  28. ["near-term", /^(?:近期|近几章|短线|soon|short(?:\s+run)?|near(?:\s*-\s*|\s+)term|current\s+sequence)$/i],
  29. ["mid-arc", /^(?:中程|中期|卷中|mid(?:\s*-\s*|\s+)arc|mid(?:\s*-\s*|\s+)book|middle)$/i],
  30. ["slow-burn", /^(?:慢烧|长线|后续|later|late(?:r)?|long(?:\s*-\s*|\s+)arc|slow(?:\s*-\s*|\s+)burn)$/i],
  31. ["endgame", /^(?:终局|终章|大结局|最终|climax|finale|endgame|late\s+book)$/i],
  32. ];
  33. const SIGNAL_PATTERNS: Array<[HookPayoffTiming, RegExp]> = [
  34. ["endgame", /(终局|终章|大结局|最终揭晓|最终摊牌|climax|finale|endgame|final reveal|last act)/i],
  35. ["immediate", /(当章|本章|下一章|马上|立刻|即刻|immediate|next chapter|right away|at once)/i],
  36. ["near-term", /(近期|近几章|很快|短线|soon|near-term|short run|current sequence)/i],
  37. ["mid-arc", /(中期|卷中|本卷中段|mid-book|mid arc|middle of the arc)/i],
  38. ["slow-burn", /(长线|慢烧|后续发酵|慢慢揭开|later|slow burn|long arc|long tail)/i],
  39. ];
  40. export function normalizeHookPayoffTiming(value: string | undefined | null): HookPayoffTiming | undefined {
  41. const normalized = value?.trim();
  42. if (!normalized) return undefined;
  43. for (const [timing, pattern] of TIMING_ALIASES) {
  44. if (pattern.test(normalized)) {
  45. return timing;
  46. }
  47. }
  48. return undefined;
  49. }
  50. export function inferHookPayoffTiming(params: {
  51. readonly expectedPayoff?: string;
  52. readonly notes?: string;
  53. }): HookPayoffTiming {
  54. const combined = [params.expectedPayoff, params.notes]
  55. .filter((value): value is string => Boolean(value && value.trim()))
  56. .join(" ")
  57. .trim();
  58. if (!combined) return "mid-arc";
  59. for (const [timing, pattern] of SIGNAL_PATTERNS) {
  60. if (pattern.test(combined)) {
  61. return timing;
  62. }
  63. }
  64. return "mid-arc";
  65. }
  66. export function resolveHookPayoffTiming(params: {
  67. readonly payoffTiming?: string | null;
  68. readonly expectedPayoff?: string;
  69. readonly notes?: string;
  70. }): HookPayoffTiming {
  71. return normalizeHookPayoffTiming(params.payoffTiming)
  72. ?? inferHookPayoffTiming({
  73. expectedPayoff: params.expectedPayoff,
  74. notes: params.notes,
  75. });
  76. }
  77. export function localizeHookPayoffTiming(
  78. timing: HookPayoffTiming,
  79. language: "zh" | "en",
  80. ): string {
  81. return LABELS[language][timing];
  82. }
  83. export function describeHookLifecycle(params: {
  84. readonly payoffTiming?: string | null;
  85. readonly expectedPayoff?: string;
  86. readonly notes?: string;
  87. readonly startChapter: number;
  88. readonly lastAdvancedChapter: number;
  89. readonly status: string;
  90. readonly chapterNumber: number;
  91. readonly targetChapters?: number;
  92. }): {
  93. readonly timing: HookPayoffTiming;
  94. readonly phase: HookPhase;
  95. readonly age: number;
  96. readonly dormancy: number;
  97. readonly readyToResolve: boolean;
  98. readonly stale: boolean;
  99. readonly overdue: boolean;
  100. readonly advancePressure: number;
  101. readonly resolvePressure: number;
  102. } {
  103. const timing = resolveHookPayoffTiming(params);
  104. const profile = HOOK_TIMING_PROFILES[timing];
  105. const phase = resolveHookPhase(params.chapterNumber, params.targetChapters);
  106. const age = Math.max(0, params.chapterNumber - Math.max(1, params.startChapter));
  107. const lastTouchChapter = Math.max(params.startChapter, params.lastAdvancedChapter);
  108. const dormancy = Math.max(0, params.chapterNumber - Math.max(1, lastTouchChapter));
  109. const explicitProgressing = /^(progressing|advanced|重大推进|持续推进)$/i.test(params.status.trim());
  110. const phaseReady = HOOK_PHASE_WEIGHT[phase] >= HOOK_PHASE_WEIGHT[profile.minimumPhase];
  111. const recentlyTouched = dormancy <= HOOK_ACTIVITY_THRESHOLDS.recentlyTouchedDormancy;
  112. const overdue = phaseReady && age >= profile.overdueAge;
  113. const cadenceReady = timing === "slow-burn"
  114. ? phase === "late" || overdue
  115. : timing === "endgame"
  116. ? phase === "late"
  117. : true;
  118. const momentum = explicitProgressing || recentlyTouched;
  119. const stale = phaseReady && (
  120. dormancy >= profile.staleDormancy
  121. || (overdue && !momentum)
  122. );
  123. const readyToResolve = phaseReady
  124. && cadenceReady
  125. && age >= profile.earliestResolveAge
  126. && (momentum || (overdue && explicitProgressing));
  127. return {
  128. timing,
  129. phase,
  130. age,
  131. dormancy,
  132. readyToResolve,
  133. stale,
  134. overdue,
  135. advancePressure: age
  136. + dormancy
  137. + (stale ? HOOK_PRESSURE_WEIGHTS.staleAdvanceBonus : 0)
  138. + (overdue ? HOOK_PRESSURE_WEIGHTS.overdueAdvanceBonus : 0),
  139. resolvePressure: readyToResolve
  140. ? profile.resolveBias * HOOK_PRESSURE_WEIGHTS.resolveBiasMultiplier
  141. + (explicitProgressing ? HOOK_PRESSURE_WEIGHTS.progressingResolveBonus : 0)
  142. + Math.min(
  143. HOOK_PRESSURE_WEIGHTS.maxDormancyResolveBonus,
  144. dormancy * HOOK_PRESSURE_WEIGHTS.dormancyResolveMultiplier,
  145. )
  146. + (overdue ? HOOK_PRESSURE_WEIGHTS.overdueResolveBonus : 0)
  147. : 0,
  148. };
  149. }
  150. function resolveHookPhase(chapterNumber: number, targetChapters?: number): HookPhase {
  151. if (targetChapters && targetChapters > 0) {
  152. const progress = chapterNumber / targetChapters;
  153. if (progress >= HOOK_PHASE_THRESHOLDS.lateProgress) return "late";
  154. if (progress >= HOOK_PHASE_THRESHOLDS.middleProgress) return "middle";
  155. return "opening";
  156. }
  157. if (chapterNumber >= HOOK_PHASE_THRESHOLDS.lateChapter) return "late";
  158. if (chapterNumber >= HOOK_PHASE_THRESHOLDS.middleChapter) return "middle";
  159. return "opening";
  160. }