Skip to content

Commit cce016d

Browse files
j15zclaude
andcommitted
fix(chunkers): preserve FAQ prose in docs chunks
cleanContent deleted every FAQ section from the embedding index: the multiline tag strip swallows an entire <FAQ items={[...]}/> block (it matches from <FAQ to the first ">", often inside an answer string), and the brace strip eats any surviving { question, answer } items — 637 Q&As across the docs never reached search, with mangled JSX fragments embedded in their place. Consume FAQ blocks whole before the tag strip and emit their question/answer text as plain prose, escape-aware so braces and quotes inside answers survive. Tag and brace stripping are otherwise unchanged — a corpus survey showed FAQ props are the only place real page prose lives inside JSX syntax on searchable pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent eec3c35 commit cce016d

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/knowledge/embeddings', () => ({
7+
generateEmbeddings: vi.fn(async () => ({ embeddings: [] })),
8+
getConfiguredEmbeddingModel: vi.fn(() => 'test-model'),
9+
}))
10+
11+
import { DocsChunker } from '@/lib/chunkers/docs-chunker'
12+
13+
function cleanContent(content: string): string {
14+
const chunker = new DocsChunker()
15+
return (chunker as unknown as { cleanContent(content: string): string }).cleanContent.call(
16+
chunker,
17+
content
18+
)
19+
}
20+
21+
describe('cleanContent FAQ extraction', () => {
22+
it('keeps FAQ question/answer prose that the tag and brace strips would otherwise delete', () => {
23+
const cleaned = cleanContent(
24+
[
25+
'Some intro prose.',
26+
'',
27+
'import { FAQ } from "@/components/ui/faq"',
28+
'',
29+
'<FAQ items={[',
30+
' { question: "What is the maximum file size for uploads?", answer: "The maximum file size for files processed during a workflow run is 20 MB." },',
31+
' { question: "How are files passed between blocks internally?", answer: "Files are represented as standardized UserFile objects." },',
32+
']} />',
33+
].join('\n')
34+
)
35+
36+
expect(cleaned).toContain('What is the maximum file size for uploads?')
37+
expect(cleaned).toContain('20 MB')
38+
expect(cleaned).toContain('standardized UserFile objects')
39+
expect(cleaned).toContain('Some intro prose.')
40+
expect(cleaned).not.toContain('items=')
41+
expect(cleaned).not.toContain('question:')
42+
})
43+
44+
it('survives braces and angle-bracket tokens inside answer strings', () => {
45+
const cleaned = cleanContent(
46+
[
47+
'<FAQ items={[',
48+
` { question: "What input formats work?", answer: "Use a data URI with the format 'data:{mime};base64,{data}' or a URL." },`,
49+
' { question: "Do I extract base64 manually?", answer: "No. Pass the entire file reference (e.g., <gmail.attachments[0]>) and the block extracts what it needs." },',
50+
']} />',
51+
].join('\n')
52+
)
53+
54+
// Brace placeholders keep their token text; the wrapper chars are dropped
55+
// so the later brace strip cannot punch holes in the sentence.
56+
expect(cleaned).toContain("'data:mime;base64,data'")
57+
// Angle brackets are dropped so the tag strip cannot re-eat the sentence.
58+
expect(cleaned).toContain('(e.g., gmail.attachments[0]) and the block extracts')
59+
})
60+
61+
it('extracts items formatted across multiple lines', () => {
62+
const cleaned = cleanContent(
63+
[
64+
'<FAQ items={[',
65+
' {',
66+
' question: "Is SSO supported?",',
67+
' answer: "Yes, on enterprise plans."',
68+
' },',
69+
']} />',
70+
].join('\n')
71+
)
72+
73+
expect(cleaned).toContain('Is SSO supported?')
74+
expect(cleaned).toContain('Yes, on enterprise plans.')
75+
})
76+
77+
it('unescapes escaped quotes in extracted strings', () => {
78+
const cleaned = cleanContent(
79+
'<FAQ items={[ { question: "What does \\"draft\\" mean?", answer: "An unsaved workflow." } ]} />'
80+
)
81+
82+
expect(cleaned).toContain('What does "draft" mean?')
83+
})
84+
})
85+
86+
describe('cleanContent scaffolding strips', () => {
87+
it('still strips imports, exports, comments, and code-ish brace expressions', () => {
88+
const cleaned = cleanContent(
89+
[
90+
'import { Callout } from "fumadocs-ui/components/callout"',
91+
'export const dynamic = "force-static"',
92+
'{/* editorial note */}',
93+
'Visible prose {props.title} continues here.',
94+
'<Callout>Inside text stays</Callout>',
95+
].join('\n')
96+
)
97+
98+
expect(cleaned).not.toContain('import')
99+
expect(cleaned).not.toContain('force-static')
100+
expect(cleaned).not.toContain('editorial note')
101+
expect(cleaned).not.toContain('props.title')
102+
expect(cleaned).toContain('Visible prose')
103+
expect(cleaned).toContain('Inside text stays')
104+
})
105+
})

apps/sim/lib/chunkers/docs-chunker.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,39 @@ interface Frontmatter {
2121

2222
const logger = createLogger('DocsChunker')
2323

24+
/**
25+
* The FAQ component and its items: `<FAQ items={[{ question: "...", answer:
26+
* "..." }, ...]} />`. The component is consumed whole BEFORE the tag strip —
27+
* its opening tag has no `>` until the closing `]} />`, so the multiline tag
28+
* regex would otherwise swallow the items (ending at the first `>` inside an
29+
* answer). The item regex consumes quoted strings escape-aware, so braces or
30+
* quotes inside an answer never end a match early.
31+
*/
32+
const FAQ_COMPONENT_PATTERN = /<FAQ\s+items=\{\[([\s\S]*?)\]\}\s*\/>/g
33+
const FAQ_ITEM_PATTERN =
34+
/\{\s*question:\s*"((?:[^"\\]|\\.)*)"\s*,\s*answer:\s*"((?:[^"\\]|\\.)*)"\s*\}/g
35+
36+
function unescapeJsxString(value: string): string {
37+
return value.replace(/\\(.)/g, (_, char: string) =>
38+
char === 'n' ? '\n' : char === 't' ? '\t' : char
39+
)
40+
}
41+
42+
/**
43+
* Emit an FAQ block's question/answer strings as plain prose lines. Angle
44+
* brackets and braces around inline tokens (`<gmail.attachments[0]>`,
45+
* `data:{mime}`) are dropped so the later tag and brace strips cannot
46+
* re-consume parts of the emitted text.
47+
*/
48+
function extractFaqProse(items: string): string {
49+
const lines: string[] = []
50+
for (const match of items.matchAll(FAQ_ITEM_PATTERN)) {
51+
lines.push(unescapeJsxString(match[1]), unescapeJsxString(match[2]))
52+
}
53+
if (lines.length === 0) return ' '
54+
return `\n${lines.join('\n').replace(/[<>{}]/g, '')}\n`
55+
}
56+
2457
export class DocsChunker {
2558
private readonly textChunker: TextChunker
2659
private readonly baseUrl: string
@@ -216,6 +249,7 @@ export class DocsChunker {
216249
.replace(/\r/g, '\n')
217250
.replace(/^import\s+.*$/gm, '')
218251
.replace(/^export\s+.*$/gm, '')
252+
.replace(FAQ_COMPONENT_PATTERN, (_match, items: string) => extractFaqProse(items))
219253
.replace(/<\/?[a-zA-Z][^>]*>/g, ' ')
220254
.replace(/\{\/\*[\s\S]*?\*\/\}/g, ' ')
221255
.replace(/\{[^{}]*\}/g, ' ')

0 commit comments

Comments
 (0)