Skip to content

Commit 1073e49

Browse files
authored
Fix LLMSQLQueryOperator not stripping single-line markdown code fences (#70137)
* Fix LLMSQLQueryOperator not stripping single-line markdown code fences _strip_llm_output only removed ```sql fences when the LLM's response spanned multiple lines. LLMs that wrap short queries in a fence on a single line (e.g. "```SELECT 1```") passed the fenced text straight into SQL validation/execution with the backticks still attached. Add a fallback branch for the single-line case, alongside the existing multi-line handling. * Also strip a same-line language tag on single-line fences Address review feedback: a single-line fence like "```sql SELECT 1```" left "sql " stuck to the front of the query, since the earlier fix only stripped the outer backticks. Drop the leading word too, but only when it's "sql" or the resolved dialect, so a real leading SQL keyword is never mistaken for a tag.
1 parent f3edf4d commit 1073e49

2 files changed

Lines changed: 51 additions & 3 deletions

File tree

providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def execute(self, context: Context) -> str:
152152
)
153153
result = agent.run_sync(self.prompt, usage_limits=self.usage_limits)
154154
log_run_summary(self.log, result)
155-
sql = self._strip_llm_output(result.output)
155+
sql = self._strip_llm_output(result.output, dialect=self._resolved_dialect)
156156

157157
if self.validate_sql:
158158
_validate_sql(sql, allowed_types=self.allowed_sql_types, dialect=self._resolved_dialect)
@@ -172,15 +172,25 @@ def execute_complete(self, context: Context, generated_output: str, event: dict[
172172
return output
173173

174174
@staticmethod
175-
def _strip_llm_output(raw: str) -> str:
175+
def _strip_llm_output(raw: str, *, dialect: str | None = None) -> str:
176176
"""Strip whitespace and markdown code fences from LLM output."""
177177
text = raw.strip()
178178
if text.startswith("```"):
179179
lines = text.split("\n")
180-
# Remove opening fence (```sql, ```, etc.) and closing fence
181180
if len(lines) >= 2:
181+
# Remove opening fence (```sql, ```, etc.) and closing fence
182182
end = -1 if lines[-1].strip().startswith("```") else len(lines)
183183
text = "\n".join(lines[1:end]).strip()
184+
elif text.endswith("```") and len(text) > 6:
185+
# Whole fenced block on one line, e.g. "```sql SELECT 1```" -> "SELECT 1".
186+
# Only drop the leading word if it's a known tag, so a real keyword
187+
# like "SELECT" is never mistaken for one.
188+
inner = text[3:-3].strip()
189+
tags = {"sql", *([dialect.lower()] if dialect else [])}
190+
first_word, sep, rest = inner.partition(" ")
191+
if sep and first_word.lower() in tags:
192+
inner = rest.strip()
193+
text = inner
184194
return text
185195

186196
def _get_schema_context(self) -> str:

providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,49 @@ class TestStripLLMOutput:
7777
"SELECT 1",
7878
id="missing_closing_fence",
7979
),
80+
pytest.param(
81+
"```SELECT 1```",
82+
"SELECT 1",
83+
id="single_line_fence_no_language_tag",
84+
),
85+
pytest.param(
86+
"```SELECT * FROM users LIMIT 10```",
87+
"SELECT * FROM users LIMIT 10",
88+
id="single_line_fence_with_query",
89+
),
90+
pytest.param(
91+
"```sql SELECT 1```",
92+
"SELECT 1",
93+
id="single_line_fence_with_language_tag",
94+
),
95+
pytest.param(
96+
"```SQL SELECT 1```",
97+
"SELECT 1",
98+
id="single_line_fence_with_uppercase_language_tag",
99+
),
80100
),
81101
)
82102
def test_strip_llm_output(self, raw, expected):
83103
assert LLMSQLQueryOperator._strip_llm_output(raw) == expected
84104

105+
@pytest.mark.parametrize(
106+
("raw", "dialect", "expected"),
107+
(
108+
pytest.param("```postgres SELECT 1```", "postgres", "SELECT 1", id="dialect_tag_matches"),
109+
pytest.param(
110+
"```POSTGRES SELECT 1```", "postgres", "SELECT 1", id="dialect_tag_case_insensitive"
111+
),
112+
pytest.param(
113+
"```mysql SELECT 1```",
114+
"postgres",
115+
"mysql SELECT 1",
116+
id="dialect_tag_mismatch_left_alone",
117+
),
118+
),
119+
)
120+
def test_strip_llm_output_with_dialect(self, raw, dialect, expected):
121+
assert LLMSQLQueryOperator._strip_llm_output(raw, dialect=dialect) == expected
122+
85123

86124
class TestLLMSQLQueryOperator:
87125
def test_inherits_from_llm_operator(self):

0 commit comments

Comments
 (0)