-
Notifications
You must be signed in to change notification settings - Fork 1.9k
fix(ai): SQL base EXPLAIN-exhaustion isValid + real-bool crash #1696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ | |
|
|
||
| from ai.common.database.db_global_base import DatabaseGlobalBase | ||
| from ai.common.database.db_instance_base import DatabaseInstanceBase | ||
| from ai.common.utils import parse_bool | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
|
|
@@ -218,6 +219,97 @@ def test_sanitize_row_scalar_input(): | |
| assert DatabaseInstanceBase._sanitize_row(42) == 42 | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # _buildSQLQuery (EXPLAIN exhaustion + isValid parsing) — regression for #1601 | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| class _TestableInstance(DatabaseInstanceBase): | ||
| """Concrete DatabaseInstanceBase that satisfies the two abstract methods.""" | ||
|
|
||
| def _db_display_name(self): | ||
| """Human-readable name used in tool descriptions.""" | ||
| return 'TestDB' | ||
|
|
||
| def _db_dialect(self): | ||
| """Machine-readable dialect identifier.""" | ||
| return 'testdb' | ||
|
|
||
|
|
||
| class _FakeGlobal: | ||
| """Stub IGlobal: every EXPLAIN attempt rejects the query.""" | ||
|
|
||
| def __init__(self, max_attempts=2, explain_error='syntax error near FROM'): | ||
| self.max_validation_attempts = max_attempts | ||
| self._explain_error = explain_error | ||
|
|
||
| def _validateQuery(self, sql): | ||
| return False, self._explain_error | ||
|
|
||
|
|
||
| def _sql_instance(fake_global): | ||
| inst = _TestableInstance.__new__(_TestableInstance) | ||
| inst.IGlobal = fake_global | ||
| return inst | ||
|
|
||
|
|
||
| def test_build_sql_query_marks_invalid_after_explain_exhaustion(): | ||
| """Every EXPLAIN attempt failing must flip isValid to False, not leave the LLM's 'true'. | ||
|
|
||
| Before the fix, exhaustion returned the last LLM response unchanged, so | ||
| get_sql/get_data/writeQuestions executed a statement the database had | ||
| already refused on every attempt. | ||
| """ | ||
| inst = _sql_instance(_FakeGlobal()) | ||
| inst._buildSQLQueryOnce = lambda question_text, *, limit, previous_sql, error: { | ||
| 'isValid': 'true', | ||
| 'query': 'SELECT * FROM users', | ||
| } | ||
|
|
||
| result = inst._buildSQLQuery('all users') | ||
|
|
||
| assert parse_bool(result.get('isValid')) is False | ||
| assert result.get('error') # the EXPLAIN error is carried for the caller | ||
|
|
||
|
|
||
| def test_build_sql_query_accepts_real_json_bool_isvalid(): | ||
| """A real JSON bool from the LLM must not crash isValid parsing. | ||
|
|
||
| Before the fix, `result.get('isValid', '').lower()` raised AttributeError | ||
| the moment the LLM returned a real bool instead of the string 'true'. | ||
| """ | ||
| fake_global = _FakeGlobal() | ||
| fake_global._validateQuery = lambda sql: (True, None) | ||
| inst = _sql_instance(fake_global) | ||
| inst._buildSQLQueryOnce = lambda question_text, *, limit, previous_sql, error: { | ||
| 'isValid': True, # real bool, not the string 'true' | ||
| 'query': 'SELECT 1', | ||
| } | ||
|
|
||
| result = inst._buildSQLQuery('anything') # must not raise AttributeError | ||
|
|
||
| assert parse_bool(result.get('isValid')) is True | ||
|
|
||
|
|
||
| def test_get_sql_surfaces_explain_error_not_rejected_sql(): | ||
| """get_sql() must return the EXPLAIN error, not the rejected SQL as prose. | ||
|
|
||
| Before this fix, the invalid branch always returned {'answer': sql_query, | ||
| 'valid': False}, so a caller could not tell an EXPLAIN rejection (rejected | ||
| SQL text) apart from a genuinely off-topic question (LLM prose answer). | ||
| """ | ||
| fake_global = _FakeGlobal(explain_error='column "userz" does not exist') | ||
| inst = _sql_instance(fake_global) | ||
| inst._buildSQLQueryOnce = lambda question_text, *, limit, previous_sql, error: { | ||
| 'isValid': 'true', | ||
| 'query': 'SELECT * FROM userz', | ||
| } | ||
|
|
||
| result = inst.get_sql({'question': 'all users'}) | ||
|
|
||
| assert result == {'error': 'column "userz" does not exist', 'valid': False} | ||
|
|
||
|
Comment on lines
+294
to
+311
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Add regression coverage for The new test covers 🤖 Prompt for AI Agents |
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # _tableExists (engine-backed) | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.