Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/lib/ingest-source-path-collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
11 changes: 9 additions & 2 deletions src/lib/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = (
Expand Down Expand Up @@ -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",
"",
Expand Down