|
|
@@ -1,8 +1,9 @@
|
|
|
import type { AuditIssue, AuditResult } from "../agents/continuity.js";
|
|
|
-import type { ReviseOutput } from "../agents/reviser.js";
|
|
|
+import type { ReviseMode, ReviseOutput } from "../agents/reviser.js";
|
|
|
import type { WriteChapterOutput } from "../agents/writer.js";
|
|
|
import type { ContextPackage, RuleStack } from "../models/input-governance.js";
|
|
|
import type { LengthSpec } from "../models/length-governance.js";
|
|
|
+import { countChapterLength, isOutsideSoftRange } from "../utils/length-metrics.js";
|
|
|
|
|
|
export interface ChapterReviewCycleUsage {
|
|
|
readonly promptTokens: number;
|
|
|
@@ -27,6 +28,17 @@ export interface ChapterReviewCycleResult {
|
|
|
readonly normalizeApplied: boolean;
|
|
|
}
|
|
|
|
|
|
+const MAX_REVIEW_ITERATIONS = 3;
|
|
|
+const PASS_SCORE_THRESHOLD = 85;
|
|
|
+const NET_IMPROVEMENT_EPSILON = 3;
|
|
|
+
|
|
|
+interface ReviewSnapshot {
|
|
|
+ readonly content: string;
|
|
|
+ readonly wordCount: number;
|
|
|
+ readonly auditResult: AuditResult;
|
|
|
+ readonly score: number;
|
|
|
+}
|
|
|
+
|
|
|
export async function runChapterReviewCycle(params: {
|
|
|
readonly book: Pick<{ genre: string }, "genre">;
|
|
|
readonly bookDir: string;
|
|
|
@@ -41,7 +53,7 @@ export async function runChapterReviewCycle(params: {
|
|
|
chapterContent: string,
|
|
|
chapterNumber: number,
|
|
|
issues: ReadonlyArray<AuditIssue>,
|
|
|
- mode: "spot-fix",
|
|
|
+ mode?: ReviseMode,
|
|
|
genre?: string,
|
|
|
options?: {
|
|
|
chapterIntent?: string;
|
|
|
@@ -76,7 +88,6 @@ export async function runChapterReviewCycle(params: {
|
|
|
left: ChapterReviewCycleUsage,
|
|
|
right?: ChapterReviewCycleUsage,
|
|
|
) => ChapterReviewCycleUsage;
|
|
|
- readonly restoreLostAuditIssues: (previous: AuditResult, next: AuditResult) => AuditResult;
|
|
|
readonly analyzeAITells: (content: string) => { issues: ReadonlyArray<AuditIssue> };
|
|
|
readonly analyzeSensitiveWords: (content: string) => {
|
|
|
found: ReadonlyArray<{ severity: string }>;
|
|
|
@@ -86,44 +97,23 @@ export async function runChapterReviewCycle(params: {
|
|
|
readonly logStage: (message: { zh: string; en: string }) => void;
|
|
|
}): Promise<ChapterReviewCycleResult> {
|
|
|
let totalUsage = params.initialUsage;
|
|
|
- let postReviseCount = 0;
|
|
|
let normalizeApplied = false;
|
|
|
let finalContent = params.initialOutput.content;
|
|
|
let finalWordCount = params.initialOutput.wordCount;
|
|
|
- let revised = false;
|
|
|
|
|
|
- if (params.initialOutput.postWriteErrors.length > 0) {
|
|
|
- params.logWarn({
|
|
|
- zh: `检测到 ${params.initialOutput.postWriteErrors.length} 个后写错误,审计前触发 spot-fix 修补`,
|
|
|
- en: `${params.initialOutput.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`,
|
|
|
- });
|
|
|
- const reviser = params.createReviser();
|
|
|
- const spotFixIssues = params.initialOutput.postWriteErrors.map((violation) => ({
|
|
|
- severity: "critical" as const,
|
|
|
- category: violation.rule,
|
|
|
- description: violation.description,
|
|
|
- suggestion: violation.suggestion,
|
|
|
- }));
|
|
|
- const fixResult = await reviser.reviseChapter(
|
|
|
- params.bookDir,
|
|
|
- finalContent,
|
|
|
- params.chapterNumber,
|
|
|
- spotFixIssues,
|
|
|
- "spot-fix",
|
|
|
- params.book.genre,
|
|
|
- {
|
|
|
- ...params.reducedControlInput,
|
|
|
- lengthSpec: params.lengthSpec,
|
|
|
- },
|
|
|
- );
|
|
|
- totalUsage = params.addUsage(totalUsage, fixResult.tokenUsage);
|
|
|
- if (fixResult.revisedContent.length > 0) {
|
|
|
- finalContent = fixResult.revisedContent;
|
|
|
- finalWordCount = fixResult.wordCount;
|
|
|
- revised = true;
|
|
|
- }
|
|
|
- }
|
|
|
+ // Convert postWriteErrors into AuditIssues to feed into the first assessment.
|
|
|
+ // These are deterministic structural violations (chapter-ref, paragraph shape, etc.)
|
|
|
+ // that the LLM auditor might not catch on its own.
|
|
|
+ const postWriteExtraIssues: ReadonlyArray<AuditIssue> = params.initialOutput.postWriteErrors.map((violation) => ({
|
|
|
+ severity: "critical" as const,
|
|
|
+ category: violation.rule,
|
|
|
+ description: violation.description,
|
|
|
+ suggestion: violation.suggestion,
|
|
|
+ }));
|
|
|
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // Initial length normalization (once, before entering the loop)
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
const normalizedBeforeAudit = await params.normalizeDraftLengthIfNeeded(finalContent);
|
|
|
totalUsage = params.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
|
|
|
finalContent = normalizedBeforeAudit.content;
|
|
|
@@ -131,86 +121,182 @@ export async function runChapterReviewCycle(params: {
|
|
|
normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied;
|
|
|
params.assertChapterContentNotEmpty(finalContent, "draft generation");
|
|
|
|
|
|
- params.logStage({ zh: "审计草稿", en: "auditing draft" });
|
|
|
- const llmAudit = await params.auditor.auditChapter(
|
|
|
- params.bookDir,
|
|
|
- finalContent,
|
|
|
- params.chapterNumber,
|
|
|
- params.book.genre,
|
|
|
- params.reducedControlInput,
|
|
|
- );
|
|
|
- totalUsage = params.addUsage(totalUsage, llmAudit.tokenUsage);
|
|
|
- const aiTellsResult = params.analyzeAITells(finalContent);
|
|
|
- const sensitiveWriteResult = params.analyzeSensitiveWords(finalContent);
|
|
|
- const hasBlockedWriteWords = sensitiveWriteResult.found.some((item) => item.severity === "block");
|
|
|
- let auditResult: AuditResult = {
|
|
|
- passed: hasBlockedWriteWords ? false : llmAudit.passed,
|
|
|
- issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues],
|
|
|
- summary: llmAudit.summary,
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // Helper: assess a chapter (audit + deterministic checks + length + score)
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ const assess = async (
|
|
|
+ content: string,
|
|
|
+ options?: { temperature?: number; extraIssues?: ReadonlyArray<AuditIssue> },
|
|
|
+ ): Promise<{ auditResult: AuditResult; score: number; lengthInRange: boolean }> => {
|
|
|
+ const llmAudit = await params.auditor.auditChapter(
|
|
|
+ params.bookDir,
|
|
|
+ content,
|
|
|
+ params.chapterNumber,
|
|
|
+ params.book.genre,
|
|
|
+ params.reducedControlInput
|
|
|
+ ? { ...params.reducedControlInput, ...(options ?? {}) }
|
|
|
+ : options,
|
|
|
+ );
|
|
|
+ totalUsage = params.addUsage(totalUsage, llmAudit.tokenUsage);
|
|
|
+ const aiTellsResult = params.analyzeAITells(content);
|
|
|
+ const sensitiveResult = params.analyzeSensitiveWords(content);
|
|
|
+ const hasBlockedWords = sensitiveResult.found.some((item) => item.severity === "block");
|
|
|
+ const wordCount = countChapterLength(content, params.lengthSpec.countingMode);
|
|
|
+ const lengthInRange = !isOutsideSoftRange(wordCount, params.lengthSpec);
|
|
|
+
|
|
|
+ const allIssues: AuditIssue[] = [
|
|
|
+ ...llmAudit.issues,
|
|
|
+ ...aiTellsResult.issues,
|
|
|
+ ...sensitiveResult.issues,
|
|
|
+ ...(options?.extraIssues ?? []),
|
|
|
+ ];
|
|
|
+
|
|
|
+ // Add length issue if out of range
|
|
|
+ if (!lengthInRange) {
|
|
|
+ const direction = wordCount < params.lengthSpec.softMin ? "short" : "long";
|
|
|
+ allIssues.push({
|
|
|
+ severity: "warning",
|
|
|
+ category: "length",
|
|
|
+ description: `Chapter word count ${wordCount} is outside target range ${params.lengthSpec.softMin}-${params.lengthSpec.softMax} (too ${direction}).`,
|
|
|
+ suggestion: direction === "long"
|
|
|
+ ? "Trim redundant explanations and weak-information sentences."
|
|
|
+ : "Expand key scenes with more sensory detail or dialogue.",
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const hasExtraCritical = (options?.extraIssues ?? []).some((i) => i.severity === "critical");
|
|
|
+ const auditResult: AuditResult = {
|
|
|
+ passed: (hasBlockedWords || hasExtraCritical) ? false : llmAudit.passed,
|
|
|
+ issues: allIssues,
|
|
|
+ summary: llmAudit.summary,
|
|
|
+ overallScore: llmAudit.overallScore,
|
|
|
+ };
|
|
|
+
|
|
|
+ const score = llmAudit.overallScore ?? 0;
|
|
|
+
|
|
|
+ return { auditResult, score, lengthInRange };
|
|
|
};
|
|
|
|
|
|
- if (!auditResult.passed) {
|
|
|
- const criticalIssues = auditResult.issues.filter((issue) => issue.severity === "critical");
|
|
|
- if (criticalIssues.length > 0) {
|
|
|
+ const isPassed = (assessment: { auditResult: AuditResult; score: number; lengthInRange: boolean }): boolean =>
|
|
|
+ assessment.auditResult.passed && assessment.score >= PASS_SCORE_THRESHOLD && assessment.lengthInRange;
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // Scoring loop: assess → revise → assess, max 3 iterations, pick best
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ params.logStage({ zh: "审计草稿", en: "auditing draft" });
|
|
|
+ const initial = await assess(finalContent, {
|
|
|
+ extraIssues: postWriteExtraIssues.length > 0 ? postWriteExtraIssues : undefined,
|
|
|
+ });
|
|
|
+
|
|
|
+ const snapshots: ReviewSnapshot[] = [{
|
|
|
+ content: finalContent,
|
|
|
+ wordCount: finalWordCount,
|
|
|
+ auditResult: initial.auditResult,
|
|
|
+ score: initial.score,
|
|
|
+ }];
|
|
|
+
|
|
|
+ let currentAudit = initial;
|
|
|
+ let postReviseCount = 0;
|
|
|
+
|
|
|
+ if (!isPassed(initial)) {
|
|
|
+ for (let iteration = 0; iteration < MAX_REVIEW_ITERATIONS; iteration++) {
|
|
|
+ params.logStage({
|
|
|
+ zh: `修复轮次 ${iteration + 1}/${MAX_REVIEW_ITERATIONS}(当前 ${currentAudit.score} 分)`,
|
|
|
+ en: `repair iteration ${iteration + 1}/${MAX_REVIEW_ITERATIONS} (current score: ${currentAudit.score})`,
|
|
|
+ });
|
|
|
+
|
|
|
const reviser = params.createReviser();
|
|
|
- params.logStage({ zh: "自动修复关键问题", en: "auto-revising critical issues" });
|
|
|
const reviseOutput = await reviser.reviseChapter(
|
|
|
params.bookDir,
|
|
|
finalContent,
|
|
|
params.chapterNumber,
|
|
|
- auditResult.issues,
|
|
|
- "spot-fix",
|
|
|
+ currentAudit.auditResult.issues,
|
|
|
+ "auto",
|
|
|
params.book.genre,
|
|
|
- {
|
|
|
- ...params.reducedControlInput,
|
|
|
- lengthSpec: params.lengthSpec,
|
|
|
- },
|
|
|
+ { ...params.reducedControlInput, lengthSpec: params.lengthSpec },
|
|
|
);
|
|
|
totalUsage = params.addUsage(totalUsage, reviseOutput.tokenUsage);
|
|
|
|
|
|
- if (reviseOutput.revisedContent.length > 0) {
|
|
|
- const normalizedRevision = await params.normalizeDraftLengthIfNeeded(reviseOutput.revisedContent);
|
|
|
- totalUsage = params.addUsage(totalUsage, normalizedRevision.tokenUsage);
|
|
|
- postReviseCount = normalizedRevision.wordCount;
|
|
|
- normalizeApplied = normalizeApplied || normalizedRevision.applied;
|
|
|
-
|
|
|
- const preMarkers = params.analyzeAITells(finalContent);
|
|
|
- const postMarkers = params.analyzeAITells(normalizedRevision.content);
|
|
|
- if (postMarkers.issues.length <= preMarkers.issues.length) {
|
|
|
- finalContent = normalizedRevision.content;
|
|
|
- finalWordCount = normalizedRevision.wordCount;
|
|
|
- revised = true;
|
|
|
- params.assertChapterContentNotEmpty(finalContent, "revision");
|
|
|
- }
|
|
|
-
|
|
|
- const reAudit = await params.auditor.auditChapter(
|
|
|
- params.bookDir,
|
|
|
- finalContent,
|
|
|
- params.chapterNumber,
|
|
|
- params.book.genre,
|
|
|
- params.reducedControlInput
|
|
|
- ? { ...params.reducedControlInput, temperature: 0 }
|
|
|
- : { temperature: 0 },
|
|
|
- );
|
|
|
- totalUsage = params.addUsage(totalUsage, reAudit.tokenUsage);
|
|
|
- const reAITells = params.analyzeAITells(finalContent);
|
|
|
- const reSensitive = params.analyzeSensitiveWords(finalContent);
|
|
|
- const reHasBlocked = reSensitive.found.some((item) => item.severity === "block");
|
|
|
- auditResult = params.restoreLostAuditIssues(auditResult, {
|
|
|
- passed: reHasBlocked ? false : reAudit.passed,
|
|
|
- issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues],
|
|
|
- summary: reAudit.summary,
|
|
|
+ if (reviseOutput.revisedContent.length === 0 || reviseOutput.revisedContent === finalContent) {
|
|
|
+ params.logWarn({
|
|
|
+ zh: `修复轮次 ${iteration + 1} 未产出新内容,退出循环`,
|
|
|
+ en: `repair iteration ${iteration + 1} produced no new content, exiting loop`,
|
|
|
+ });
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 });
|
|
|
+
|
|
|
+ snapshots.push({
|
|
|
+ content: reviseOutput.revisedContent,
|
|
|
+ wordCount: revisedWordCount,
|
|
|
+ auditResult: nextAssessment.auditResult,
|
|
|
+ score: nextAssessment.score,
|
|
|
+ });
|
|
|
+
|
|
|
+ // Check if passed
|
|
|
+ if (isPassed(nextAssessment)) {
|
|
|
+ params.logStage({
|
|
|
+ zh: `修复后达到通过线(${nextAssessment.score} 分),退出循环`,
|
|
|
+ en: `repair reached pass threshold (${nextAssessment.score}), exiting loop`,
|
|
|
+ });
|
|
|
+ finalContent = reviseOutput.revisedContent;
|
|
|
+ finalWordCount = revisedWordCount;
|
|
|
+ postReviseCount = revisedWordCount;
|
|
|
+ currentAudit = nextAssessment;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check net improvement
|
|
|
+ if (nextAssessment.score > currentAudit.score + NET_IMPROVEMENT_EPSILON) {
|
|
|
+ finalContent = reviseOutput.revisedContent;
|
|
|
+ finalWordCount = revisedWordCount;
|
|
|
+ postReviseCount = revisedWordCount;
|
|
|
+ currentAudit = nextAssessment;
|
|
|
+ // Continue to next iteration
|
|
|
+ } else {
|
|
|
+ params.logWarn({
|
|
|
+ zh: `修复轮次 ${iteration + 1} 未净提升(${currentAudit.score} → ${nextAssessment.score}),退出循环`,
|
|
|
+ en: `repair iteration ${iteration + 1} no net improvement (${currentAudit.score} → ${nextAssessment.score}), exiting loop`,
|
|
|
});
|
|
|
+ break;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // Pick the best scoring snapshot for final output
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ const bestSnapshot = snapshots.reduce((best, snap) =>
|
|
|
+ snap.score > best.score + NET_IMPROVEMENT_EPSILON ? snap : best,
|
|
|
+ );
|
|
|
+
|
|
|
+ // If best snapshot differs from current content (repair made things worse
|
|
|
+ // but an earlier version was better), roll back to the best version.
|
|
|
+ if (bestSnapshot.content !== finalContent && bestSnapshot.score > currentAudit.score + NET_IMPROVEMENT_EPSILON) {
|
|
|
+ params.logWarn({
|
|
|
+ zh: `回退到最高分版本(${bestSnapshot.score} 分 vs 当前 ${currentAudit.score} 分)`,
|
|
|
+ en: `rolling back to highest-scoring version (${bestSnapshot.score} vs current ${currentAudit.score})`,
|
|
|
+ });
|
|
|
+ finalContent = bestSnapshot.content;
|
|
|
+ finalWordCount = bestSnapshot.wordCount;
|
|
|
+ currentAudit = {
|
|
|
+ auditResult: bestSnapshot.auditResult,
|
|
|
+ score: bestSnapshot.score,
|
|
|
+ lengthInRange: !isOutsideSoftRange(bestSnapshot.wordCount, params.lengthSpec),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
return {
|
|
|
finalContent,
|
|
|
finalWordCount,
|
|
|
preAuditNormalizedWordCount: normalizedBeforeAudit.wordCount,
|
|
|
- revised,
|
|
|
- auditResult,
|
|
|
+ revised: snapshots.length > 1 && finalContent !== params.initialOutput.content,
|
|
|
+ auditResult: currentAudit.auditResult,
|
|
|
totalUsage,
|
|
|
postReviseCount,
|
|
|
normalizeApplied,
|