فهرست منبع

fix(create): normalize book platform aliases

Ma 6 ماه پیش
والد
کامیت
690a62f4d2

+ 3 - 7
packages/cli/src/commands/book.ts

@@ -2,7 +2,7 @@ import { Command } from "commander";
 import { access, readFile, rm } from "node:fs/promises";
 import { access, readFile, rm } from "node:fs/promises";
 import { createInterface } from "node:readline";
 import { createInterface } from "node:readline";
 import { join, resolve } from "node:path";
 import { join, resolve } from "node:path";
-import { PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core";
+import { deriveBookIdFromTitle, normalizePlatformOrOther, PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core";
 import {
 import {
   formatBookCreateCreated,
   formatBookCreateCreated,
   formatBookCreateCreating,
   formatBookCreateCreating,
@@ -31,11 +31,7 @@ bookCommand
     try {
     try {
       const root = findProjectRoot();
       const root = findProjectRoot();
 
 
-      const bookId = opts.title
-        .toLowerCase()
-        .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
-        .replace(/-+/g, "-")
-        .slice(0, 30);
+      const bookId = deriveBookIdFromTitle(opts.title) || `book-${Date.now().toString(36)}`;
 
 
       const bookDir = join(root, "books", bookId);
       const bookDir = join(root, "books", bookId);
       try {
       try {
@@ -55,7 +51,7 @@ bookCommand
       const book: BookConfig = {
       const book: BookConfig = {
         id: bookId,
         id: bookId,
         title: opts.title,
         title: opts.title,
-        platform: opts.platform,
+        platform: normalizePlatformOrOther(opts.platform),
         genre: opts.genre,
         genre: opts.genre,
         status: "outlining",
         status: "outlining",
         targetChapters: parseInt(opts.targetChapters, 10),
         targetChapters: parseInt(opts.targetChapters, 10),

+ 3 - 7
packages/cli/src/commands/fanfic.ts

@@ -1,7 +1,7 @@
 import { Command } from "commander";
 import { Command } from "commander";
 import { readFile, readdir, stat } from "node:fs/promises";
 import { readFile, readdir, stat } from "node:fs/promises";
 import { join, resolve, basename } from "node:path";
 import { join, resolve, basename } from "node:path";
-import { PipelineRunner, type BookConfig, type FanficMode } from "@actalk/inkos-core";
+import { deriveBookIdFromTitle, normalizePlatformOrOther, PipelineRunner, type BookConfig, type FanficMode } from "@actalk/inkos-core";
 import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 
 export const fanficCommand = new Command("fanfic")
 export const fanficCommand = new Command("fanfic")
@@ -38,17 +38,13 @@ fanficCommand
         throw new Error(`源素材文件内容过短(${sourceText.length} 字符)。请提供至少 100 字符的原作素材。`);
         throw new Error(`源素材文件内容过短(${sourceText.length} 字符)。请提供至少 100 字符的原作素材。`);
       }
       }
 
 
-      const bookId = opts.title
-        .toLowerCase()
-        .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
-        .replace(/-+/g, "-")
-        .slice(0, 30);
+      const bookId = deriveBookIdFromTitle(opts.title) || `book-${Date.now().toString(36)}`;
 
 
       const now = new Date().toISOString();
       const now = new Date().toISOString();
       const book: BookConfig = {
       const book: BookConfig = {
         id: bookId,
         id: bookId,
         title: opts.title,
         title: opts.title,
-        platform: opts.platform,
+        platform: normalizePlatformOrOther(opts.platform),
         genre: opts.genre,
         genre: opts.genre,
         status: "outlining",
         status: "outlining",
         targetChapters: parseInt(opts.targetChapters, 10),
         targetChapters: parseInt(opts.targetChapters, 10),

+ 31 - 0
packages/core/src/__tests__/interaction-tools.test.ts

@@ -297,6 +297,37 @@ describe("interaction tools", () => {
     );
     );
   });
   });
 
 
+  it("normalizes human-facing platform aliases before creating a book", async () => {
+    const pipeline = {
+      initBook: vi.fn(async () => undefined),
+      writeNextChapter: vi.fn(),
+      reviseDraft: vi.fn(),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => []),
+    };
+
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+    await tools.createBook?.({
+      title: "测试书",
+      genre: "urban",
+      platform: "番茄小说",
+    });
+
+    expect(pipeline.initBook).toHaveBeenCalledWith(
+      expect.objectContaining({
+        id: "测试书",
+        platform: "tomato",
+      }),
+      expect.any(Object),
+    );
+  });
+
   it("builds a reusable chapter lookup from a single directory listing", () => {
   it("builds a reusable chapter lookup from a single directory listing", () => {
     const lookup = buildChapterFileLookup([
     const lookup = buildChapterFileLookup([
       "0001_First.md",
       "0001_First.md",

+ 14 - 0
packages/core/src/__tests__/models.test.ts

@@ -4,6 +4,8 @@ import {
   PlatformSchema,
   PlatformSchema,
   GenreSchema,
   GenreSchema,
   BookStatusSchema,
   BookStatusSchema,
+  normalizePlatformId,
+  normalizePlatformOrOther,
 } from "../models/book.js";
 } from "../models/book.js";
 import { ChapterMetaSchema, ChapterStatusSchema } from "../models/chapter.js";
 import { ChapterMetaSchema, ChapterStatusSchema } from "../models/chapter.js";
 import {
 import {
@@ -136,6 +138,18 @@ describe("PlatformSchema", () => {
   it("rejects unknown platform", () => {
   it("rejects unknown platform", () => {
     expect(() => PlatformSchema.parse("amazon")).toThrow();
     expect(() => PlatformSchema.parse("amazon")).toThrow();
   });
   });
+
+  it("normalizes platform ids and human-facing aliases", () => {
+    expect(normalizePlatformId("tomato")).toBe("tomato");
+    expect(normalizePlatformId("番茄小说")).toBe("tomato");
+    expect(normalizePlatformId("fanqie-novel")).toBe("tomato");
+    expect(normalizePlatformId("起点中文网")).toBe("qidian");
+    expect(normalizePlatformId("飞卢")).toBe("feilu");
+    expect(normalizePlatformId("royal-road")).toBe("other");
+    expect(normalizePlatformId("Kindle Unlimited")).toBe("other");
+    expect(normalizePlatformId("")).toBeUndefined();
+    expect(normalizePlatformOrOther("")).toBe("other");
+  });
 });
 });
 
 
 describe("GenreSchema", () => {
 describe("GenreSchema", () => {

+ 23 - 0
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -164,6 +164,29 @@ describe("agent pipeline tools", () => {
       .resolves.toContain("mentor fallout");
       .resolves.toContain("mentor fallout");
   });
   });
 
 
+  it("normalizes human-facing platform aliases before create_book persists config", async () => {
+    const initBook = vi.spyOn(PipelineRunner.prototype, "initBook").mockResolvedValue(undefined);
+
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "create_book",
+      {
+        title: "测试书",
+        genre: "urban",
+        platform: "番茄小说",
+        brief: "一本文娱爽文。",
+      },
+    ));
+
+    expect(result).toMatchObject({ bookId: "测试书", title: "测试书", status: "created" });
+    expect(initBook).toHaveBeenCalledWith(expect.objectContaining({
+      id: "测试书",
+      platform: "tomato",
+    }));
+  });
+
   it("keeps update_current_focus usable for explicit local overrides through the tool surface", async () => {
   it("keeps update_current_focus usable for explicit local overrides through the tool surface", async () => {
     await executeAgentTool(pipeline, state, config, "update_current_focus", {
     await executeAgentTool(pipeline, state, config, "update_current_focus", {
       bookId,
       bookId,

+ 3 - 32
packages/core/src/agent/agent-tools.ts

@@ -9,6 +9,7 @@ import { assertSafeTruthFileName, createInteractionToolsFromDeps } from "../inte
 import { writeExportArtifact } from "../interaction/export-artifact.js";
 import { writeExportArtifact } from "../interaction/export-artifact.js";
 import { assertSafeBookId, deriveBookIdFromTitle } from "../utils/book-id.js";
 import { assertSafeBookId, deriveBookIdFromTitle } from "../utils/book-id.js";
 import { safeChildPath } from "../utils/path-safety.js";
 import { safeChildPath } from "../utils/path-safety.js";
+import { normalizePlatformId, normalizePlatformOrOther } from "../models/book.js";
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Helpers
 // Helpers
@@ -105,36 +106,6 @@ const SubAgentParams = Type.Object({
 });
 });
 
 
 type SubAgentParamsType = Static<typeof SubAgentParams>;
 type SubAgentParamsType = Static<typeof SubAgentParams>;
-type ArchitectPlatform = NonNullable<SubAgentParamsType["platform"]>;
-
-function normalizeArchitectPlatform(platform: unknown): ArchitectPlatform | undefined {
-  if (typeof platform !== "string") {
-    return undefined;
-  }
-
-  const raw = platform.trim();
-  if (!raw) {
-    return undefined;
-  }
-
-  const lowered = raw.toLowerCase();
-  const compact = lowered.replace(/[\s_-]+/g, "");
-
-  if (compact === "tomato" || compact === "fanqie" || compact === "fanqienovel" || raw.includes("番茄")) {
-    return "tomato";
-  }
-  if (compact === "qidian" || compact === "qidianzhongwenwang" || raw.includes("起点")) {
-    return "qidian";
-  }
-  if (compact === "feilu" || raw.includes("飞卢")) {
-    return "feilu";
-  }
-  if (compact === "other" || compact === "others" || raw.includes("其他") || raw.includes("其它")) {
-    return "other";
-  }
-
-  return "other";
-}
 
 
 function prepareSubAgentArguments(args: unknown): SubAgentParamsType {
 function prepareSubAgentArguments(args: unknown): SubAgentParamsType {
   if (!args || typeof args !== "object" || Array.isArray(args)) {
   if (!args || typeof args !== "object" || Array.isArray(args)) {
@@ -143,7 +114,7 @@ function prepareSubAgentArguments(args: unknown): SubAgentParamsType {
 
 
   const prepared = { ...(args as Record<string, unknown>) };
   const prepared = { ...(args as Record<string, unknown>) };
   if ("platform" in prepared) {
   if ("platform" in prepared) {
-    const platform = normalizeArchitectPlatform(prepared.platform);
+    const platform = normalizePlatformId(prepared.platform);
     if (platform) {
     if (platform) {
       prepared.platform = platform;
       prepared.platform = platform;
     } else {
     } else {
@@ -215,7 +186,7 @@ export function createSubAgentTool(
                 id,
                 id,
                 title: resolvedTitle,
                 title: resolvedTitle,
                 genre: genre ?? "general",
                 genre: genre ?? "general",
-                platform: normalizeArchitectPlatform(platform) ?? "other",
+                platform: normalizePlatformOrOther(platform),
                 language: (language ?? "zh") as any,
                 language: (language ?? "zh") as any,
                 status: "outlining" as any,
                 status: "outlining" as any,
                 targetChapters: targetChapters ?? 200,
                 targetChapters: targetChapters ?? 200,

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

@@ -1,5 +1,5 @@
 // Models
 // Models
-export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema } from "./models/book.js";
+export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema, normalizePlatformId, normalizePlatformOrOther } from "./models/book.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
 export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type FoundationConfig, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, FoundationConfigSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type FoundationConfig, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, FoundationConfigSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";

+ 4 - 22
packages/core/src/interaction/project-tools.ts

@@ -8,7 +8,6 @@ import type {
   ReviseMode,
   ReviseMode,
   LLMClient,
   LLMClient,
   BookConfig,
   BookConfig,
-  Platform,
   ToolDefinition,
   ToolDefinition,
 } from "../index.js";
 } from "../index.js";
 import { chatCompletion, chatWithTools } from "../index.js";
 import { chatCompletion, chatWithTools } from "../index.js";
@@ -17,6 +16,8 @@ import type { InteractionRuntimeTools } from "./runtime.js";
 import type { BookCreationDraft } from "./session.js";
 import type { BookCreationDraft } from "./session.js";
 import { writeExportArtifact } from "./export-artifact.js";
 import { writeExportArtifact } from "./export-artifact.js";
 import { safeChildPath } from "../utils/path-safety.js";
 import { safeChildPath } from "../utils/path-safety.js";
+import { deriveBookIdFromTitle } from "../utils/book-id.js";
+import { normalizePlatformOrOther } from "../models/book.js";
 
 
 const SAFE_TRUTH_FLAT_FILE_NAMES = new Set([
 const SAFE_TRUTH_FLAT_FILE_NAMES = new Set([
   "author_intent.md",
   "author_intent.md",
@@ -83,25 +84,6 @@ type InstrumentablePipelineLike = PipelineLike & {
   };
   };
 };
 };
 
 
-function normalizePlatform(platform?: string): Platform {
-  switch (platform) {
-    case "tomato":
-    case "feilu":
-    case "qidian":
-      return platform;
-    default:
-      return "other";
-  }
-}
-
-function deriveBookId(title: string): string {
-  return title
-    .toLowerCase()
-    .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
-    .replace(/-+/g, "-")
-    .slice(0, 30);
-}
-
 function buildBookConfig(input: {
 function buildBookConfig(input: {
   readonly title: string;
   readonly title: string;
   readonly genre?: string;
   readonly genre?: string;
@@ -112,9 +94,9 @@ function buildBookConfig(input: {
 }): BookConfig {
 }): BookConfig {
   const now = new Date().toISOString();
   const now = new Date().toISOString();
   return {
   return {
-    id: deriveBookId(input.title),
+    id: deriveBookIdFromTitle(input.title) || `book-${Date.now().toString(36)}`,
     title: input.title,
     title: input.title,
-    platform: normalizePlatform(input.platform),
+    platform: normalizePlatformOrOther(input.platform),
     genre: input.genre ?? "other",
     genre: input.genre ?? "other",
     status: "outlining",
     status: "outlining",
     targetChapters: input.targetChapters ?? 200,
     targetChapters: input.targetChapters ?? 200,

+ 33 - 0
packages/core/src/models/book.ts

@@ -3,6 +3,39 @@ import { z } from "zod";
 export const PlatformSchema = z.enum(["tomato", "feilu", "qidian", "other"]);
 export const PlatformSchema = z.enum(["tomato", "feilu", "qidian", "other"]);
 export type Platform = z.infer<typeof PlatformSchema>;
 export type Platform = z.infer<typeof PlatformSchema>;
 
 
+export function normalizePlatformId(platform: unknown): Platform | undefined {
+  if (typeof platform !== "string") {
+    return undefined;
+  }
+
+  const raw = platform.trim();
+  if (!raw) {
+    return undefined;
+  }
+
+  const lowered = raw.toLowerCase();
+  const compact = lowered.replace(/[\s_-]+/g, "");
+
+  if (compact === "tomato" || compact === "fanqie" || compact === "fanqienovel" || raw.includes("番茄")) {
+    return "tomato";
+  }
+  if (compact === "qidian" || compact === "qidianzhongwenwang" || raw.includes("起点")) {
+    return "qidian";
+  }
+  if (compact === "feilu" || raw.includes("飞卢")) {
+    return "feilu";
+  }
+  if (compact === "other" || compact === "others" || raw.includes("其他") || raw.includes("其它")) {
+    return "other";
+  }
+
+  return "other";
+}
+
+export function normalizePlatformOrOther(platform: unknown): Platform {
+  return normalizePlatformId(platform) ?? "other";
+}
+
 export const GenreSchema = z.string().min(1);
 export const GenreSchema = z.string().min(1);
 export type Genre = z.infer<typeof GenreSchema>;
 export type Genre = z.infer<typeof GenreSchema>;
 
 

+ 4 - 7
packages/core/src/pipeline/agent.ts

@@ -1,7 +1,8 @@
 import { chatWithTools, type AgentMessage, type ToolDefinition } from "../llm/provider.js";
 import { chatWithTools, type AgentMessage, type ToolDefinition } from "../llm/provider.js";
 import { PipelineRunner, type PipelineConfig } from "./runner.js";
 import { PipelineRunner, type PipelineConfig } from "./runner.js";
-import type { Platform, Genre } from "../models/book.js";
+import { normalizePlatformOrOther, type Genre } from "../models/book.js";
 import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
 import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
+import { deriveBookIdFromTitle } from "../utils/book-id.js";
 
 
 /** Tool definitions for the agent loop. */
 /** Tool definitions for the agent loop. */
 const TOOLS: ReadonlyArray<ToolDefinition> = [
 const TOOLS: ReadonlyArray<ToolDefinition> = [
@@ -420,16 +421,12 @@ export async function executeAgentTool(
     case "create_book": {
     case "create_book": {
       const now = new Date().toISOString();
       const now = new Date().toISOString();
       const title = args.title as string;
       const title = args.title as string;
-      const bookId = title
-        .toLowerCase()
-        .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
-        .replace(/-+/g, "-")
-        .slice(0, 30);
+      const bookId = deriveBookIdFromTitle(title) || `book-${Date.now().toString(36)}`;
 
 
       const book = {
       const book = {
         id: bookId,
         id: bookId,
         title,
         title,
-        platform: ((args.platform as string) ?? "tomato") as Platform,
+        platform: normalizePlatformOrOther(args.platform ?? "tomato"),
         genre: ((args.genre as string) ?? "xuanhuan") as Genre,
         genre: ((args.genre as string) ?? "xuanhuan") as Genre,
         status: "outlining" as const,
         status: "outlining" as const,
         targetChapters: 200,
         targetChapters: 200,

+ 1 - 0
packages/studio/src/api/book-create.test.ts

@@ -4,6 +4,7 @@ import { buildStudioBookConfig, normalizeStudioPlatform, waitForStudioBookReady
 describe("normalizeStudioPlatform", () => {
 describe("normalizeStudioPlatform", () => {
   it("keeps supported chinese platform ids and folds unsupported values to other", () => {
   it("keeps supported chinese platform ids and folds unsupported values to other", () => {
     expect(normalizeStudioPlatform("tomato")).toBe("tomato");
     expect(normalizeStudioPlatform("tomato")).toBe("tomato");
+    expect(normalizeStudioPlatform("番茄小说")).toBe("tomato");
     expect(normalizeStudioPlatform("qidian")).toBe("qidian");
     expect(normalizeStudioPlatform("qidian")).toBe("qidian");
     expect(normalizeStudioPlatform("feilu")).toBe("feilu");
     expect(normalizeStudioPlatform("feilu")).toBe("feilu");
     expect(normalizeStudioPlatform("royal-road")).toBe("other");
     expect(normalizeStudioPlatform("royal-road")).toBe("other");

+ 2 - 9
packages/studio/src/api/book-create.ts

@@ -1,4 +1,4 @@
-import type { Platform } from "@actalk/inkos-core";
+import { normalizePlatformOrOther, type Platform } from "@actalk/inkos-core";
 
 
 export interface StudioCreateBookBody {
 export interface StudioCreateBookBody {
   readonly title: string;
   readonly title: string;
@@ -36,14 +36,7 @@ interface WaitForStudioBookReadyOptions {
 }
 }
 
 
 export function normalizeStudioPlatform(platform?: string): Platform {
 export function normalizeStudioPlatform(platform?: string): Platform {
-  switch (platform) {
-    case "tomato":
-    case "feilu":
-    case "qidian":
-      return platform;
-    default:
-      return "other";
-  }
+  return normalizePlatformOrOther(platform);
 }
 }
 
 
 export function buildStudioBookConfig(body: StudioCreateBookBody, now: string): StudioBookConfigDraft {
 export function buildStudioBookConfig(body: StudioCreateBookBody, now: string): StudioBookConfigDraft {

+ 1 - 0
packages/studio/src/api/server.test.ts

@@ -204,6 +204,7 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
     createLogger: vi.fn(() => logger),
     createLogger: vi.fn(() => logger),
     computeAnalytics: vi.fn(() => ({})),
     computeAnalytics: vi.fn(() => ({})),
     isSafeBookId: actual.isSafeBookId,
     isSafeBookId: actual.isSafeBookId,
+    normalizePlatformOrOther: actual.normalizePlatformOrOther,
     chatCompletion: chatCompletionMock,
     chatCompletion: chatCompletionMock,
     loadProjectConfig: loadProjectConfigMock,
     loadProjectConfig: loadProjectConfigMock,
     processProjectInteractionInput: processProjectInteractionInputMock,
     processProjectInteractionInput: processProjectInteractionInputMock,

+ 1 - 0
packages/studio/vitest.config.ts

@@ -5,6 +5,7 @@ export default defineConfig({
   resolve: {
   resolve: {
     alias: {
     alias: {
       "@": resolve(__dirname, "src"),
       "@": resolve(__dirname, "src"),
+      "@actalk/inkos-core": resolve(__dirname, "../core/src/index.ts"),
     },
     },
   },
   },
   test: {
   test: {