Просмотр исходного кода

feat: add inkos eval command for structured quality reporting

Outputs per-chapter and whole-book quality metrics:
- Composite quality score (0-100)
- Audit pass rate, AI tell density, paragraph health
- Hook resolve rate, duplicate title count
- Per-chapter quality trend with drift detection
- Supports --json for machine-readable output
- Supports --chapters range for partial evaluation
Ma 7 месяцев назад
Родитель
Сommit
eaa8749a54
3 измененных файлов с 220 добавлено и 1 удалено
  1. 217 0
      packages/cli/src/commands/eval.ts
  2. 2 0
      packages/cli/src/index.ts
  3. 1 1
      packages/core/src/index.ts

+ 217 - 0
packages/cli/src/commands/eval.ts

@@ -0,0 +1,217 @@
+import { Command } from "commander";
+import {
+  StateManager,
+  analyzeAITells,
+  computeAnalytics,
+} from "@actalk/inkos-core";
+import { readFile, readdir } from "node:fs/promises";
+import { join } from "node:path";
+import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+
+interface ChapterEval {
+  readonly number: number;
+  readonly title: string;
+  readonly wordCount: number;
+  readonly auditIssueCount: number;
+  readonly aiTellCount: number;
+  readonly aiTellDensity: number; // issues per 1000 chars
+  readonly paragraphWarnings: number;
+  readonly status: string;
+}
+
+interface BookEval {
+  readonly bookId: string;
+  readonly totalChapters: number;
+  readonly totalWords: number;
+  readonly auditPassRate: number;
+  readonly avgAiTellDensity: number;
+  readonly avgParagraphWarnings: number;
+  readonly hookResolveRate: number;
+  readonly duplicateTitles: number;
+  readonly qualityScore: number; // composite 0-100
+  readonly chapters: ReadonlyArray<ChapterEval>;
+  readonly qualityTrend: ReadonlyArray<{ chapter: number; score: number }>;
+}
+
+function computeChapterScore(ch: ChapterEval): number {
+  // Per-chapter quality score (0-100)
+  // Penalties: audit issues, AI tells, paragraph problems
+  let score = 100;
+  score -= ch.auditIssueCount * 5; // -5 per audit issue
+  score -= ch.aiTellDensity * 20;  // -20 per AI tell per 1k chars
+  score -= ch.paragraphWarnings * 3; // -3 per paragraph warning
+  return Math.max(0, Math.min(100, score));
+}
+
+export const evalCommand = new Command("eval")
+  .description("Evaluate writing quality for a book — outputs structured quality report")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--json", "Output JSON only")
+  .option("--chapters <range>", "Chapter range (e.g. 1-10, 5-20)")
+  .action(async (bookIdArg: string | undefined, opts: { json?: boolean; chapters?: string }) => {
+    try {
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const state = new StateManager(root);
+      const index = await state.loadChapterIndex(bookId);
+      const bookDir = state.bookDir(bookId);
+      const chaptersDir = join(bookDir, "chapters");
+
+      // Parse chapter range
+      let startCh = 1;
+      let endCh = Infinity;
+      if (opts.chapters) {
+        const parts = opts.chapters.split("-");
+        startCh = parseInt(parts[0]!, 10);
+        endCh = parts[1] ? parseInt(parts[1], 10) : startCh;
+      }
+
+      const filteredIndex = index.filter(
+        (ch) => ch.number >= startCh && ch.number <= endCh,
+      );
+
+      // Read chapter files and evaluate each
+      const chapterFiles = await readdir(chaptersDir).catch(() => [] as string[]);
+      const allTitles = index.map((ch) => ch.title);
+      const chapterEvals: ChapterEval[] = [];
+
+      for (const ch of filteredIndex) {
+        const paddedNum = String(ch.number).padStart(4, "0");
+        const file = chapterFiles.find(
+          (f) => f.startsWith(paddedNum) && f.endsWith(".md"),
+        );
+        let content = "";
+        if (file) {
+          content = await readFile(join(chaptersDir, file), "utf-8");
+        }
+
+        const aiTells = content ? analyzeAITells(content) : { issues: [] };
+        // Simple paragraph shape check: count short paragraphs
+        const paragraphs = content.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#"));
+        const shortParas = paragraphs.filter((p) => p.length < 35);
+        const paragraphWarningCount = shortParas.length > paragraphs.length * 0.4 ? 1 : 0;
+
+        const aiTellDensity = content.length > 0
+          ? (aiTells.issues.length / content.length) * 1000
+          : 0;
+
+        chapterEvals.push({
+          number: ch.number,
+          title: ch.title,
+          wordCount: ch.wordCount,
+          auditIssueCount: ch.auditIssues.length,
+          aiTellCount: aiTells.issues.length,
+          aiTellDensity: Math.round(aiTellDensity * 100) / 100,
+          paragraphWarnings: paragraphWarningCount,
+          status: ch.status,
+        });
+      }
+
+      // Hook health: parse markdown for status counts
+      const hooksContent = await readFile(
+        join(bookDir, "story", "pending_hooks.md"),
+        "utf-8",
+      ).catch(() => "");
+      let hookResolveRate = 0;
+      if (hooksContent) {
+        const lines = hooksContent.split("\n");
+        let totalHooks = 0;
+        let resolvedHooks = 0;
+        for (const line of lines) {
+          if (/^\|.*\|.*\|/.test(line) && !line.includes("---") && !line.toLowerCase().includes("hook") && !line.toLowerCase().includes("伏笔")) {
+            totalHooks++;
+            if (/resolved|已回收|已解决/i.test(line)) resolvedHooks++;
+          }
+        }
+        hookResolveRate = totalHooks > 0
+          ? Math.round((resolvedHooks / totalHooks) * 100)
+          : 0;
+      }
+
+      // Duplicate titles: simple exact match
+      const titleSet = new Set<string>();
+      let duplicateTitles = 0;
+      for (const title of allTitles) {
+        const norm = title.trim().toLowerCase();
+        if (titleSet.has(norm)) duplicateTitles++;
+        titleSet.add(norm);
+      }
+
+      // Composite score
+      const analytics = computeAnalytics(bookId, index);
+      const avgAiTellDensity = chapterEvals.length > 0
+        ? chapterEvals.reduce((s, c) => s + c.aiTellDensity, 0) / chapterEvals.length
+        : 0;
+      const avgParagraphWarnings = chapterEvals.length > 0
+        ? chapterEvals.reduce((s, c) => s + c.paragraphWarnings, 0) / chapterEvals.length
+        : 0;
+
+      // Quality score: weighted composite (0-100)
+      const qualityScore = Math.round(
+        analytics.auditPassRate * 0.3 +                        // 30% audit
+        Math.max(0, 100 - avgAiTellDensity * 30) * 0.25 +     // 25% AI tells
+        Math.max(0, 100 - avgParagraphWarnings * 10) * 0.15 +  // 15% paragraphs
+        hookResolveRate * 0.2 +                                 // 20% hooks
+        Math.max(0, 100 - duplicateTitles * 20) * 0.1,         // 10% title dedup
+      );
+
+      // Quality trend (per-chapter scores)
+      const qualityTrend = chapterEvals.map((ch) => ({
+        chapter: ch.number,
+        score: computeChapterScore(ch),
+      }));
+
+      const result: BookEval = {
+        bookId,
+        totalChapters: filteredIndex.length,
+        totalWords: filteredIndex.reduce((s, c) => s + c.wordCount, 0),
+        auditPassRate: analytics.auditPassRate,
+        avgAiTellDensity: Math.round(avgAiTellDensity * 100) / 100,
+        avgParagraphWarnings: Math.round(avgParagraphWarnings * 100) / 100,
+        hookResolveRate,
+        duplicateTitles,
+        qualityScore,
+        chapters: chapterEvals,
+        qualityTrend,
+      };
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        log(`\nQuality Report: "${bookId}"\n`);
+        log(`  Quality Score: ${qualityScore}/100`);
+        log(`  Chapters: ${result.totalChapters}`);
+        log(`  Words: ${result.totalWords.toLocaleString()}`);
+        log("");
+        log("  Dimensions:");
+        log(`    Audit pass rate:      ${analytics.auditPassRate}%`);
+        log(`    AI tell density:      ${avgAiTellDensity.toFixed(2)} / 1k chars`);
+        log(`    Paragraph warnings:   ${avgParagraphWarnings.toFixed(1)} avg/chapter`);
+        log(`    Hook resolve rate:    ${hookResolveRate}%`);
+        log(`    Duplicate titles:     ${duplicateTitles}`);
+        log("");
+        log("  Quality Trend:");
+        for (const { chapter, score } of qualityTrend) {
+          const bar = "█".repeat(Math.round(score / 5)) + "░".repeat(20 - Math.round(score / 5));
+          log(`    Ch.${String(chapter).padStart(3)} ${bar} ${score}`);
+        }
+        log("");
+
+        // Drift detection: compare first half vs second half
+        if (qualityTrend.length >= 6) {
+          const mid = Math.floor(qualityTrend.length / 2);
+          const firstHalf = qualityTrend.slice(0, mid).reduce((s, c) => s + c.score, 0) / mid;
+          const secondHalf = qualityTrend.slice(mid).reduce((s, c) => s + c.score, 0) / (qualityTrend.length - mid);
+          const drift = Math.round(secondHalf - firstHalf);
+          log(`  Quality Drift: ${drift > 0 ? "+" : ""}${drift} (${drift >= 0 ? "stable/improving" : "DEGRADING"})`);
+        }
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Eval failed: ${e}`);
+      }
+      process.exit(1);
+    }
+  });

+ 2 - 0
packages/cli/src/index.ts

@@ -23,6 +23,7 @@ import { updateCommand } from "./commands/update.js";
 import { detectCommand } from "./commands/detect.js";
 import { styleCommand } from "./commands/style.js";
 import { analyticsCommand } from "./commands/analytics.js";
+import { evalCommand } from "./commands/eval.js";
 import { importCommand } from "./commands/import.js";
 import { fanficCommand } from "./commands/fanfic.js";
 import { studioCommand } from "./commands/studio.js";
@@ -60,6 +61,7 @@ program.addCommand(updateCommand);
 program.addCommand(detectCommand);
 program.addCommand(styleCommand);
 program.addCommand(analyticsCommand);
+program.addCommand(evalCommand);
 program.addCommand(importCommand);
 program.addCommand(fanficCommand);
 program.addCommand(studioCommand);

+ 1 - 1
packages/core/src/index.ts

@@ -82,7 +82,7 @@ export { analyzeSensitiveWords, type SensitiveWordResult, type SensitiveWordMatc
 export { detectAIContent, type DetectionResult } from "./agents/detector.js";
 export { analyzeStyle } from "./agents/style-analyzer.js";
 export { analyzeDetectionInsights } from "./agents/detection-insights.js";
-export { validatePostWrite, type PostWriteViolation } from "./agents/post-write-validator.js";
+export { validatePostWrite, detectParagraphLengthDrift, detectParagraphShapeWarnings, detectDuplicateTitle, type PostWriteViolation } from "./agents/post-write-validator.js";
 export { ChapterAnalyzerAgent, type AnalyzeChapterInput, type AnalyzeChapterOutput } from "./agents/chapter-analyzer.js";
 export { parseWriterOutput, parseCreativeOutput, type ParsedWriterOutput, type CreativeOutput } from "./agents/writer-parser.js";
 export { buildSettlerSystemPrompt, buildSettlerUserPrompt } from "./agents/settler-prompts.js";