Skip to content

Commit 9e4f00b

Browse files
committed
fix(snowflake): keep Cortex Analyst SQL context for quoted and multi-source views and replay suggestions
1 parent 0b00701 commit 9e4f00b

1 file changed

Lines changed: 71 additions & 15 deletions

File tree

‎apps/sim/tools/snowflake/cortex.ts‎

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,25 +90,75 @@ function assertStagePath(value: string): void {
9090
}
9191
}
9292

93-
const UNQUOTED_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/
93+
/** An unquoted identifier, or a double-quoted one with `""` escapes, as Execute SQL accepts. */
94+
const CONTEXT_IDENTIFIER = /^(?:[A-Za-z_][A-Za-z0-9_$]*|"(?:[^"]|"")+")$/
95+
96+
/** Splits a qualified name on the dots outside double-quoted identifiers. */
97+
function splitQualifiedName(name: string): string[] {
98+
const parts: string[] = []
99+
let current = ''
100+
let quoted = false
101+
for (const char of name) {
102+
if (char === '"') quoted = !quoted
103+
if (char === '.' && !quoted) {
104+
parts.push(current)
105+
current = ''
106+
} else {
107+
current += char
108+
}
109+
}
110+
parts.push(current)
111+
return parts
112+
}
113+
114+
/** Database and schema of a `DB.SCHEMA.VIEW` view name or an `@DB.SCHEMA.STAGE/file` stage path. */
115+
function sourceContext(
116+
kind: 'semantic_view' | 'semantic_model_file',
117+
value: string
118+
): { database: string; schema: string } | null {
119+
const name = kind === 'semantic_view' ? value.trim() : value.trim().slice(1).split('/')[0]
120+
const parts = splitQualifiedName(name)
121+
if (parts.length !== 3) return null
122+
const [database, schema] = parts
123+
if (!CONTEXT_IDENTIFIER.test(database) || !CONTEXT_IDENTIFIER.test(schema)) return null
124+
return { database, schema }
125+
}
94126

95127
/**
96-
* Database and schema to run the generated SQL in, read from a fully qualified semantic view
97-
* (`DB.SCHEMA.VIEW`) or stage path (`@DB.SCHEMA.STAGE/file.yaml`). Generated SQL can reference
98-
* the semantic view unqualified, so it has to run in that view's schema. Quoted identifiers are
99-
* left to the credential's default context rather than re-quoted.
128+
* Database and schema to run the generated SQL in. Generated SQL can reference the semantic
129+
* view unqualified, so it runs in the fully qualified view's (or staged model's) schema. With
130+
* several sources, Snowflake does not document how `semantic_model_selection` names the chosen
131+
* one, so the context is set only when every source shares the same database and schema;
132+
* otherwise the credential's default context applies.
100133
*/
101134
export function cortexAnalystSqlContext(
102-
params: Pick<SnowflakeCortexAnalystAskParams, 'semanticView' | 'semanticModelFile'>
135+
params: Pick<
136+
SnowflakeCortexAnalystAskParams,
137+
'semanticView' | 'semanticModelFile' | 'semanticModels'
138+
>
103139
): { database?: string; schema?: string } {
104-
const name = hasValue(params.semanticView)
105-
? String(params.semanticView).trim()
140+
const contexts = hasValue(params.semanticView)
141+
? [sourceContext('semantic_view', String(params.semanticView))]
106142
: hasValue(params.semanticModelFile)
107-
? String(params.semanticModelFile).trim().slice(1).split('/')[0]
108-
: ''
109-
const parts = name.split('.')
110-
if (parts.length !== 3 || !parts.every((part) => UNQUOTED_IDENTIFIER.test(part))) return {}
111-
return { database: parts[0], schema: parts[1] }
143+
? [sourceContext('semantic_model_file', String(params.semanticModelFile))]
144+
: hasValue(params.semanticModels)
145+
? parseSemanticModels(params.semanticModels).map((entry) =>
146+
entry.semantic_view !== undefined
147+
? sourceContext('semantic_view', entry.semantic_view)
148+
: sourceContext('semantic_model_file', entry.semantic_model_file)
149+
)
150+
: []
151+
const [first] = contexts
152+
if (
153+
!first ||
154+
contexts.some(
155+
(context) =>
156+
!context || context.database !== first.database || context.schema !== first.schema
157+
)
158+
) {
159+
return {}
160+
}
161+
return { database: first.database, schema: first.schema }
112162
}
113163

114164
/** The request's user turn, appended after any history. */
@@ -182,8 +232,10 @@ function stringArray(value: unknown): string[] {
182232
* `suggestion` and `suggestions`, and types the field as a string while its examples return an
183233
* array, so every form is read.
184234
*
185-
* The analyst turn is echoed into `conversation` in the documented request shape (text blocks
186-
* and `sql` statements only), so it can be replayed as history on the next ask.
235+
* The analyst turn is echoed into `conversation` so it can be replayed as history on the next
236+
* ask: text, `sql` statements without their confidence metadata, and suggestion lists, so a
237+
* follow-up can refer to a suggested question. Snowflake's own Cortex Analyst client replays
238+
* suggestion blocks the same way.
187239
*/
188240
export function mapCortexAnalystResponse(
189241
data: CortexAnalystResponsePayload,
@@ -222,6 +274,10 @@ export function mapCortexAnalystResponse(
222274
if (block.type === 'sql' && block.statement) {
223275
return [{ type: 'sql', statement: block.statement }]
224276
}
277+
if (block.type === 'suggestions' || block.type === 'suggestion') {
278+
const suggested = stringArray(block.suggestions)
279+
return suggested.length > 0 ? [{ type: block.type, suggestions: suggested }] : []
280+
}
225281
return []
226282
}),
227283
}

0 commit comments

Comments
 (0)