Skip to content

Commit feb112c

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/workspace-variables
2 parents 2fac109 + 9b80fdd commit feb112c

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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('extracts single-quoted multiline items with trailing commas (session-policies shape)', () => {
78+
const cleaned = cleanContent(
79+
[
80+
'<FAQ',
81+
' items={[',
82+
' {',
83+
" question: 'Do session policies apply to SSO sign-ins?',",
84+
' answer:',
85+
" 'Yes. Sessions created through SSO follow the same limits.',",
86+
' },',
87+
' {',
88+
' question: \'Does "Sign out all members" affect API keys?\',',
89+
" answer: 'No. API keys are unaffected.',",
90+
' },',
91+
' ]}',
92+
'/>',
93+
].join('\n')
94+
)
95+
96+
expect(cleaned).toContain('Do session policies apply to SSO sign-ins?')
97+
expect(cleaned).toContain('Yes. Sessions created through SSO follow the same limits.')
98+
expect(cleaned).toContain('Does "Sign out all members" affect API keys?')
99+
expect(cleaned).toContain('No. API keys are unaffected.')
100+
expect(cleaned).not.toContain('items=')
101+
})
102+
103+
it('unescapes escaped quotes in extracted strings', () => {
104+
const cleaned = cleanContent(
105+
'<FAQ items={[ { question: "What does \\"draft\\" mean?", answer: "An unsaved workflow." } ]} />'
106+
)
107+
108+
expect(cleaned).toContain('What does "draft" mean?')
109+
})
110+
})
111+
112+
describe('cleanContent scaffolding strips', () => {
113+
it('still strips imports, exports, comments, and code-ish brace expressions', () => {
114+
const cleaned = cleanContent(
115+
[
116+
'import { Callout } from "fumadocs-ui/components/callout"',
117+
'export const dynamic = "force-static"',
118+
'{/* editorial note */}',
119+
'Visible prose {props.title} continues here.',
120+
'<Callout>Inside text stays</Callout>',
121+
].join('\n')
122+
)
123+
124+
expect(cleaned).not.toContain('import')
125+
expect(cleaned).not.toContain('force-static')
126+
expect(cleaned).not.toContain('editorial note')
127+
expect(cleaned).not.toContain('props.title')
128+
expect(cleaned).toContain('Visible prose')
129+
expect(cleaned).toContain('Inside text stays')
130+
})
131+
})

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

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

2222
const logger = createLogger('DocsChunker')
2323

24+
/**
25+
* One `{ question: "...", answer: "..." }` FAQ item, in either quote style and
26+
* with an optional trailing comma (`session-policies.mdx` uses single-quoted
27+
* multiline items). Each captured value keeps its surrounding quotes — the
28+
* quoted strings are consumed escape-aware per style, so quotes of the other
29+
* style, braces, or escapes inside an answer never end a match early.
30+
*/
31+
const FAQ_ITEM_PATTERN =
32+
/\{\s*question:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*,\s*answer:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*,?\s*\}/g
33+
34+
/** Strip a captured value's surrounding quotes (either style), then unescape. */
35+
function unquoteJsxString(value: string): string {
36+
return unescapeJsxString(value.slice(1, -1))
37+
}
38+
39+
function unescapeJsxString(value: string): string {
40+
return value.replace(/\\(.)/g, (_, char: string) =>
41+
char === 'n' ? '\n' : char === 't' ? '\t' : char
42+
)
43+
}
44+
45+
/**
46+
* Emit an FAQ block's question/answer strings as plain prose lines. Must run
47+
* BEFORE the tag strip: a `<FAQ items={[` opening tag has no `>` until the
48+
* closing `]} />`, so the multiline tag regex would otherwise swallow the
49+
* items whole (ending at the first `>` inside an answer). Angle brackets and
50+
* braces around inline tokens (`<gmail.attachments[0]>`, `data:{mime}`) are
51+
* dropped so the later tag and brace strips cannot re-consume the emitted
52+
* text.
53+
*/
54+
function extractFaqProse(items: string): string {
55+
const lines: string[] = []
56+
for (const match of items.matchAll(FAQ_ITEM_PATTERN)) {
57+
lines.push(unquoteJsxString(match[1]), unquoteJsxString(match[2]))
58+
}
59+
if (lines.length === 0) return ' '
60+
return `\n${lines.join('\n').replace(/[<>{}]/g, '')}\n`
61+
}
62+
2463
export class DocsChunker {
2564
private readonly textChunker: TextChunker
2665
private readonly baseUrl: string
@@ -216,6 +255,9 @@ export class DocsChunker {
216255
.replace(/\r/g, '\n')
217256
.replace(/^import\s+.*$/gm, '')
218257
.replace(/^export\s+.*$/gm, '')
258+
.replace(/<FAQ\s+items=\{\[([\s\S]*?)\]\}\s*\/>/g, (_m, items: string) =>
259+
extractFaqProse(items)
260+
)
219261
.replace(/<\/?[a-zA-Z][^>]*>/g, ' ')
220262
.replace(/\{\/\*[\s\S]*?\*\/\}/g, ' ')
221263
.replace(/\{[^{}]*\}/g, ' ')

0 commit comments

Comments
 (0)