Skip to content

Commit 0a03670

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 0a03670

2 files changed

Lines changed: 138 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,38 @@ interface Frontmatter {
2121

2222
const logger = createLogger('DocsChunker')
2323

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

0 commit comments

Comments
 (0)