Shizu0n commited on
Commit
92923b1
·
1 Parent(s): 3c4dce1

fix: unsupported data mutation routing

Browse files
Files changed (6) hide show
  1. README.md +2 -1
  2. app.py +23 -0
  3. intent.py +4 -0
  4. sql_tools.py +65 -1
  5. tests/test_chatbot_behavior.py +87 -1
  6. tests/test_chatbot_core.py +46 -0
README.md CHANGED
@@ -51,6 +51,7 @@ Reported gain: **+71.5 percentage points** over the base model.
51
  Known limits:
52
 
53
  - The training data is mostly `SELECT`; `INSERT`, `UPDATE`, and `DELETE` are not a reliable model capability.
 
54
  - PT-BR input is not trained model capability. The app handles selected PT-BR cases through deterministic normalization/templates.
55
  - Conversational chat and JSON schema proposal are out of scope and use fallback behavior instead of model prompts.
56
  - Exact match does not prove semantic perfection. It is useful evidence, not a substitute for qualitative review.
@@ -126,7 +127,7 @@ The probe prints JSON with pass/fail checks for static fallback, deterministic C
126
  set PYTHONPATH=. && pytest tests/test_chatbot_core.py tests/test_chatbot_behavior.py -q
127
  ```
128
 
129
- Current unit suite: **105 tests**. These tests avoid loading the 3.8B model and focus on routing, deterministic tools, prompt construction, model-output rejection, SQL validation, UI schema-context synchronization, and error handling.
130
 
131
  ## Run Locally
132
 
 
51
  Known limits:
52
 
53
  - The training data is mostly `SELECT`; `INSERT`, `UPDATE`, and `DELETE` are not a reliable model capability.
54
+ - Unsupported DML requests are not generated by the model and must not be disguised as schema edits. For example, `delete species` can remove a schema column, but `delete all animals` returns a static unsupported-scope fallback.
55
  - PT-BR input is not trained model capability. The app handles selected PT-BR cases through deterministic normalization/templates.
56
  - Conversational chat and JSON schema proposal are out of scope and use fallback behavior instead of model prompts.
57
  - Exact match does not prove semantic perfection. It is useful evidence, not a substitute for qualitative review.
 
127
  set PYTHONPATH=. && pytest tests/test_chatbot_core.py tests/test_chatbot_behavior.py -q
128
  ```
129
 
130
+ Current unit suite: **127 tests**. These tests avoid loading the 3.8B model and focus on routing, deterministic tools, prompt construction, model-output rejection, SQL validation, UI schema-context synchronization, and error handling.
131
 
132
  ## Run Locally
133
 
app.py CHANGED
@@ -28,6 +28,10 @@ FALLBACK_RESPONSE = (
28
  "Example: 'what is the most expensive product?' or "
29
  "'create table products with id name price'."
30
  )
 
 
 
 
31
  SOURCE_FINE_TUNED_MODEL = "Source: fine-tuned model"
32
  SOURCE_DETERMINISTIC_SQL_TEMPLATE = "Source: deterministic SQL template"
33
  SOURCE_DETERMINISTIC_SCHEMA_PARSER = "Source: deterministic schema parser"
@@ -1175,6 +1179,14 @@ def generate_response(message, chat_history, active_schema, loaded_key, conversa
1175
  sql_text=edited_table,
1176
  validator=sql_core.validate_sql(edited_table),
1177
  )
 
 
 
 
 
 
 
 
1178
  return _empty_generation_response(
1179
  chat_history,
1180
  message,
@@ -1204,6 +1216,17 @@ def generate_response(message, chat_history, active_schema, loaded_key, conversa
1204
  source_label=SOURCE_DETERMINISTIC_SCHEMA_PARSER,
1205
  )
1206
 
 
 
 
 
 
 
 
 
 
 
 
1207
  if intent_result.intent in {intent_core.SMALLTALK, intent_core.UNKNOWN}:
1208
  return _response_tuple(
1209
  chat_history,
 
28
  "Example: 'what is the most expensive product?' or "
29
  "'create table products with id name price'."
30
  )
31
+ UNSUPPORTED_MUTATION_RESPONSE = (
32
+ "This demo does not generate INSERT, UPDATE, DELETE, or DROP statements. "
33
+ "It only supports SELECT/WITH model SQL plus deterministic CREATE TABLE tools."
34
+ )
35
  SOURCE_FINE_TUNED_MODEL = "Source: fine-tuned model"
36
  SOURCE_DETERMINISTIC_SQL_TEMPLATE = "Source: deterministic SQL template"
37
  SOURCE_DETERMINISTIC_SCHEMA_PARSER = "Source: deterministic schema parser"
 
1179
  sql_text=edited_table,
1180
  validator=sql_core.validate_sql(edited_table),
1181
  )
1182
+ if sql_core.last_create_table_from_history(chat_history) or sql_core.create_table_from_schema(state.active_schema):
1183
+ return _empty_generation_response(
1184
+ chat_history,
1185
+ message,
1186
+ state,
1187
+ "No matching schema column was changed.",
1188
+ source_label=SOURCE_DETERMINISTIC_SCHEMA_PARSER,
1189
+ )
1190
  return _empty_generation_response(
1191
  chat_history,
1192
  message,
 
1216
  source_label=SOURCE_DETERMINISTIC_SCHEMA_PARSER,
1217
  )
1218
 
1219
+ if intent_result.intent == intent_core.UNKNOWN and intent_result.reason == "unsupported_data_mutation":
1220
+ return _response_tuple(
1221
+ chat_history,
1222
+ message,
1223
+ state,
1224
+ UNSUPPORTED_MUTATION_RESPONSE,
1225
+ f"{SOURCE_STATIC_FALLBACK}. Unsupported data mutation - no model call.",
1226
+ sql_text="",
1227
+ validator=EMPTY_VALIDATOR,
1228
+ )
1229
+
1230
  if intent_result.intent in {intent_core.SMALLTALK, intent_core.UNKNOWN}:
1231
  return _response_tuple(
1232
  chat_history,
intent.py CHANGED
@@ -55,6 +55,10 @@ def classify_intent(message, state=None, chat_history=None):
55
  if _is_smalltalk(message):
56
  return IntentResult(SMALLTALK, 0.95, "smalltalk_phrase")
57
 
 
 
 
 
58
  edited_table = sql_tools.edit_create_table_from_message(message, chat_history, state.active_schema)
59
  if edited_table or sql_tools.is_table_edit_intent(message):
60
  return IntentResult(
 
55
  if _is_smalltalk(message):
56
  return IntentResult(SMALLTALK, 0.95, "smalltalk_phrase")
57
 
58
+ schema_context = sql_tools.last_create_table_from_history(chat_history) or state.active_schema
59
+ if sql_tools.is_unsupported_data_mutation_for_schema(message, schema_context):
60
+ return IntentResult(UNKNOWN, 0.9, "unsupported_data_mutation")
61
+
62
  edited_table = sql_tools.edit_create_table_from_message(message, chat_history, state.active_schema)
63
  if edited_table or sql_tools.is_table_edit_intent(message):
64
  return IntentResult(
sql_tools.py CHANGED
@@ -284,6 +284,67 @@ def is_table_edit_intent(message):
284
  )
285
 
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  def infer_column_type(column_name):
288
  name = column_name.strip().lower()
289
  if name == "id" or name.endswith("_id") or name in {"quantity", "quantidade", "stock", "estoque", "year"}:
@@ -795,7 +856,10 @@ def edit_create_table_from_message(message, chat_history, active_schema):
795
  for col_name, col_type in existing_columns
796
  if col_name not in removed_set
797
  ]
798
- return format_create_table(table_name, [*kept_columns, *added_columns])
 
 
 
799
 
800
 
801
  def create_table_from_suggestion(suggestion):
 
284
  )
285
 
286
 
287
+ def is_unsupported_data_mutation_intent(message):
288
+ message = normalize_text(message)
289
+ if not message:
290
+ return False
291
+ if is_create_table_intent(message):
292
+ return False
293
+ explicit_dml = (
294
+ r"\b(?:insert\s+into|update\b.+\bset\b|delete\s+from|"
295
+ r"drop\s+table|drop\s+(?:the\s+|a\s+|an\s+)?\w+\s+table|truncate\s+table)\b"
296
+ )
297
+ if re.search(explicit_dml, message):
298
+ return True
299
+ if re.search(r"\binsert\b", message) and not re.search(r"\b(column|field|coluna|campo)\b", message):
300
+ return True
301
+ row_terms = r"(?:row|rows|record|records|linha|linhas|registro|registros)"
302
+ row_mutation = (
303
+ rf"\b(?:add|create|insert|include|remove|delete|drop|"
304
+ rf"adicionar|adicione|criar|crie|incluir|inclua|remover|remova|deletar|excluir|exclua|apagar|apague)\b"
305
+ rf".*\b{row_terms}\b"
306
+ )
307
+ if re.search(row_mutation, message):
308
+ return True
309
+ destructive_all = (
310
+ r"\b(?:delete|drop|remove|deletar|exclua|excluir|apaga|apagar|apague|remova|remover)\b"
311
+ r"\s+(?:all|every|rows?|records?|table|todos|todas|linhas?|registros?|tabela)\b"
312
+ )
313
+ return bool(re.search(destructive_all, message))
314
+
315
+
316
+ def _table_name_variants(table_name):
317
+ base = normalize_text(table_name)
318
+ if not base:
319
+ return set()
320
+ variants = {base}
321
+ if base.endswith("ies") and len(base) > 3:
322
+ variants.add(f"{base[:-3]}y")
323
+ elif base.endswith("s") and len(base) > 1:
324
+ variants.add(base[:-1])
325
+ else:
326
+ variants.add(f"{base}s")
327
+ return {variant for variant in variants if variant}
328
+
329
+
330
+ def is_unsupported_data_mutation_for_schema(message, active_schema=""):
331
+ if is_unsupported_data_mutation_intent(message):
332
+ return True
333
+ table_name, _columns = parse_create_table_schema(create_table_from_schema(active_schema))
334
+ if not table_name:
335
+ return False
336
+ message = normalize_text(message)
337
+ table_pattern = "|".join(
338
+ re.escape(variant)
339
+ for variant in sorted(_table_name_variants(table_name), key=len, reverse=True)
340
+ )
341
+ table_mutation = (
342
+ rf"\b(?:delete|drop|remove|deletar|excluir|exclua|remover|remova)\b"
343
+ rf"\s+(?:the\s+|a\s+|an\s+)?(?:table\s+)?(?:{table_pattern})\b(?:\s+table)?"
344
+ )
345
+ return bool(re.search(table_mutation, message))
346
+
347
+
348
  def infer_column_type(column_name):
349
  name = column_name.strip().lower()
350
  if name == "id" or name.endswith("_id") or name in {"quantity", "quantidade", "stock", "estoque", "year"}:
 
856
  for col_name, col_type in existing_columns
857
  if col_name not in removed_set
858
  ]
859
+ updated_columns = [*kept_columns, *added_columns]
860
+ if updated_columns == existing_columns:
861
+ return ""
862
+ return format_create_table(table_name, updated_columns)
863
 
864
 
865
  def create_table_from_suggestion(suggestion):
tests/test_chatbot_behavior.py CHANGED
@@ -148,7 +148,7 @@ def test_add_column_variants(message, expected_col, monkeypatch):
148
  [
149
  ("remova salario", "salario"),
150
  ("remove nome", "nome"),
151
- ("delete salary", "salary"),
152
  ("drop coluna id", "id"),
153
  ],
154
  )
@@ -674,6 +674,92 @@ def test_edit_without_existing_table_returns_error():
674
  assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
675
 
676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  def test_non_template_sql_intent_without_model_returns_load_error():
678
  result = app.generate_response(
679
  "find employees named Alice", [], app.PRESETS["employees"], None, None
 
148
  [
149
  ("remova salario", "salario"),
150
  ("remove nome", "nome"),
151
+ ("delete salario", "salario"),
152
  ("drop coluna id", "id"),
153
  ],
154
  )
 
674
  assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
675
 
676
 
677
+ @pytest.mark.parametrize(
678
+ "message",
679
+ [
680
+ "delete all animals",
681
+ "delete animals",
682
+ "DELETE FROM animals",
683
+ "drop table animals",
684
+ "drop animals table",
685
+ "drop the animals table",
686
+ "drop animals",
687
+ "update animals set weight = 0",
688
+ "insert into animals values (1)",
689
+ "insert animal",
690
+ "insert row into animals",
691
+ "add row to animals",
692
+ "add animal record",
693
+ ],
694
+ )
695
+ def test_unsupported_data_mutation_returns_static_fallback(message, monkeypatch):
696
+ monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
697
+
698
+ result = app.generate_response(
699
+ message,
700
+ [],
701
+ "CREATE TABLE animals (id INTEGER, name TEXT, weight NUMERIC)",
702
+ None,
703
+ None,
704
+ )
705
+
706
+ assert sql_output(result) == ""
707
+ assert result[5] == app.EMPTY_VALIDATOR
708
+ assert app.UNSUPPORTED_MUTATION_RESPONSE in assistant_text(result)
709
+ assert app.SOURCE_STATIC_FALLBACK in status_html(result)
710
+ assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER not in status_html(result)
711
+
712
+
713
+ def test_remove_nonexistent_columns_does_not_return_unchanged_schema():
714
+ schema = "CREATE TABLE animals (id INTEGER, name TEXT, species TEXT);"
715
+
716
+ assert app.sql_core.edit_create_table_from_message("delete all animals", [], schema) == ""
717
+
718
+
719
+ def test_noop_schema_edit_reports_no_matching_column(monkeypatch):
720
+ monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
721
+
722
+ result = app.generate_response(
723
+ "delete salary",
724
+ [],
725
+ "CREATE TABLE animals (id INTEGER, name TEXT, species TEXT)",
726
+ None,
727
+ None,
728
+ )
729
+
730
+ assert sql_output(result) == ""
731
+ assert "No matching schema column was changed." in status_html(result)
732
+ assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
733
+
734
+
735
+ def test_data_mutation_uses_schema_from_history_when_active_schema_empty(monkeypatch):
736
+ monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
737
+ history = [
738
+ {
739
+ "role": "assistant",
740
+ "content": "```sql\nCREATE TABLE animals (id INTEGER, species TEXT);\n```",
741
+ }
742
+ ]
743
+
744
+ result = app.generate_response("delete animals", history, "", None, None)
745
+
746
+ assert sql_output(result) == ""
747
+ assert app.UNSUPPORTED_MUTATION_RESPONSE in assistant_text(result)
748
+ assert app.SOURCE_STATIC_FALLBACK in status_html(result)
749
+
750
+
751
+ @pytest.mark.parametrize("message", ["create table rows with id name", "create table records with id name"])
752
+ def test_create_table_names_that_overlap_row_terms_still_create_schema(message, monkeypatch):
753
+ monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
754
+
755
+ result = app.generate_response(message, [], "", None, None)
756
+
757
+ assert "CREATE TABLE" in sql_output(result)
758
+ assert "id INTEGER" in sql_output(result)
759
+ assert "name TEXT" in sql_output(result)
760
+ assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
761
+
762
+
763
  def test_non_template_sql_intent_without_model_returns_load_error():
764
  result = app.generate_response(
765
  "find employees named Alice", [], app.PRESETS["employees"], None, None
tests/test_chatbot_core.py CHANGED
@@ -46,6 +46,52 @@ def test_intent_create_edit_and_sql_query():
46
  assert query.intent == SQL_QUERY
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def test_zoologico_transcript_with_mocked_sql_model(monkeypatch):
50
  app._model = types.SimpleNamespace(generation_config=types.SimpleNamespace(eos_token_id=0))
51
  app._tokenizer = types.SimpleNamespace(eos_token_id=0, pad_token_id=0)
 
46
  assert query.intent == SQL_QUERY
47
 
48
 
49
+ def test_destructive_data_mutation_is_unknown_with_active_schema():
50
+ state = ConversationState(active_schema="CREATE TABLE animals (id INTEGER, name TEXT, species TEXT)")
51
+
52
+ assert classify_intent("delete all animals", state).intent == UNKNOWN
53
+ assert classify_intent("DELETE FROM animals", state).intent == UNKNOWN
54
+ assert classify_intent("UPDATE animals SET name = 'x'", state).intent == UNKNOWN
55
+ assert classify_intent("INSERT INTO animals VALUES (1)", state).intent == UNKNOWN
56
+ assert classify_intent("insert animal", state).intent == UNKNOWN
57
+ assert classify_intent("insert row into animals", state).intent == UNKNOWN
58
+ assert classify_intent("add row to animals", state).intent == UNKNOWN
59
+ assert classify_intent("add animal record", state).intent == UNKNOWN
60
+ assert classify_intent("drop animals table", state).intent == UNKNOWN
61
+ assert classify_intent("drop the animals table", state).intent == UNKNOWN
62
+ assert classify_intent("delete animals", state).intent == UNKNOWN
63
+ assert classify_intent("drop animals", state).intent == UNKNOWN
64
+
65
+ singular_state = ConversationState(active_schema="CREATE TABLE animal (id INTEGER, species TEXT)")
66
+ assert classify_intent("delete animals", singular_state).intent == UNKNOWN
67
+ assert classify_intent("drop animals", singular_state).intent == UNKNOWN
68
+
69
+
70
+ def test_data_mutation_uses_schema_from_history_when_active_schema_empty():
71
+ history = [
72
+ {
73
+ "role": "assistant",
74
+ "content": "```sql\nCREATE TABLE animals (id INTEGER, species TEXT);\n```",
75
+ }
76
+ ]
77
+
78
+ assert classify_intent("delete animals", ConversationState(), history).intent == UNKNOWN
79
+ assert classify_intent("drop animals", ConversationState(), history).intent == UNKNOWN
80
+
81
+
82
+ def test_delete_existing_column_stays_schema_edit():
83
+ state = ConversationState(active_schema="CREATE TABLE animals (id INTEGER, species TEXT)")
84
+
85
+ assert classify_intent("delete species", state).intent == EDIT_TABLE
86
+ assert classify_intent("drop coluna id", state).intent == EDIT_TABLE
87
+ assert classify_intent("add habitat", state).intent == EDIT_TABLE
88
+
89
+
90
+ def test_create_tables_named_rows_or_records_are_not_data_mutation():
91
+ assert classify_intent("create table rows with id name", ConversationState()).intent == CREATE_TABLE
92
+ assert classify_intent("create table records with id name", ConversationState()).intent == CREATE_TABLE
93
+
94
+
95
  def test_zoologico_transcript_with_mocked_sql_model(monkeypatch):
96
  app._model = types.SimpleNamespace(generation_config=types.SimpleNamespace(eos_token_id=0))
97
  app._tokenizer = types.SimpleNamespace(eos_token_id=0, pad_token_id=0)