length-normalizer.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import { BaseAgent } from "./base.js";
  2. import type { LengthNormalizeMode, LengthSpec } from "../models/length-governance.js";
  3. import { countChapterLength, chooseNormalizeMode, isOutsideHardRange, isOutsideSoftRange } from "../utils/length-metrics.js";
  4. export interface NormalizeLengthInput {
  5. readonly chapterContent: string;
  6. readonly lengthSpec: LengthSpec;
  7. readonly chapterIntent?: string;
  8. readonly reducedControlBlock?: string;
  9. }
  10. export interface NormalizeLengthOutput {
  11. readonly normalizedContent: string;
  12. readonly finalCount: number;
  13. readonly applied: boolean;
  14. readonly mode: LengthNormalizeMode;
  15. readonly warning?: string;
  16. readonly tokenUsage?: {
  17. readonly promptTokens: number;
  18. readonly completionTokens: number;
  19. readonly totalTokens: number;
  20. };
  21. }
  22. export class LengthNormalizerAgent extends BaseAgent {
  23. get name(): string {
  24. return "length-normalizer";
  25. }
  26. async normalizeChapter(input: NormalizeLengthInput): Promise<NormalizeLengthOutput> {
  27. const originalCount = countChapterLength(input.chapterContent, input.lengthSpec.countingMode);
  28. const mode = input.lengthSpec.normalizeMode === "none"
  29. ? chooseNormalizeMode(originalCount, input.lengthSpec)
  30. : input.lengthSpec.normalizeMode;
  31. if (mode === "none") {
  32. return {
  33. normalizedContent: input.chapterContent,
  34. finalCount: originalCount,
  35. applied: false,
  36. mode,
  37. };
  38. }
  39. const systemPrompt = this.buildSystemPrompt(mode);
  40. const userPrompt = this.buildUserPrompt(input, originalCount, mode);
  41. const response = await this.chat(
  42. [
  43. { role: "system", content: systemPrompt },
  44. { role: "user", content: userPrompt },
  45. ],
  46. {
  47. temperature: 0.2,
  48. maxTokens: Math.max(4096, Math.ceil(originalCount * 1.2)),
  49. },
  50. );
  51. const normalizedContent = this.sanitizeNormalizedContent(response.content, input.chapterContent);
  52. const finalCount = countChapterLength(normalizedContent, input.lengthSpec.countingMode);
  53. const warning = this.buildWarning(finalCount, input.lengthSpec);
  54. return {
  55. normalizedContent,
  56. finalCount,
  57. applied: true,
  58. mode,
  59. warning,
  60. tokenUsage: response.usage,
  61. };
  62. }
  63. private buildSystemPrompt(mode: LengthNormalizeMode): string {
  64. const action = mode === "compress"
  65. ? "compress"
  66. : "expand";
  67. return `你是一位章节长度修正器。你的任务是对章节正文做一次单次修正,只能执行一次,不得递归重写。
  68. 修正目标:
  69. - ${action} 章节长度到给定目标区间
  70. - 保留章节原有事实、关键钩子、角色名和必须保留的标记
  71. - 不要引入新的支线、未来揭示或额外总结
  72. - 不要在正文外输出任何解释`;
  73. }
  74. private buildUserPrompt(
  75. input: NormalizeLengthInput,
  76. originalCount: number,
  77. mode: LengthNormalizeMode,
  78. ): string {
  79. const intentBlock = input.chapterIntent
  80. ? `\n## Chapter Intent\n${input.chapterIntent}\n`
  81. : "";
  82. const controlBlock = input.reducedControlBlock
  83. ? `\n## Reduced Control Block\n${input.reducedControlBlock}\n`
  84. : "";
  85. return `请对下面正文做一次${mode === "compress" ? "压缩" : "扩写"}修正。
  86. ## Length Spec
  87. - Target: ${input.lengthSpec.target}
  88. - Soft Range: ${input.lengthSpec.softMin}-${input.lengthSpec.softMax}
  89. - Hard Range: ${input.lengthSpec.hardMin}-${input.lengthSpec.hardMax}
  90. - Counting Mode: ${input.lengthSpec.countingMode}
  91. ## Current Count
  92. ${originalCount}
  93. ## Correction Rules
  94. - 只修正一次,不要递归
  95. - 保留正文中的关键标记、人物名、地点名和已有事实
  96. - 不要凭空新增子情节
  97. - 不要插入解释性总结或分析
  98. - 输出修正后的完整正文,不要加标签
  99. ${intentBlock}${controlBlock}
  100. ## Chapter Content
  101. ${input.chapterContent}`;
  102. }
  103. private buildWarning(finalCount: number, lengthSpec: LengthSpec): string | undefined {
  104. if (!isOutsideSoftRange(finalCount, lengthSpec)) {
  105. return undefined;
  106. }
  107. if (isOutsideHardRange(finalCount, lengthSpec)) {
  108. return `Final count ${finalCount} is outside the hard range ${lengthSpec.hardMin}-${lengthSpec.hardMax} after one normalization pass.`;
  109. }
  110. return `Final count ${finalCount} is outside the soft range ${lengthSpec.softMin}-${lengthSpec.softMax} after one normalization pass.`;
  111. }
  112. private sanitizeNormalizedContent(rawContent: string, fallbackContent: string): string {
  113. const trimmed = rawContent.trim();
  114. if (!trimmed) return fallbackContent;
  115. const fenced = this.extractFirstFencedBlock(trimmed);
  116. if (fenced) return fenced;
  117. const stripped = this.stripCommonWrappers(trimmed);
  118. if (stripped !== undefined) {
  119. // Empty after stripping = response was only wrapper text, use original
  120. if (!stripped) return fallbackContent;
  121. // Guard: if stripping removed more than 50% of content, the regex was too aggressive.
  122. if (stripped.length < trimmed.length * 0.5) return trimmed;
  123. return stripped;
  124. }
  125. return trimmed;
  126. }
  127. private extractFirstFencedBlock(content: string): string | undefined {
  128. const match = content.match(/```(?:[a-zA-Z-]+)?\s*\n([\s\S]*?)\n```/);
  129. if (!match) return undefined;
  130. const body = match[1]?.trim();
  131. return body ? body : undefined;
  132. }
  133. private stripCommonWrappers(content: string): string | undefined {
  134. const lines = content.split("\n");
  135. let removedAny = false;
  136. const keptLines: string[] = [];
  137. for (const rawLine of lines) {
  138. const trimmed = rawLine.trim();
  139. if (this.isWrapperLine(trimmed)) {
  140. removedAny = true;
  141. continue;
  142. }
  143. keptLines.push(rawLine);
  144. }
  145. if (!removedAny) {
  146. return undefined;
  147. }
  148. return keptLines.join("\n").trim();
  149. }
  150. private isWrapperLine(line: string): boolean {
  151. if (!line) return false;
  152. if (/^```/.test(line)) return true;
  153. if (/^#+\s*(说明|解释|注释|analysis|analysis note)\b/i.test(line)) return true;
  154. if (/^(下面是|以下是).*(正文|章节|压缩|扩写|修正|修改|调整|改写|润色|结果|内容|输出|版本)/i.test(line)) {
  155. return true;
  156. }
  157. if (/^我先.*(压缩|扩写|修正|修改|调整|改写|润色|处理).*(正文|章节)?/i.test(line)) {
  158. return true;
  159. }
  160. if (/^(here(?:'s| is)|below is).*(chapter|draft|content|rewrite|revised|compressed|expanded|normalized|adjusted|output|version|result)/i.test(line)) {
  161. return true;
  162. }
  163. if (/^i(?:'ll| will)\s+(rewrite|revise|reword|compress|expand|normalize|adjust|shorten|lengthen|trim|fix)\b/i.test(line)) {
  164. return true;
  165. }
  166. return false;
  167. }
  168. }