فهرست منبع

fix(architect): tolerate section label format drift

Ma 7 ماه پیش
والد
کامیت
bbb059bcdb
2فایلهای تغییر یافته به همراه87 افزوده شده و 5 حذف شده
  1. 64 0
      packages/core/src/__tests__/architect.test.ts
  2. 23 5
      packages/core/src/agents/architect.ts

+ 64 - 0
packages/core/src/__tests__/architect.test.ts

@@ -271,6 +271,70 @@ describe("ArchitectAgent", () => {
     expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |");
   });
 
+  it("accepts section labels with spacing and punctuation drift from non-strict models", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "format-drift-book",
+      title: "格式漂移测试",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-04-01T00:00:00.000Z",
+      updatedAt: "2026-04-01T00:00:00.000Z",
+    };
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== Section:Story Bible ===",
+          "# 故事圣经",
+          "",
+          "=== section: Volume Outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book-rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION : current state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    const result = await agent.generateFoundation(book);
+
+    expect(result.storyBible).toBe("# 故事圣经");
+    expect(result.volumeOutline).toBe("# 卷纲");
+    expect(result.bookRules).toContain("version: \"1.0\"");
+    expect(result.currentState).toBe("# 当前状态");
+    expect(result.pendingHooks).toContain("| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |");
+  });
+
   it("throws when a required foundation section is missing", async () => {
     const agent = new ArchitectAgent({
       client: {

+ 23 - 5
packages/core/src/agents/architect.ts

@@ -654,12 +654,21 @@ prohibitions:
   }
 
   private parseSections(content: string): ArchitectOutput {
+    const parsedSections = new Map<string, string>();
+    const sectionPattern = /^\s*===\s*SECTION\s*[::]\s*([^\n=]+?)\s*===\s*$/gim;
+    const matches = [...content.matchAll(sectionPattern)];
+
+    for (let i = 0; i < matches.length; i++) {
+      const match = matches[i]!;
+      const rawName = match[1] ?? "";
+      const start = (match.index ?? 0) + match[0].length;
+      const end = matches[i + 1]?.index ?? content.length;
+      const normalizedName = this.normalizeSectionName(rawName);
+      parsedSections.set(normalizedName, content.slice(start, end).trim());
+    }
+
     const extract = (name: string): string => {
-      const regex = new RegExp(
-        `=== SECTION: ${name} ===\\s*([\\s\\S]*?)(?==== SECTION:|$)`,
-      );
-      const match = content.match(regex);
-      const section = match?.[1]?.trim();
+      const section = parsedSections.get(this.normalizeSectionName(name));
       if (!section) {
         throw new Error(`Architect output missing required section: ${name}`);
       }
@@ -678,6 +687,15 @@ prohibitions:
     };
   }
 
+  private normalizeSectionName(name: string): string {
+    return name
+      .normalize("NFKC")
+      .toLowerCase()
+      .replace(/[`"'*_]/g, " ")
+      .replace(/[^a-z0-9]+/g, "_")
+      .replace(/^_+|_+$/g, "");
+  }
+
   private stripTrailingAssistantCoda(section: string): string {
     const lines = section.split("\n");
     const cutoff = lines.findIndex((line) => {