Skip to content

Commit 58af8aa

Browse files
j15zclaude
andcommitted
fix(chunkers): preserve FAQ and component-attribute prose in docs chunks
cleanContent stripped every JSX brace expression and whole tags, which deleted the FAQ component's question/answer strings (595 Q&As across 108 docs pages) and prose-bearing title/description/alt attributes from the embedding index — while leaving mangled JSX debris behind. Consume FAQ blocks whole before the tag strip and emit their Q&A text as plain prose, keep title/description/alt values when tags are dropped, and leave the brace strip untouched for genuinely code-ish expressions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent eec3c35 commit 58af8aa

2 files changed

Lines changed: 181 additions & 1 deletion

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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 prose attributes', () => {
87+
it('keeps title, description, and alt values while dropping the tag chrome', () => {
88+
const cleaned = cleanContent(
89+
[
90+
'<Card title="Using tables in workflows" description="Read, write, and update rows with the Table block." href="/tables/using-in-workflows" className="p-2">',
91+
'</Card>',
92+
'<Image src="/static/tables.png" alt="A table of typed columns" width={800} height={500} />',
93+
].join('\n')
94+
)
95+
96+
expect(cleaned).toContain('Using tables in workflows')
97+
expect(cleaned).toContain('Read, write, and update rows with the Table block.')
98+
expect(cleaned).toContain('A table of typed columns')
99+
expect(cleaned).not.toContain('href')
100+
expect(cleaned).not.toContain('p-2')
101+
expect(cleaned).not.toContain('/static/tables.png')
102+
})
103+
})
104+
105+
describe('cleanContent scaffolding strips', () => {
106+
it('still strips imports, exports, comments, and code-ish brace expressions', () => {
107+
const cleaned = cleanContent(
108+
[
109+
'import { Callout } from "fumadocs-ui/components/callout"',
110+
'export const dynamic = "force-static"',
111+
'{/* editorial note */}',
112+
'Visible prose {props.title} continues here.',
113+
'<Callout>Inside text stays</Callout>',
114+
].join('\n')
115+
)
116+
117+
expect(cleaned).not.toContain('import')
118+
expect(cleaned).not.toContain('force-static')
119+
expect(cleaned).not.toContain('editorial note')
120+
expect(cleaned).not.toContain('props.title')
121+
expect(cleaned).toContain('Visible prose')
122+
expect(cleaned).toContain('Inside text stays')
123+
})
124+
})

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,61 @@ 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+
/**
37+
* Tag attributes whose string values are page prose (fumadocs Card/Image/
38+
* Accordion vocabulary), kept as text when the surrounding tag is stripped.
39+
* Everything else inside JSX syntax is scaffolding. Extend this vocabulary
40+
* when a new docs component carries prose in its props.
41+
*/
42+
const PROSE_TAG_ATTRIBUTE_PATTERN = /\b(?:title|description|alt)="([^"]*)"/g
43+
44+
function unescapeJsxString(value: string): string {
45+
return value.replace(/\\(.)/g, (_, char: string) =>
46+
char === 'n' ? '\n' : char === 't' ? '\t' : char
47+
)
48+
}
49+
50+
/**
51+
* Emit an FAQ block's question/answer strings as plain prose lines. Angle
52+
* brackets and braces around inline tokens (`<gmail.attachments[0]>`,
53+
* `data:{mime}`) are dropped so the later tag and brace strips cannot
54+
* re-consume parts of the emitted text.
55+
*/
56+
function extractFaqProse(items: string): string {
57+
const lines: string[] = []
58+
for (const match of items.matchAll(FAQ_ITEM_PATTERN)) {
59+
lines.push(unescapeJsxString(match[1]), unescapeJsxString(match[2]))
60+
}
61+
if (lines.length === 0) return ' '
62+
return `\n${lines.join('\n').replace(/[<>{}]/g, '')}\n`
63+
}
64+
65+
/**
66+
* Tag-strip replacer keeping prose-bearing attribute values (title,
67+
* description, alt) while the tag and its remaining attributes are dropped.
68+
*/
69+
function keepProseAttributes(tag: string): string {
70+
const kept: string[] = []
71+
for (const match of tag.matchAll(PROSE_TAG_ATTRIBUTE_PATTERN)) {
72+
const value = match[1].trim()
73+
if (value) kept.push(value)
74+
}
75+
if (kept.length === 0) return ' '
76+
return ` ${kept.join('. ')} `
77+
}
78+
2479
export class DocsChunker {
2580
private readonly textChunker: TextChunker
2681
private readonly baseUrl: string
@@ -216,7 +271,8 @@ export class DocsChunker {
216271
.replace(/\r/g, '\n')
217272
.replace(/^import\s+.*$/gm, '')
218273
.replace(/^export\s+.*$/gm, '')
219-
.replace(/<\/?[a-zA-Z][^>]*>/g, ' ')
274+
.replace(FAQ_COMPONENT_PATTERN, (_match, items: string) => extractFaqProse(items))
275+
.replace(/<\/?[a-zA-Z][^>]*>/g, keepProseAttributes)
220276
.replace(/\{\/\*[\s\S]*?\*\/\}/g, ' ')
221277
.replace(/\{[^{}]*\}/g, ' ')
222278
.replace(/\n{3,}/g, '\n\n')

0 commit comments

Comments
 (0)