File size: 43,778 Bytes
bc39556 ad5be9b bc39556 5b92375 bc39556 d88f966 bc39556 5b92375 bc39556 5b92375 bc39556 d88f966 bc39556 5b92375 bc39556 d88f966 bc39556 92923b1 bc39556 5b92375 bc39556 d88f966 bc39556 5b92375 bc39556 d88f966 bc39556 d88f966 bc39556 d88f966 bc39556 5b92375 bc39556 d88f966 bc39556 d88f966 bc39556 d88f966 5903774 d88f966 5b92375 d88f966 5b92375 d88f966 5b92375 d88f966 737eaac 1104ea5 737eaac bc39556 d88f966 bc39556 5b92375 bc39556 5b92375 3c4dce1 8aa688e 6fc8f36 5b92375 bc39556 737eaac 35d7c39 bc39556 d88f966 bc39556 d88f966 8aa688e 6fc8f36 737eaac 35d7c39 737eaac bc39556 d88f966 bc39556 d88f966 bc39556 5b92375 bc39556 5b92375 bc39556 92923b1 d88f966 bc39556 d88f966 bc39556 5b92375 bc39556 ad5be9b bc39556 5b92375 bc39556 5b92375 bc39556 d88f966 bc39556 ad5be9b 5b92375 bc39556 ad5be9b 5b92375 bc39556 d88f966 bc39556 d88f966 bc39556 d88f966 bc39556 d88f966 bc39556 5b92375 bc39556 5b92375 bc39556 5b92375 bc39556 d88f966 bc39556 47affa0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 | import types
import pytest
import app
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def reset_model_state():
app._model = None
app._tokenizer = None
app._current_model_id = None
def assistant_text(result):
return result[0][-1]["content"]
def sql_output(result):
return result[4]
def status_html(result):
return result[6]
@pytest.fixture(autouse=True)
def clean_model_state():
reset_model_state()
yield
reset_model_state()
# ---------------------------------------------------------------------------
# CREATE TABLE — formas verbais PT/EN
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("message", "expected"),
[
(
"crie tabela pesquisadores com id nome artigo e curriculo",
["CREATE TABLE pesquisadores", "id INTEGER", "nome TEXT", "artigo TEXT", "curriculo TEXT"],
),
(
"cria tabela animal com nome tamanho peso especie",
["CREATE TABLE animal", "nome TEXT", "tamanho TEXT", "peso NUMERIC", "especie TEXT"],
),
(
"faça tabela clientes com id nome email",
["CREATE TABLE clientes", "id INTEGER", "nome TEXT", "email TEXT"],
),
(
"create table researchers with id, name, articles and cv",
["CREATE TABLE researchers", "id INTEGER", "name TEXT", "articles TEXT", "cv TEXT"],
),
(
"crie tabela alunos com id int nome text nota numeric",
["CREATE TABLE alunos", "id INTEGER", "nome TEXT", "nota NUMERIC"],
),
(
"crie tabela pesquisadores com id nome artigo curriculo",
["CREATE TABLE pesquisadores", "id INTEGER", "nome TEXT", "artigo TEXT", "curriculo TEXT"],
),
],
)
def test_create_table_without_model(message, expected, monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(message, [], "", None, None)
for fragment in expected:
assert fragment in sql_output(result), f"missing: {fragment!r}"
assert "validator-ok" in result[5]
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
assert "without calling the model" in status_html(result)
# ---------------------------------------------------------------------------
# CREATE TABLE — active preset as schema fallback
# ---------------------------------------------------------------------------
def test_create_table_from_active_preset(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(
"gere esta tabela", [], app.PRESETS["employees"], None, None
)
assert "CREATE TABLE employees" in sql_output(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
assert "without calling the model" in status_html(result)
def test_create_table_response_updates_schema_context_display(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(
"create table animals with id name species weight",
[],
app.PRESETS["products"],
None,
None,
)
assert "CREATE TABLE animals" in result[2]
assert "Context: animals" in result[8]
assert "CREATE TABLE animals" in result[8]
# ---------------------------------------------------------------------------
# EDIT — add column variants and patterns
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("message", "expected_col"),
[
("adicione cpf", "cpf TEXT"),
("add email", "email TEXT"),
("inclua telefone", "telefone TEXT"),
("acrescente campo bonus numeric", "bonus NUMERIC"),
("adicione: matricula", "matricula TEXT"),
],
)
def test_add_column_variants(message, expected_col, monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela funcionarios com id nome salario", [], "", None, None
)
result = app.generate_response(message, base[0], "", None, None)
assert expected_col in sql_output(result)
assert "CREATE TABLE funcionarios" in sql_output(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
assert "without calling the model" in status_html(result)
# ---------------------------------------------------------------------------
# EDIT — remove column
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("message", "removed_col"),
[
("remova salario", "salario"),
("remove nome", "nome"),
("delete salario", "salario"),
("drop coluna id", "id"),
],
)
def test_remove_column_variants(message, removed_col, monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela funcionarios com id nome salario", [], "", None, None
)
result = app.generate_response(message, base[0], "", None, None)
assert "CREATE TABLE funcionarios" in sql_output(result)
assert removed_col not in sql_output(result)
assert "validator-ok" in result[5]
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
# ---------------------------------------------------------------------------
# EDIT — "altere" and "mude" (regression fix for is_table_edit_intent)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"edit_message",
[
"altere para ter também email",
"mude adicionando telefone",
],
)
def test_edit_intent_recognizes_pt_conjugations(edit_message, monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela x com id nome", [], "", None, None
)
result = app.generate_response(edit_message, base[0], "", None, None)
assert "CREATE TABLE x" in sql_output(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
assert "without calling the model" in status_html(result)
# ---------------------------------------------------------------------------
# EDIT — multiple add/remove actions in the same prompt
# ---------------------------------------------------------------------------
def test_add_multiple_columns(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela pesquisadores com id nome artigo e curriculo", [], "", None, None
)
result = app.generate_response("add email and phone", base[0], "", None, None)
assert "email TEXT" in sql_output(result)
assert "phone TEXT" in sql_output(result)
def test_remove_column(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela pesquisadores com id nome artigo e curriculo", [], "", None, None
)
added = app.generate_response("adicione cpf", base[0], "", None, None)
result = app.generate_response("remover curriculo", added[0], "", None, None)
assert "curriculo TEXT" not in sql_output(result)
assert "cpf TEXT" in sql_output(result)
assert "id INTEGER" in sql_output(result)
assert "validator-ok" in result[5]
# ---------------------------------------------------------------------------
# EDIT — history with different content shapes
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"history",
[
[{"role": "assistant", "content": "```sql\nCREATE TABLE pesquisadores (\n id INTEGER,\n nome TEXT\n);\n```"}],
[{"role": "assistant", "content": [{"text": "```sql\nCREATE TABLE pesquisadores (\n id INTEGER,\n nome TEXT\n);\n```"}]}],
[{"role": "assistant", "content": "CREATE TABLE pesquisadores (\n id INTEGER,\n nome TEXT\n);"}],
],
)
def test_edit_from_history_content_shapes(history, monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(
"edita ela para ter um novo elemento: cpf", history, "", None, None
)
assert "CREATE TABLE pesquisadores" in sql_output(result)
assert "cpf TEXT" in sql_output(result)
assert "id INTEGER" in sql_output(result)
assert "validator-ok" in result[5]
# ---------------------------------------------------------------------------
# EDIT — with active_schema and empty history
# ---------------------------------------------------------------------------
def test_edit_from_active_schema_no_history(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(
"adicione bonus", [], app.PRESETS["employees"], None, None
)
assert "CREATE TABLE employees" in sql_output(result)
assert "bonus" in sql_output(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
assert "without calling the model" in status_html(result)
# ---------------------------------------------------------------------------
# EDIT — last_create_table_from_history retorna o mais recente
# ---------------------------------------------------------------------------
def test_last_create_table_returns_most_recent():
history = [
{"role": "assistant", "content": "```sql\nCREATE TABLE old (x TEXT);\n```"},
{"role": "user", "content": "adicione id"},
{"role": "assistant", "content": "```sql\nCREATE TABLE new (id INTEGER);\n```"},
]
result = app.last_create_table_from_history(history)
assert "CREATE TABLE new" in result
assert "CREATE TABLE old" not in result
# ---------------------------------------------------------------------------
# FULL multi-turn flow: create → add → add → remove → SQL intent
# ---------------------------------------------------------------------------
def test_full_schema_build_flow(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
r1 = app.generate_response(
"crie tabela produtos com id nome preco", [], "", None, None
)
assert "CREATE TABLE produtos" in sql_output(r1)
assert "preco NUMERIC" in sql_output(r1)
r2 = app.generate_response("adicione categoria e estoque", r1[0], "", None, None)
assert "categoria TEXT" in sql_output(r2)
assert "estoque INTEGER" in sql_output(r2)
assert "id INTEGER" in sql_output(r2)
r3 = app.generate_response("remova preco", r2[0], "", None, None)
assert "preco" not in sql_output(r3)
assert "categoria TEXT" in sql_output(r3)
r4 = app.generate_response("qual o produto mais caro?", r3[0], sql_output(r3), None, None)
assert "Load a model" in status_html(r4)
# ---------------------------------------------------------------------------
# SQL intent routing
# ---------------------------------------------------------------------------
def test_sql_prompt_uses_schema_template():
prompt = app.build_generation_prompt(
app.PRESETS["employees"],
"What is the average salary per department?",
)
assert "CREATE TABLE employees" in prompt
assert "<|user|>" in prompt
assert "<|assistant|>" in prompt
def test_sql_prompt_fallback_schema_when_empty():
prompt = app.build_generation_prompt("", "select all rows")
assert "CREATE TABLE unknown (id INTEGER)" in prompt
def test_sql_prompt_normalizes_pt_br_query_to_english():
prompt = app.build_generation_prompt(app.PRESETS["products"], "qual o produto mais caro?")
assert "Question: what is the most expensive product?" in prompt
assert "qual o produto mais caro" not in prompt
def test_example_prompts_translate_pt_br_phrase():
html = app.render_example_prompts()
assert "what is the most expensive product?" in html
assert "qual o produto mais caro" not in html
def test_model_metadata_uses_compact_source_contract_grid():
html = app.model_metadata()
assert html.count('class="stat-card"') == 4
assert "source-row" not in html
def test_sql_prompt_normalizes_grouped_average_to_english():
prompt = app.build_generation_prompt(
app.PRESETS["employees"],
"qual a media de salario por departamento",
)
assert "Question: what is the average salary by department?" in prompt
assert "media de salario" not in prompt
def test_deterministic_sql_query_handles_most_expensive_product():
sql = app.sql_core.deterministic_sql_query("qual o produto mais caro?", app.PRESETS["products"])
assert sql == "SELECT * FROM products ORDER BY price DESC LIMIT 1;"
assert "validator-ok" in app.validate_sql(sql)
def test_deterministic_sql_query_handles_pt_br_grouped_average():
sql = app.sql_core.deterministic_sql_query(
"qual a media de salario por departamento",
app.PRESETS["employees"],
)
assert sql == "SELECT department, AVG(salary) AS average_salary FROM employees GROUP BY department;"
assert "validator-ok" in app.validate_sql(sql)
def test_deterministic_sql_query_handles_pt_br_comparison():
sql = app.sql_core.deterministic_sql_query(
"mostre os alunos com nota maior que 8",
app.PRESETS["students"],
)
assert sql == "SELECT * FROM students WHERE grade > 8;"
assert "validator-ok" in app.validate_sql(sql)
def test_deterministic_sql_query_handles_custom_weight_ranking():
schema = "CREATE TABLE animal (nome TEXT, nome_cientifico TEXT, peso NUMERIC, especie TEXT)"
sql = app.sql_core.deterministic_sql_query("qual o maior peso?", schema)
assert sql == "SELECT * FROM animal ORDER BY peso DESC LIMIT 1;"
assert "validator-ok" in app.validate_sql(sql)
def test_custom_animal_weight_flow_without_model(monkeypatch):
monkeypatch.setattr(app, "_generate_model_text", lambda *a, **k: pytest.fail("model should not run"))
created = app.generate_response(
"crie tabela animal com nome, nome_cientifico, peso e especie",
[],
"",
None,
None,
)
result = app.generate_response("qual o maior peso?", created[0], created[2], None, created[7])
assert "CREATE TABLE animal" in sql_output(created)
assert sql_output(result) == "SELECT * FROM animal ORDER BY peso DESC LIMIT 1;"
assert "validator-ok" in result[5]
assert app.SOURCE_DETERMINISTIC_SQL_TEMPLATE in status_html(result)
assert "deterministic SQL template" in status_html(result)
def test_pt_br_template_query_does_not_require_model(monkeypatch):
monkeypatch.setattr(app, "_generate_model_text", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(
"qual o produto mais caro?",
[],
app.PRESETS["products"],
None,
None,
)
assert sql_output(result) == "SELECT * FROM products ORDER BY price DESC LIMIT 1;"
assert app.SOURCE_DETERMINISTIC_SQL_TEMPLATE in status_html(result)
assert "deterministic SQL template" in status_html(result)
def test_sql_prompt_translates_column_aliases_to_english():
prompt = app.build_generation_prompt(
app.PRESETS["students"],
"mostre os alunos com nota maior que 8",
)
assert "Question: show the students with grade greater than 8?" in prompt
assert "nota maior que" not in prompt
def test_pt_br_sql_query_reaches_model_with_english_prompt(monkeypatch):
app._model = types.SimpleNamespace(generation_config=types.SimpleNamespace(eos_token_id=0))
app._tokenizer = types.SimpleNamespace(eos_token_id=0, pad_token_id=0)
app._current_model_id = app.FINE_TUNED_MODEL_ID
seen = {}
def fake_generate(prompt, generation_kind):
seen["prompt"] = prompt
assert generation_kind == app.model_core.SQL_GENERATION
return "SELECT name FROM products ORDER BY price DESC LIMIT 1;", 1
monkeypatch.setattr(app, "_generate_model_text", fake_generate)
result = app.generate_response(
"liste produtos por categoria",
[],
app.PRESETS["products"],
app.FINE_TUNED_MODEL_KEY,
None,
)
assert "SELECT name FROM products" in sql_output(result)
assert "Question: list products by category?" in seen["prompt"]
assert "liste produtos" not in seen["prompt"]
assert app.SOURCE_FINE_TUNED_MODEL in status_html(result)
def test_sql_model_rejects_destructive_output(monkeypatch):
app._model = types.SimpleNamespace(generation_config=types.SimpleNamespace(eos_token_id=0))
app._tokenizer = types.SimpleNamespace(eos_token_id=0, pad_token_id=0)
app._current_model_id = app.FINE_TUNED_MODEL_ID
monkeypatch.setattr(
app,
"_generate_model_text",
lambda *a, **k: ("DROP TABLE employees;", 1),
)
result = app.generate_response(
"find employees named Alice",
[],
app.PRESETS["employees"],
app.FINE_TUNED_MODEL_KEY,
None,
)
assert sql_output(result) == ""
assert "DROP TABLE" not in assistant_text(result)
assert "rejected" in assistant_text(result).lower()
assert app.SOURCE_FINE_TUNED_MODEL in status_html(result)
assert "Rejected non-SELECT/WITH model output" in status_html(result)
def test_sql_model_rejects_non_sql_output_as_chat_capability(monkeypatch):
app._model = types.SimpleNamespace(generation_config=types.SimpleNamespace(eos_token_id=0))
app._tokenizer = types.SimpleNamespace(eos_token_id=0, pad_token_id=0)
app._current_model_id = app.FINE_TUNED_MODEL_ID
monkeypatch.setattr(
app,
"_generate_model_text",
lambda *a, **k: ("I can help with that, but I need more details.", 1),
)
result = app.generate_response(
"find employees named Alice",
[],
app.PRESETS["employees"],
app.FINE_TUNED_MODEL_KEY,
None,
)
assert sql_output(result) == ""
assert "I can help" not in assistant_text(result)
assert "chat response" not in status_html(result).lower()
assert app.SOURCE_FINE_TUNED_MODEL in status_html(result)
assert "Rejected non-SELECT/WITH model output" in status_html(result)
def test_sql_model_rejects_hallucinated_schema_column(monkeypatch):
app._model = types.SimpleNamespace(generation_config=types.SimpleNamespace(eos_token_id=0))
app._tokenizer = types.SimpleNamespace(eos_token_id=0, pad_token_id=0)
app._current_model_id = app.FINE_TUNED_MODEL_ID
monkeypatch.setattr(
app,
"_generate_model_text",
lambda *a, **k: ("SELECT email FROM employees WHERE name = 'Alice';", 1),
)
result = app.generate_response(
"find employees named Alice",
[],
app.PRESETS["employees"],
app.FINE_TUNED_MODEL_KEY,
None,
)
assert sql_output(result) == ""
assert "model output failed SQL/schema validation" in assistant_text(result)
assert "email" not in assistant_text(result)
assert "schema-invalid model output" in status_html(result)
assert app.SOURCE_FINE_TUNED_MODEL in status_html(result)
def test_sql_model_accepts_valid_schema_column_output(monkeypatch):
app._model = types.SimpleNamespace(generation_config=types.SimpleNamespace(eos_token_id=0))
app._tokenizer = types.SimpleNamespace(eos_token_id=0, pad_token_id=0)
app._current_model_id = app.FINE_TUNED_MODEL_ID
monkeypatch.setattr(
app,
"_generate_model_text",
lambda *a, **k: ("SELECT * FROM employees WHERE name = 'Alice';", 1),
)
result = app.generate_response(
"find employees named Alice",
[],
app.PRESETS["employees"],
app.FINE_TUNED_MODEL_KEY,
None,
)
assert "SELECT * FROM employees WHERE name = 'Alice';" in sql_output(result)
assert "validator-ok" in result[5]
assert app.SOURCE_FINE_TUNED_MODEL in status_html(result)
def test_sql_intent_detected():
assert app.is_sql_intent("What is the average salary per department?", app.PRESETS["employees"])
assert app.is_sql_intent("what is the most expensive product?", app.PRESETS["products"])
assert app.is_sql_intent("liste todos os funcionários", app.PRESETS["employees"])
assert app.is_sql_intent("mostre os alunos com nota maior que 8", app.PRESETS["students"])
def test_greeting_not_sql_intent():
assert not app.is_sql_intent("oi", app.PRESETS["employees"])
assert not app.is_sql_intent("hello", "")
# ---------------------------------------------------------------------------
# Output parsing — clean_generation e format_generation_result
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "expected"), [
("```sql\nSELECT * FROM x\n```", "SELECT * FROM x"),
("SELECT id FROM t<|end|>", "SELECT id FROM t"),
("SQL: SELECT name FROM t", "SELECT name FROM t"),
("```\nSELECT 1\n```", "SELECT 1"),
])
def test_clean_generation_strips_artifacts(raw, expected):
assert app.clean_generation(raw) == expected
def test_format_generation_result_sql_path():
sql, chat, validator = app.format_generation_result("SELECT * FROM employees")
assert sql == "SELECT * FROM employees"
assert chat == ""
assert "validator-ok" in validator
def test_format_generation_result_rejects_non_sql_output():
sql, chat, validator = app.format_generation_result("I don't know, try again.")
assert sql == ""
assert chat == ""
assert validator == app.EMPTY_VALIDATOR
@pytest.mark.parametrize("raw", [
"DROP TABLE employees;",
"ALTER TABLE employees ADD COLUMN email TEXT;",
"UPDATE employees SET salary = 0;",
"DELETE FROM employees;",
"INSERT INTO employees VALUES (1);",
"CREATE TABLE employees_copy (id INTEGER);",
])
def test_format_generation_result_rejects_non_select_model_sql(raw):
sql, chat, validator = app.format_generation_result(raw)
assert sql == ""
assert chat == ""
assert validator == app.EMPTY_VALIDATOR
@pytest.mark.parametrize("raw", [
"SELECT * FROM employees; DROP TABLE employees;",
"SELECT 1; DELETE FROM employees;",
"WITH high_salary AS (SELECT * FROM employees) SELECT * FROM high_salary; UPDATE employees SET salary = 0;",
"SELECT * FROM employees DROP TABLE employees",
])
def test_format_generation_result_rejects_stacked_model_sql(raw):
sql, chat, validator = app.format_generation_result(raw)
assert sql == ""
assert chat == ""
assert validator == app.EMPTY_VALIDATOR
def test_format_generation_result_rejects_incomplete_not_clause():
sql, chat, validator = app.format_generation_result(
'SELECT COUNT(*) FROM employees WHERE name = "Alice" AND NOT'
)
assert sql == ""
assert chat == ""
assert validator == app.EMPTY_VALIDATOR
def test_format_generation_result_rejects_bare_negated_predicate():
sql, chat, validator = app.format_generation_result(
'SELECT COUNT(*) FROM employees WHERE name = "Alice" AND NOT department'
)
assert sql == ""
assert chat == ""
assert validator == app.EMPTY_VALIDATOR
def test_format_generation_result_rejects_trailing_comparison_operator():
sql, chat, validator = app.format_generation_result(
"SELECT id AS email FROM employees WHERE 1 =",
app.PRESETS["employees"],
)
assert sql == ""
assert chat == ""
assert validator == app.EMPTY_VALIDATOR
def test_format_generation_result_accepts_with_query():
sql, chat, validator = app.format_generation_result(
"WITH high_salary AS (SELECT * FROM employees) SELECT * FROM high_salary;"
)
assert sql.startswith("WITH high_salary")
assert chat == ""
assert "validator-ok" in validator
def test_format_generation_result_rejects_unknown_column_against_schema():
sql, chat, validator = app.format_generation_result(
"SELECT email FROM employees;",
app.PRESETS["employees"],
)
assert sql == ""
assert chat == ""
assert validator == app.EMPTY_VALIDATOR
def test_format_generation_result_accepts_double_quoted_comparison_literal():
sql, chat, validator = app.format_generation_result(
'SELECT COUNT(*) FROM employees WHERE name = "Alice";',
app.PRESETS["employees"],
)
assert sql == 'SELECT COUNT(*) FROM employees WHERE name = "Alice";'
assert chat == ""
assert "validator-ok" in validator
# ---------------------------------------------------------------------------
# validate_sql — starters beyond SELECT
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("stmt", [
"SELECT * FROM employees",
"CREATE TABLE t (id INTEGER)",
"INSERT INTO t VALUES (1)",
"WITH cte AS (SELECT 1) SELECT * FROM cte",
"DROP TABLE t",
"UPDATE t SET x = 1 WHERE id = 1",
])
def test_validate_sql_valid_starters(stmt):
assert "validator-ok" in app.validate_sql(stmt)
def test_validate_sql_garbage_returns_warn():
assert "validator-warn" in app.validate_sql("isto nao e sql %$#")
def test_validate_sql_incomplete_trailing_clause_returns_warn():
assert "validator-warn" in app.validate_sql("SELECT MAX(price), id FROM products GROUP BY 1 ORDER")
def test_validate_sql_bare_negated_predicate_returns_warn():
assert "validator-warn" in app.validate_sql(
'SELECT COUNT(*) FROM employees WHERE name = "Alice" AND NOT department'
)
def test_validate_sql_trailing_comparison_operator_returns_warn():
assert "validator-warn" in app.validate_sql(
"SELECT id AS email FROM employees WHERE 1 =",
app.PRESETS["employees"],
)
def test_validate_sql_rejects_unknown_schema_column():
assert "validator-warn" in app.validate_sql(
"SELECT email FROM employees;",
app.PRESETS["employees"],
)
def test_validate_sql_rejects_double_quoted_unknown_schema_column():
assert "validator-warn" in app.validate_sql(
'SELECT "email" FROM employees;',
app.PRESETS["employees"],
)
def test_validate_sql_accepts_double_quoted_comparison_literal():
assert "validator-ok" in app.validate_sql(
'SELECT COUNT(*) FROM employees WHERE name = "Alice";',
app.PRESETS["employees"],
)
def test_validate_sql_rejects_unknown_schema_table():
assert "validator-warn" in app.validate_sql(
"SELECT name FROM departments;",
app.PRESETS["employees"],
)
def test_validate_sql_rejects_date_when_not_in_schema():
assert "validator-warn" in app.validate_sql(
"SELECT date FROM employees;",
app.PRESETS["employees"],
)
def test_validate_sql_accepts_schema_alias_and_output_alias():
assert "validator-ok" in app.validate_sql(
"SELECT e.name, COUNT(*) AS total FROM employees e GROUP BY e.name ORDER BY total DESC;",
app.PRESETS["employees"],
)
def test_validate_sql_empty_returns_empty_badge():
assert app.validate_sql("") == app.EMPTY_VALIDATOR
# ---------------------------------------------------------------------------
# Explicit type normalization in the column parser
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "expected_type"), [
("price DECIMAL", "NUMERIC"),
("active BOOL", "BOOLEAN"),
("qty INT", "INTEGER"),
("score REAL", "REAL"),
# P2 fix: column name matches SQL type keyword (date DATE, int INTEGER)
# Parser now picks the last match as the type, not the first
("date DATE", "DATE"),
("int INTEGER", "INTEGER"),
("name TEXT", "TEXT"),
])
def test_parse_column_explicit_type_normalization(raw, expected_type):
parsed = app.parse_column_definition(raw)
assert parsed is not None
assert parsed[1] == expected_type
_, col_type = parsed
assert col_type == expected_type
# ---------------------------------------------------------------------------
# trim_chat_history
# ---------------------------------------------------------------------------
def test_trim_chat_history_caps_at_max_exchanges():
history = [
{"role": "user" if i % 2 == 0 else "assistant", "content": str(i)}
for i in range(30)
]
trimmed = app.trim_chat_history(history)
assert len(trimmed) == 20
# ---------------------------------------------------------------------------
# Errors e estado do modelo
# ---------------------------------------------------------------------------
def test_empty_input_returns_error():
result = app.generate_response("", [], "", None, None)
assert result[0] == []
assert "Type a message" in status_html(result)
def test_malformed_create_table_returns_error():
result = app.generate_response("crie tabela", [], "", None, None)
assert sql_output(result) == ""
assert "CREATE TABLE needs" in status_html(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
def test_edit_without_existing_table_returns_error():
result = app.generate_response("adicione cpf", [], "", None, None)
assert sql_output(result) == ""
assert "existing CREATE TABLE" in status_html(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
@pytest.mark.parametrize(
"message",
[
"delete all animals",
"delete animals",
"DELETE FROM animals",
"drop table animals",
"drop animals table",
"drop the animals table",
"drop animals",
"update animals set weight = 0",
"insert into animals values (1)",
"insert animal",
"insert row into animals",
"add row to animals",
"add animal record",
],
)
def test_unsupported_data_mutation_returns_static_fallback(message, monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(
message,
[],
"CREATE TABLE animals (id INTEGER, name TEXT, weight NUMERIC)",
None,
None,
)
assert sql_output(result) == ""
assert result[5] == app.EMPTY_VALIDATOR
assert app.UNSUPPORTED_MUTATION_RESPONSE in assistant_text(result)
assert app.SOURCE_STATIC_FALLBACK in status_html(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER not in status_html(result)
def test_remove_nonexistent_columns_does_not_return_unchanged_schema():
schema = "CREATE TABLE animals (id INTEGER, name TEXT, species TEXT);"
assert app.sql_core.edit_create_table_from_message("delete all animals", [], schema) == ""
def test_noop_schema_edit_reports_no_matching_column(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(
"delete salary",
[],
"CREATE TABLE animals (id INTEGER, name TEXT, species TEXT)",
None,
None,
)
assert sql_output(result) == ""
assert "No matching schema column was changed." in status_html(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
def test_data_mutation_uses_schema_from_history_when_active_schema_empty(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
history = [
{
"role": "assistant",
"content": "```sql\nCREATE TABLE animals (id INTEGER, species TEXT);\n```",
}
]
result = app.generate_response("delete animals", history, "", None, None)
assert sql_output(result) == ""
assert app.UNSUPPORTED_MUTATION_RESPONSE in assistant_text(result)
assert app.SOURCE_STATIC_FALLBACK in status_html(result)
@pytest.mark.parametrize("message", ["create table rows with id name", "create table records with id name"])
def test_create_table_names_that_overlap_row_terms_still_create_schema(message, monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response(message, [], "", None, None)
assert "CREATE TABLE" in sql_output(result)
assert "id INTEGER" in sql_output(result)
assert "name TEXT" in sql_output(result)
assert app.SOURCE_DETERMINISTIC_SCHEMA_PARSER in status_html(result)
def test_non_template_sql_intent_without_model_returns_load_error():
result = app.generate_response(
"find employees named Alice", [], app.PRESETS["employees"], None, None
)
assert "Load a model" in status_html(result)
assert app.SOURCE_FINE_TUNED_MODEL in status_html(result)
def test_model_id_mismatch_returns_inconsistency_error():
app._model = types.SimpleNamespace(
generation_config=types.SimpleNamespace(eos_token_id=0)
)
app._tokenizer = object()
app._current_model_id = "microsoft/Phi-3-mini-4k-instruct"
try:
result = app.generate_response(
"select all", [], app.PRESETS["employees"], app.FINE_TUNED_MODEL_KEY, None
)
assert "inconsistent" in status_html(result)
assert app.SOURCE_FINE_TUNED_MODEL in status_html(result)
finally:
reset_model_state()
def test_busy_generation_lock_raises():
assert app._model_activity_lock.acquire(blocking=False)
try:
with pytest.raises(RuntimeError, match="Another model operation"):
app._run_generation(object(), {}, {})
finally:
app._model_activity_lock.release()
def test_generation_exception_is_rendered_not_raised(monkeypatch):
class DummyTokenizer:
eos_token_id = 0
pad_token_id = 0
def __call__(self, prompt, return_tensors):
return {"input_ids": types.SimpleNamespace(shape=(1, 1))}
monkeypatch.setattr(app, "import_model_runtime", lambda: (object(), None, None, None))
monkeypatch.setattr(
app, "_run_generation",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("timeout"))
)
app._model = types.SimpleNamespace(
generation_config=types.SimpleNamespace(eos_token_id=0)
)
app._tokenizer = DummyTokenizer()
app._current_model_id = app.FINE_TUNED_MODEL_ID
result = app.generate_response(
"select all rows", [], "", app.FINE_TUNED_MODEL_KEY, None
)
assert sql_output(result) == ""
assert "Generation failed: RuntimeError: timeout" in status_html(result)
assert app.SOURCE_FINE_TUNED_MODEL in status_html(result)
# ---------------------------------------------------------------------------
# Fallback for messages outside SQL context
# ---------------------------------------------------------------------------
def test_off_topic_message_returns_fallback(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response("me conte uma piada", [], "", None, None)
assert sql_output(result) == ""
assert app.FALLBACK_RESPONSE in assistant_text(result)
assert app.SOURCE_STATIC_FALLBACK in status_html(result)
def test_greeting_returns_fallback(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
result = app.generate_response("oi", [], "", None, None)
assert sql_output(result) == ""
assert app.FALLBACK_RESPONSE in assistant_text(result)
assert app.SOURCE_STATIC_FALLBACK in status_html(result)
# ---------------------------------------------------------------------------
# Stopwords do not become columns
# ---------------------------------------------------------------------------
def test_stopwords_not_treated_as_columns(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela animal com nome especie", [], "", None, None
)
result = app.generate_response("add peso", base[0], "", None, None)
schema = sql_output(result)
assert "peso NUMERIC" in schema
assert " to TEXT" not in schema
assert " as TEXT" not in schema
assert " from TEXT" not in schema
# ---------------------------------------------------------------------------
# Column rename
# ---------------------------------------------------------------------------
def test_rename_column_basic(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela animal com nome cientifico especie", [], "", None, None
)
result = app.generate_response(
"rename cientifico to nome_cientifico", base[0], "", None, None
)
schema = sql_output(result)
assert "nome_cientifico TEXT" in schema
assert "\n cientifico TEXT" not in schema
assert "nome TEXT" in schema
assert "especie TEXT" in schema
assert "validator-ok" in result[5]
def test_rename_column_pt(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela produto com id nome preco", [], "", None, None
)
result = app.generate_response(
"renomeie preco para valor", base[0], "", None, None
)
schema = sql_output(result)
assert "valor NUMERIC" in schema
assert "preco" not in schema
# ---------------------------------------------------------------------------
# Compound operation: add + rename in the same prompt
# ---------------------------------------------------------------------------
def test_compound_add_and_rename(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela animal com nome cientifico especie", [], "", None, None
)
result = app.generate_response(
"add peso and rename cientifico to nome_cientifico", base[0], "", None, None
)
schema = sql_output(result)
assert "peso" in schema
assert "nome_cientifico TEXT" in schema
assert "\n cientifico TEXT" not in schema
assert "edit TEXT" not in schema
assert " to TEXT" not in schema
assert "validator-ok" in result[5]
def test_compound_add_and_remove(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela funcionarios com id nome salario departamento", [], "", None, None
)
result = app.generate_response(
"add email and remove salario", base[0], "", None, None
)
schema = sql_output(result)
assert "email TEXT" in schema
assert "salario" not in schema
assert "id INTEGER" in schema
assert "nome TEXT" in schema
# ---------------------------------------------------------------------------
# Rename preserves the original column type
# ---------------------------------------------------------------------------
def test_rename_preserves_column_type(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela vendas com id total date", [], "", None, None
)
result = app.generate_response(
"rename total to valor_total", base[0], "", None, None
)
schema = sql_output(result)
assert "valor_total NUMERIC" in schema
assert "\n total NUMERIC" not in schema
# ---------------------------------------------------------------------------
# Edit terms → off-topic, not SQL intent (Fix 1: off_topic_patterns blocklist)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("message", "schema"),
[
("troca tipo por medida", "CREATE TABLE comida (id INTEGER)"),
("renomeia nome para titulo", "CREATE TABLE livro (id INTEGER, nome TEXT)"),
("muda preco para numeric", "CREATE TABLE produto (id INTEGER, preco TEXT)"),
("altera coluna idade para integer", "CREATE TABLE pessoa (id INTEGER, idade TEXT)"),
],
)
def test_edit_terms_routed_to_off_topic(message, schema, monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
# Result must NOT ask to load model — edit terms are off-topic, not SQL intent
result = app.generate_response(message, [], schema, None, None)
status = status_html(result)
assert "Load a model" not in status
# Should be either edit-without-table error or safe fallback — not model path
# ---------------------------------------------------------------------------
# build_generation_prompt uses schema plus normalized question only
# ---------------------------------------------------------------------------
def test_build_generation_prompt_excludes_raw_chat_history():
schema = "CREATE TABLE comida (id INTEGER, nome TEXT, sabor TEXT)"
message = "liste tudo ordenado por nome"
chat_history = [
{"role": "user", "content": "crie tabela comida com nome sabor"},
{"role": "assistant", "content": "```sql\nCREATE TABLE comida (id INTEGER, nome TEXT, sabor TEXT)\n```"},
{"role": "user", "content": "adiciona coluna peso"},
{"role": "assistant", "content": "```sql\nALTER TABLE comida ADD COLUMN peso NUMERIC\n```"},
]
prompt = app.build_generation_prompt(schema, message, chat_history)
assert "Previous conversation:" not in prompt
assert "crie tabela comida" not in prompt
assert "adiciona coluna peso" not in prompt
assert "CREATE TABLE comida" in prompt
assert "Question: list tudo ordered by name?" in prompt
def test_build_generation_prompt_no_history_no_context():
schema = "CREATE TABLE comida (id INTEGER)"
message = "liste todos"
prompt = app.build_generation_prompt(schema, message, None)
# Should not include conversation context header
assert "Previous conversation:" not in prompt
# But should still include schema and question
assert "comida" in prompt
assert "Question: list all?" in prompt
# ---------------------------------------------------------------------------
# Regression: ISSUE-001 — "troca X por Y" rename pattern
# ---------------------------------------------------------------------------
def test_troca_x_por_y_rename(monkeypatch):
monkeypatch.setattr(app, "_run_generation", lambda *a, **k: pytest.fail("model should not run"))
base = app.generate_response(
"crie tabela comida com id nome sabor peso tipo", [], "", None, None
)
result = app.generate_response(
"troca tipo por medida", base[0], base[2], None, None
)
schema = sql_output(result)
assert "medida TEXT" in schema
assert "tipo TEXT" not in schema
assert "id INTEGER" in schema
assert "nome TEXT" in schema
assert "sabor TEXT" in schema
assert "peso NUMERIC" in schema
|