diff --git a/src/lib/ingest-source-path-collision.test.ts b/src/lib/ingest-source-path-collision.test.ts index 7736601b6..76ad8a046 100644 --- a/src/lib/ingest-source-path-collision.test.ts +++ b/src/lib/ingest-source-path-collision.test.ts @@ -625,6 +625,38 @@ describe("autoIngest source summary paths", () => { expect(reviews[0].description).not.toContain("Truncated Orphan") }) + it("rejects malformed review headers without swallowing later blocks and bounds titles", async () => { + if (!tmp) throw new Error("missing temp project") + sourceMarkers = ["project-a config"] + generationSuffix = `${"\n"}${"X".repeat(10_500)}` + const longTitle = "Long follow-up title ".repeat(12) + extraReviewResponse = [ + "---REVIEW: missing-page | Missing closing header delimiter", + "This malformed block must not consume the next valid review.", + "---END REVIEW---", + "", + `---REVIEW: suggestion | ${longTitle}---`, + "The full explanation remains in the description.", + "OPTIONS: Create Page | Skip", + "---END REVIEW---", + ].join("\n") + + await autoIngest( + tmp.path, + `${tmp.path}/raw/sources/project-a/config.yaml`, + { ...useWikiStore.getState().llmConfig, maxContextSize: 128_000 }, + undefined, + "project-a", + ) + + const reviews = useReviewStore.getState().items + expect(reviews).toHaveLength(1) + expect(reviews[0].title.length).toBeLessThanOrEqual(120) + expect(reviews[0].title.length).toBeGreaterThan(100) + expect(reviews[0].title).not.toContain("malformed block") + expect(reviews[0].description).toBe("The full explanation remains in the description.") + }) + it("propagates cancellation that happens during the dedicated review stage", async () => { if (!tmp) throw new Error("missing temp project") sourceMarkers = ["project-a config"] diff --git a/src/lib/ingest.ts b/src/lib/ingest.ts index 0b16d25ed..44fb07c90 100644 --- a/src/lib/ingest.ts +++ b/src/lib/ingest.ts @@ -1863,7 +1863,13 @@ function isOwnedOnlyBySource(content: string, sourceIdentity: string): boolean { ) } -const REVIEW_BLOCK_REGEX = /---REVIEW:\s*(\w[\w-]*)\s*\|\s*(.+?)\s*---\n([\s\S]*?)---END REVIEW---/g +const MAX_REVIEW_TITLE_LENGTH = 120 +const REVIEW_BLOCK_REGEX = /^[ \t]*---REVIEW:[ \t]*(\w[\w-]*)[ \t]*\|[ \t]*([^\r\n]*?)[ \t]*---[ \t]*\r?\n([\s\S]*?)^[ \t]*---END REVIEW---[ \t]*\r?$/gm + +function normalizeReviewTitle(value: string): string { + const title = value.replace(/\s+/g, " ").trim().slice(0, MAX_REVIEW_TITLE_LENGTH).replace(/[ |\-::]+$/u, "") + return title || "Review item" +} function parseReviewBlocks( text: string, @@ -1874,7 +1880,7 @@ function parseReviewBlocks( for (const match of matches) { const rawType = match[1].trim().toLowerCase() - const title = match[2].trim() + const title = normalizeReviewTitle(match[2]) const body = match[3].trim() const type = ( @@ -2200,6 +2206,7 @@ function buildReviewSuggestionPrompt( "- duplicate: likely duplicate pages/names that need user review", "", "Prefer 1-5 high-signal reviews. If there is nothing worth reviewing, output nothing.", + "Keep every REVIEW title on one line and within 60 Chinese characters or 120 total characters.", "For suggestion and missing-page reviews, include a SEARCH line with 2-3 keyword-rich web search queries separated by ` | `.", "Use only these options: OPTIONS: Create Page | Skip", "",