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

fix: harden consolidate parsing and api key env validation

Ma 7 месяцев назад
Родитель
Сommit
ae4ccce9c0

+ 24 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -141,6 +141,30 @@ describe("CLI integration", () => {
     });
   });
 
+  describe("inkos config set-model", () => {
+    it("rejects raw API keys passed to --api-key-env", async () => {
+      const { exitCode, stderr } = runStderr([
+        "config",
+        "set-model",
+        "writer",
+        "gpt-4-turbo",
+        "--provider",
+        "custom",
+        "--base-url",
+        "https://poloai.top/v1",
+        "--api-key-env",
+        "sk-test-direct-key",
+      ]);
+
+      expect(exitCode).not.toBe(0);
+      expect(stderr).toContain("--api-key-env expects an environment variable name");
+
+      const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
+      const config = JSON.parse(raw);
+      expect(config.modelOverrides).toBeUndefined();
+    });
+  });
+
   describe("inkos book list", () => {
     it("shows no books in empty project", () => {
       const output = run(["book", "list"]);

+ 17 - 0
packages/cli/src/commands/config.ts

@@ -160,6 +160,15 @@ configCommand
   });
 
 const KNOWN_AGENTS = ["writer", "auditor", "reviser", "architect", "radar", "chapter-analyzer"] as const;
+const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
+function validateApiKeyEnvName(value: string): string | undefined {
+  if (ENV_VAR_NAME_PATTERN.test(value)) return undefined;
+  if (/^(sk-|sess-|rk-|pk-)/i.test(value) || value.includes("://")) {
+    return "--api-key-env expects an environment variable name like PACKY_API_KEY, not a raw API key or URL.";
+  }
+  return `--api-key-env expects an environment variable name like PACKY_API_KEY. "${value}" is not a valid env var name.`;
+}
 
 configCommand
   .command("set-model")
@@ -177,6 +186,14 @@ configCommand
       process.exit(1);
     }
 
+    if (opts.apiKeyEnv) {
+      const validationError = validateApiKeyEnvName(opts.apiKeyEnv);
+      if (validationError) {
+        logError(validationError);
+        process.exit(1);
+      }
+    }
+
     const root = findProjectRoot();
     const configPath = join(root, "inkos.json");
 

+ 32 - 0
packages/core/src/__tests__/consolidator.test.ts

@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { ConsolidatorAgent } from "../agents/consolidator.js";
+
+describe("ConsolidatorAgent", () => {
+  it("parses Chinese volume boundaries with full-width parentheses and chapter ranges", () => {
+    const agent = new ConsolidatorAgent({
+      client: {} as ConstructorParameters<typeof ConsolidatorAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: "/tmp",
+    });
+
+    const outline = [
+      "# Volume Outline",
+      "",
+      "### 第一卷:死而复生的实习期(1-20章)",
+      "- 主角重返公司,卷入第一起异常事故",
+      "",
+      "### 第二卷:时间线上的猎手(21-60章)",
+      "- 追查时间裂隙背后的操控者",
+      "",
+    ].join("\n");
+
+    const boundaries = (agent as unknown as {
+      parseVolumeBoundaries: (input: string) => Array<{ name: string; startCh: number; endCh: number }>;
+    }).parseVolumeBoundaries(outline);
+
+    expect(boundaries).toEqual([
+      { name: "第一卷:死而复生的实习期", startCh: 1, endCh: 20 },
+      { name: "第二卷:时间线上的猎手", startCh: 21, endCh: 60 },
+    ]);
+  });
+});

+ 18 - 8
packages/core/src/agents/consolidator.ts

@@ -127,14 +127,24 @@ export class ConsolidatorAgent extends BaseAgent {
 
   private parseVolumeBoundaries(outline: string): Array<{ name: string; startCh: number; endCh: number }> {
     const volumes: Array<{ name: string; startCh: number; endCh: number }> = [];
-    // Match patterns like "第一卷 ... 第1-30章" or "Volume 1 ... Chapters 1-30"
-    const regex = /(?:第[一二三四五六七八九十\d]+卷|Volume\s+\d+)[::\s]*([^\n]*?)(?:第(\d+)[-–~](\d+)章|[Cc]hapters?\s+(\d+)[-–~](\d+))/g;
-    let match: RegExpExecArray | null;
-    while ((match = regex.exec(outline)) !== null) {
-      const name = match[0].split(/[((]/)[0]!.trim();
-      const startCh = parseInt(match[2] ?? match[4] ?? "0", 10);
-      const endCh = parseInt(match[3] ?? match[5] ?? "0", 10);
-      if (startCh > 0 && endCh > 0) {
+    const lines = outline.split("\n");
+    const volumeHeader = /^(第[一二三四五六七八九十百千万零〇\d]+卷|Volume\s+\d+)/i;
+    const rangePattern = /[((]\s*(?:第|[Cc]hapters?\s+)?(\d+)\s*[-–~~—]\s*(\d+)\s*(?:章)?\s*[))]|(?:第|[Cc]hapters?\s+)(\d+)\s*[-–~~—]\s*(\d+)\s*(?:章)?/i;
+
+    for (const rawLine of lines) {
+      const line = rawLine.replace(/^#+\s*/, "").trim();
+      if (!volumeHeader.test(line)) continue;
+
+      const rangeMatch = line.match(rangePattern);
+      if (!rangeMatch) continue;
+
+      const startCh = parseInt(rangeMatch[1] ?? rangeMatch[3] ?? "0", 10);
+      const endCh = parseInt(rangeMatch[2] ?? rangeMatch[4] ?? "0", 10);
+      if (startCh <= 0 || endCh <= 0) continue;
+
+      const rangeIndex = rangeMatch.index ?? line.length;
+      const name = line.slice(0, rangeIndex).replace(/[((]\s*$/, "").trim();
+      if (name.length > 0) {
         volumes.push({ name, startCh, endCh });
       }
     }