Spaces:
Running on Zero
Running on Zero
File size: 359,471 Bytes
19dfd32 86b079e 19dfd32 86b079e 19dfd32 86b079e 19dfd32 86b079e 19dfd32 86b079e 19dfd32 86b079e 19dfd32 86b079e 19dfd32 | 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 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 3067 3068 3069 3070 3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 3138 3139 3140 3141 3142 3143 3144 3145 3146 3147 3148 3149 3150 3151 3152 3153 3154 3155 3156 3157 3158 3159 3160 3161 3162 3163 3164 3165 3166 3167 3168 3169 3170 3171 3172 3173 3174 3175 3176 3177 3178 3179 3180 3181 3182 3183 3184 3185 3186 3187 3188 3189 3190 3191 3192 3193 3194 3195 3196 3197 3198 3199 3200 3201 3202 3203 3204 3205 3206 3207 3208 3209 3210 3211 3212 3213 3214 3215 3216 3217 3218 3219 3220 3221 3222 3223 3224 3225 3226 3227 3228 3229 3230 3231 3232 3233 3234 3235 3236 3237 3238 3239 3240 3241 3242 3243 3244 3245 3246 3247 3248 3249 3250 3251 3252 3253 3254 3255 3256 3257 3258 3259 3260 3261 3262 3263 3264 3265 3266 3267 3268 3269 3270 3271 3272 3273 3274 3275 3276 3277 3278 3279 3280 3281 3282 3283 3284 3285 3286 3287 3288 3289 3290 3291 3292 3293 3294 3295 3296 3297 3298 3299 3300 3301 3302 3303 3304 3305 3306 3307 3308 3309 3310 3311 3312 3313 3314 3315 3316 3317 3318 3319 3320 3321 3322 3323 3324 3325 3326 3327 3328 3329 3330 3331 3332 3333 3334 3335 3336 3337 3338 3339 3340 3341 3342 3343 3344 3345 3346 3347 3348 3349 3350 3351 3352 3353 3354 3355 3356 3357 3358 3359 3360 3361 3362 3363 3364 3365 3366 3367 3368 3369 3370 3371 3372 3373 3374 3375 3376 3377 3378 3379 3380 3381 3382 3383 3384 3385 3386 3387 3388 3389 3390 3391 3392 3393 3394 3395 3396 3397 3398 3399 3400 3401 3402 3403 3404 3405 3406 3407 3408 3409 3410 3411 3412 3413 3414 3415 3416 3417 3418 3419 3420 3421 3422 3423 3424 3425 3426 3427 3428 3429 3430 3431 3432 3433 3434 3435 3436 3437 3438 3439 3440 3441 3442 3443 3444 3445 3446 3447 3448 3449 3450 3451 3452 3453 3454 3455 3456 3457 3458 3459 3460 3461 3462 3463 3464 3465 3466 3467 3468 3469 3470 3471 3472 3473 3474 3475 3476 3477 3478 3479 3480 3481 3482 3483 3484 3485 3486 3487 3488 3489 3490 3491 3492 3493 3494 3495 3496 3497 3498 3499 3500 3501 3502 3503 3504 3505 3506 3507 3508 3509 3510 3511 3512 3513 3514 3515 3516 3517 3518 3519 3520 3521 3522 3523 3524 3525 3526 3527 3528 3529 3530 3531 3532 3533 3534 3535 3536 3537 3538 3539 3540 3541 3542 3543 3544 3545 3546 3547 3548 3549 3550 3551 3552 3553 3554 3555 3556 3557 3558 3559 3560 3561 3562 3563 3564 3565 3566 3567 3568 3569 3570 3571 3572 3573 3574 3575 3576 3577 3578 3579 3580 3581 3582 3583 3584 3585 3586 3587 3588 3589 3590 3591 3592 3593 3594 3595 3596 3597 3598 3599 3600 3601 3602 3603 3604 3605 3606 3607 3608 3609 3610 3611 3612 3613 3614 3615 3616 3617 3618 3619 3620 3621 3622 3623 3624 3625 3626 3627 3628 3629 3630 3631 3632 3633 3634 3635 3636 3637 3638 3639 3640 3641 3642 3643 3644 3645 3646 3647 3648 3649 3650 3651 3652 3653 3654 3655 3656 3657 3658 3659 3660 3661 3662 3663 3664 3665 3666 3667 3668 3669 3670 3671 3672 3673 3674 3675 3676 3677 3678 3679 3680 3681 3682 3683 3684 3685 3686 3687 3688 3689 3690 3691 3692 3693 3694 3695 3696 3697 3698 3699 3700 3701 3702 3703 3704 3705 3706 3707 3708 3709 3710 3711 3712 3713 3714 3715 3716 3717 3718 3719 3720 3721 3722 3723 3724 3725 3726 3727 3728 3729 3730 3731 3732 3733 3734 3735 3736 3737 3738 3739 3740 3741 3742 3743 3744 3745 3746 3747 3748 3749 3750 3751 3752 3753 3754 3755 3756 3757 3758 3759 3760 3761 3762 3763 3764 3765 3766 3767 3768 3769 3770 3771 3772 3773 3774 3775 3776 3777 3778 3779 3780 3781 3782 3783 3784 3785 3786 3787 3788 3789 3790 3791 3792 3793 3794 3795 3796 3797 3798 3799 3800 3801 3802 3803 3804 3805 3806 3807 3808 3809 3810 3811 3812 3813 3814 3815 3816 3817 3818 3819 3820 3821 3822 3823 3824 3825 3826 3827 3828 3829 3830 3831 3832 3833 3834 3835 3836 3837 3838 3839 3840 3841 3842 3843 3844 3845 3846 3847 3848 3849 3850 3851 3852 3853 3854 3855 3856 3857 3858 3859 3860 3861 3862 3863 3864 3865 3866 3867 3868 3869 3870 3871 3872 3873 3874 3875 3876 3877 3878 3879 3880 3881 3882 3883 3884 3885 3886 3887 3888 3889 3890 3891 3892 3893 3894 3895 3896 3897 3898 3899 3900 3901 3902 3903 3904 3905 3906 3907 3908 3909 3910 3911 3912 3913 3914 3915 3916 3917 3918 3919 3920 3921 3922 3923 3924 3925 3926 3927 3928 3929 3930 3931 3932 3933 3934 3935 3936 3937 3938 3939 3940 3941 3942 3943 3944 3945 3946 3947 3948 3949 3950 3951 3952 3953 3954 3955 3956 3957 3958 3959 3960 3961 3962 3963 3964 3965 3966 3967 3968 3969 3970 3971 3972 3973 3974 3975 3976 3977 3978 3979 3980 3981 3982 3983 3984 3985 3986 3987 3988 3989 3990 3991 3992 3993 3994 3995 3996 3997 3998 3999 4000 4001 4002 4003 4004 4005 4006 4007 4008 4009 4010 4011 4012 4013 4014 4015 4016 4017 4018 4019 4020 4021 4022 4023 4024 4025 4026 4027 4028 4029 4030 4031 4032 4033 4034 4035 4036 4037 4038 4039 4040 4041 4042 4043 4044 4045 4046 4047 4048 4049 4050 4051 4052 4053 4054 4055 4056 4057 4058 4059 4060 4061 4062 4063 4064 4065 4066 4067 4068 4069 4070 4071 4072 4073 4074 4075 4076 4077 4078 4079 4080 4081 4082 4083 4084 4085 4086 4087 4088 4089 4090 4091 4092 4093 4094 4095 4096 4097 4098 4099 4100 4101 4102 4103 4104 4105 4106 4107 4108 4109 4110 4111 4112 4113 4114 4115 4116 4117 4118 4119 4120 4121 4122 4123 4124 4125 4126 4127 4128 4129 4130 4131 4132 4133 4134 4135 4136 4137 4138 4139 4140 4141 4142 4143 4144 4145 4146 4147 4148 4149 4150 4151 4152 4153 4154 4155 4156 4157 4158 4159 4160 4161 4162 4163 4164 4165 4166 4167 4168 4169 4170 4171 4172 4173 4174 4175 4176 4177 4178 4179 4180 4181 4182 4183 4184 4185 4186 4187 4188 4189 4190 4191 4192 4193 4194 4195 4196 4197 4198 4199 4200 4201 4202 4203 4204 4205 4206 4207 4208 4209 4210 4211 4212 4213 4214 4215 4216 4217 4218 4219 4220 4221 4222 4223 4224 4225 4226 4227 4228 4229 4230 4231 4232 4233 4234 4235 4236 4237 4238 4239 4240 4241 4242 4243 4244 4245 4246 4247 4248 4249 4250 4251 4252 4253 4254 4255 4256 4257 4258 4259 4260 4261 4262 4263 4264 4265 4266 4267 4268 4269 4270 4271 4272 4273 4274 4275 4276 4277 4278 4279 4280 4281 4282 4283 4284 4285 4286 4287 4288 4289 4290 4291 4292 4293 4294 4295 4296 4297 4298 4299 4300 4301 4302 4303 4304 4305 4306 4307 4308 4309 4310 4311 4312 4313 4314 4315 4316 4317 4318 4319 4320 4321 4322 4323 4324 4325 4326 4327 4328 4329 4330 4331 4332 4333 4334 4335 4336 4337 4338 4339 4340 4341 4342 4343 4344 4345 4346 4347 4348 4349 4350 4351 4352 4353 4354 4355 4356 4357 4358 4359 4360 4361 4362 4363 4364 4365 4366 4367 4368 4369 4370 4371 4372 4373 4374 4375 4376 4377 4378 4379 4380 4381 4382 4383 4384 4385 4386 4387 4388 4389 4390 4391 4392 4393 4394 4395 4396 4397 4398 4399 4400 4401 4402 4403 4404 4405 4406 4407 4408 4409 4410 4411 4412 4413 4414 4415 4416 4417 4418 4419 4420 4421 4422 4423 4424 4425 4426 4427 4428 4429 4430 4431 4432 4433 4434 4435 4436 4437 4438 4439 4440 4441 4442 4443 4444 4445 4446 4447 4448 4449 4450 4451 4452 4453 4454 4455 4456 4457 4458 4459 4460 4461 4462 4463 4464 4465 4466 4467 4468 4469 4470 4471 4472 4473 4474 4475 4476 4477 4478 4479 4480 4481 4482 4483 4484 4485 4486 4487 4488 4489 4490 4491 4492 4493 4494 4495 4496 4497 4498 4499 4500 4501 4502 4503 4504 4505 4506 4507 4508 4509 4510 4511 4512 4513 4514 4515 4516 4517 4518 4519 4520 4521 4522 4523 4524 4525 4526 4527 4528 4529 4530 4531 4532 4533 4534 4535 4536 4537 4538 4539 4540 4541 4542 4543 4544 4545 4546 4547 4548 4549 4550 4551 4552 4553 4554 4555 4556 4557 4558 4559 4560 4561 4562 4563 4564 4565 4566 4567 4568 4569 4570 4571 4572 4573 4574 4575 4576 4577 4578 4579 4580 4581 4582 4583 4584 4585 4586 4587 4588 4589 4590 4591 4592 4593 4594 4595 4596 4597 4598 4599 4600 4601 4602 4603 4604 4605 4606 4607 4608 4609 4610 4611 4612 4613 4614 4615 4616 4617 4618 4619 4620 4621 4622 4623 4624 4625 4626 4627 4628 4629 4630 4631 4632 4633 4634 4635 4636 4637 4638 4639 4640 4641 4642 4643 4644 4645 4646 4647 4648 4649 4650 4651 4652 4653 4654 4655 4656 4657 4658 4659 4660 4661 4662 4663 4664 4665 4666 4667 4668 4669 4670 4671 4672 4673 4674 4675 4676 4677 4678 4679 4680 4681 4682 4683 4684 4685 4686 4687 4688 4689 4690 4691 4692 4693 4694 4695 4696 4697 4698 4699 4700 4701 4702 4703 4704 4705 4706 4707 4708 4709 4710 4711 4712 4713 4714 4715 4716 4717 4718 4719 4720 4721 4722 4723 4724 4725 4726 4727 4728 4729 4730 4731 4732 4733 4734 4735 4736 4737 4738 4739 4740 4741 4742 4743 4744 4745 4746 4747 4748 4749 4750 4751 4752 4753 4754 4755 4756 4757 4758 4759 4760 4761 4762 4763 4764 4765 4766 4767 4768 4769 4770 4771 4772 4773 4774 4775 4776 4777 4778 4779 4780 4781 4782 4783 4784 4785 4786 4787 4788 4789 4790 4791 4792 4793 4794 4795 4796 4797 4798 4799 4800 4801 4802 4803 4804 4805 4806 4807 4808 4809 4810 4811 4812 4813 4814 4815 4816 4817 4818 4819 4820 4821 4822 4823 4824 4825 4826 4827 4828 4829 4830 4831 4832 4833 4834 4835 4836 4837 4838 4839 4840 4841 4842 4843 4844 4845 4846 4847 4848 4849 4850 4851 4852 4853 4854 4855 4856 4857 4858 4859 4860 4861 4862 4863 4864 4865 4866 4867 4868 4869 4870 4871 4872 4873 4874 4875 4876 4877 4878 4879 4880 4881 4882 4883 4884 4885 4886 4887 4888 4889 4890 4891 4892 4893 4894 4895 4896 4897 4898 4899 4900 4901 4902 4903 4904 4905 4906 4907 4908 4909 4910 4911 4912 4913 4914 4915 4916 4917 4918 4919 4920 4921 4922 4923 4924 4925 4926 4927 4928 4929 4930 4931 4932 4933 4934 4935 4936 4937 4938 4939 4940 4941 4942 4943 4944 4945 4946 4947 4948 4949 4950 4951 4952 4953 4954 4955 4956 4957 4958 4959 4960 4961 4962 4963 4964 4965 4966 4967 4968 4969 4970 4971 4972 4973 4974 4975 4976 4977 4978 4979 4980 4981 4982 4983 4984 4985 4986 4987 4988 4989 4990 4991 4992 4993 4994 4995 4996 4997 4998 4999 5000 5001 5002 5003 5004 5005 5006 5007 5008 5009 5010 5011 5012 5013 5014 5015 5016 5017 5018 5019 5020 5021 5022 5023 5024 5025 5026 5027 5028 5029 5030 5031 5032 5033 5034 5035 5036 5037 5038 5039 5040 5041 5042 5043 5044 5045 5046 5047 5048 5049 5050 5051 5052 5053 5054 5055 5056 5057 5058 5059 5060 5061 5062 5063 5064 5065 5066 5067 5068 5069 5070 5071 5072 5073 5074 5075 5076 5077 5078 5079 5080 5081 5082 5083 5084 5085 5086 5087 5088 5089 5090 5091 5092 5093 5094 5095 5096 5097 5098 5099 5100 5101 5102 5103 5104 5105 5106 5107 5108 5109 5110 5111 5112 5113 5114 5115 5116 5117 5118 5119 5120 5121 5122 5123 5124 5125 5126 5127 5128 5129 5130 5131 5132 5133 5134 5135 5136 5137 5138 5139 5140 5141 5142 5143 5144 5145 5146 5147 5148 5149 5150 5151 5152 5153 5154 5155 5156 5157 5158 5159 5160 5161 5162 5163 5164 5165 5166 5167 5168 5169 5170 5171 5172 5173 5174 5175 5176 5177 5178 5179 5180 5181 5182 5183 5184 5185 5186 5187 5188 5189 5190 5191 5192 5193 5194 5195 5196 5197 5198 5199 5200 5201 5202 5203 5204 5205 5206 5207 5208 5209 5210 5211 5212 5213 5214 5215 5216 5217 5218 5219 5220 5221 5222 5223 5224 5225 5226 5227 5228 5229 5230 5231 5232 5233 5234 5235 5236 5237 5238 5239 5240 5241 5242 5243 5244 5245 5246 5247 5248 5249 5250 5251 5252 5253 5254 5255 5256 5257 5258 5259 5260 5261 5262 5263 5264 5265 5266 5267 5268 5269 5270 5271 5272 5273 5274 5275 5276 5277 5278 5279 5280 5281 5282 5283 5284 5285 5286 5287 5288 5289 5290 5291 5292 5293 5294 5295 5296 5297 5298 5299 5300 5301 5302 5303 5304 5305 5306 5307 5308 5309 5310 5311 5312 5313 5314 5315 5316 5317 5318 5319 5320 5321 5322 5323 5324 5325 5326 5327 5328 5329 5330 5331 5332 5333 5334 5335 5336 5337 5338 5339 5340 5341 5342 5343 5344 5345 5346 5347 5348 5349 5350 5351 5352 5353 5354 5355 5356 5357 5358 5359 5360 5361 5362 5363 5364 5365 5366 5367 5368 5369 5370 5371 5372 5373 5374 5375 5376 5377 5378 5379 5380 5381 5382 5383 5384 5385 5386 5387 5388 5389 5390 5391 5392 5393 5394 5395 5396 5397 5398 5399 5400 5401 5402 5403 5404 5405 5406 5407 5408 5409 5410 5411 5412 5413 5414 5415 5416 5417 5418 5419 5420 5421 5422 5423 5424 5425 5426 5427 5428 5429 5430 5431 5432 5433 5434 5435 5436 5437 5438 5439 5440 5441 5442 5443 5444 5445 5446 5447 5448 5449 5450 5451 5452 5453 5454 5455 5456 5457 5458 5459 5460 5461 5462 5463 5464 5465 5466 5467 5468 5469 5470 5471 5472 5473 5474 5475 5476 5477 5478 5479 5480 5481 5482 5483 5484 5485 5486 5487 5488 5489 5490 5491 5492 5493 5494 5495 5496 5497 5498 5499 5500 5501 5502 5503 5504 5505 5506 5507 5508 5509 5510 5511 5512 5513 5514 5515 5516 5517 5518 5519 5520 5521 5522 5523 5524 5525 5526 5527 5528 5529 5530 5531 5532 5533 5534 5535 5536 5537 5538 5539 5540 5541 5542 5543 5544 5545 5546 5547 5548 5549 5550 5551 5552 5553 5554 5555 5556 5557 5558 5559 5560 5561 5562 5563 5564 5565 5566 5567 5568 5569 5570 5571 5572 5573 5574 5575 5576 5577 5578 5579 5580 5581 5582 5583 5584 5585 5586 5587 5588 5589 5590 5591 5592 5593 5594 5595 5596 5597 5598 5599 5600 5601 5602 5603 5604 5605 5606 5607 5608 5609 5610 5611 5612 5613 5614 5615 5616 5617 5618 5619 5620 5621 5622 5623 5624 5625 5626 5627 5628 5629 5630 5631 5632 5633 5634 5635 5636 5637 5638 5639 5640 5641 5642 5643 5644 5645 5646 5647 5648 5649 5650 5651 5652 5653 5654 5655 5656 5657 5658 5659 5660 5661 5662 5663 5664 5665 5666 5667 5668 5669 5670 5671 5672 5673 5674 5675 5676 5677 5678 5679 5680 5681 5682 5683 5684 5685 5686 5687 5688 5689 5690 5691 5692 5693 5694 5695 5696 5697 5698 5699 5700 5701 5702 5703 5704 5705 5706 5707 5708 5709 5710 5711 5712 5713 5714 5715 5716 5717 5718 5719 5720 5721 5722 5723 5724 5725 5726 5727 5728 5729 5730 5731 5732 5733 5734 5735 5736 5737 5738 5739 5740 5741 5742 5743 5744 5745 5746 5747 5748 5749 5750 5751 5752 5753 5754 5755 5756 5757 5758 5759 5760 5761 5762 5763 5764 5765 5766 5767 5768 5769 5770 5771 5772 5773 5774 5775 5776 5777 5778 5779 5780 5781 5782 5783 5784 5785 5786 5787 5788 5789 5790 5791 5792 5793 5794 5795 5796 5797 5798 5799 5800 5801 5802 5803 5804 5805 5806 5807 5808 5809 5810 5811 5812 5813 5814 5815 5816 5817 5818 5819 5820 5821 5822 5823 5824 5825 5826 5827 5828 5829 5830 5831 5832 5833 5834 5835 5836 5837 5838 5839 5840 5841 5842 5843 5844 5845 5846 5847 5848 5849 5850 5851 5852 5853 5854 5855 5856 5857 5858 5859 5860 5861 5862 5863 5864 5865 5866 5867 5868 5869 5870 5871 5872 5873 5874 5875 5876 5877 5878 5879 5880 5881 5882 5883 5884 5885 5886 5887 5888 5889 5890 5891 5892 5893 5894 5895 5896 5897 5898 5899 5900 5901 5902 5903 5904 5905 5906 5907 5908 5909 5910 5911 5912 5913 5914 5915 5916 5917 5918 5919 5920 5921 5922 5923 5924 5925 5926 5927 5928 5929 5930 5931 5932 5933 5934 5935 5936 5937 5938 5939 5940 5941 5942 5943 5944 5945 5946 5947 5948 5949 5950 5951 5952 5953 5954 5955 5956 5957 5958 5959 5960 5961 5962 5963 5964 5965 5966 5967 5968 5969 5970 5971 5972 5973 5974 5975 5976 5977 5978 5979 5980 5981 5982 5983 5984 5985 5986 5987 5988 5989 5990 5991 5992 5993 5994 5995 5996 5997 5998 5999 6000 6001 6002 6003 6004 6005 6006 6007 6008 6009 6010 6011 6012 6013 6014 6015 6016 6017 6018 6019 6020 6021 6022 6023 6024 6025 6026 6027 6028 6029 6030 6031 6032 6033 6034 6035 6036 6037 6038 6039 6040 6041 6042 6043 6044 6045 6046 6047 6048 6049 6050 6051 6052 6053 6054 6055 6056 6057 6058 6059 6060 6061 6062 6063 6064 6065 6066 6067 6068 6069 6070 6071 6072 6073 6074 6075 6076 6077 6078 6079 6080 6081 6082 6083 6084 6085 6086 6087 6088 6089 6090 6091 6092 6093 6094 6095 6096 6097 6098 6099 6100 6101 6102 6103 6104 6105 6106 6107 6108 6109 6110 6111 6112 6113 6114 6115 6116 6117 6118 6119 6120 6121 6122 6123 6124 6125 6126 6127 6128 6129 6130 6131 6132 6133 6134 6135 6136 6137 6138 6139 6140 6141 6142 6143 6144 6145 6146 6147 6148 6149 6150 6151 6152 6153 6154 6155 6156 6157 6158 6159 6160 6161 6162 6163 6164 6165 6166 6167 6168 6169 6170 6171 6172 6173 6174 6175 6176 6177 6178 6179 6180 6181 6182 6183 6184 6185 6186 6187 6188 6189 6190 6191 6192 6193 6194 6195 6196 6197 6198 6199 6200 6201 6202 6203 6204 6205 6206 6207 6208 6209 6210 6211 6212 6213 6214 6215 6216 6217 6218 6219 6220 6221 6222 6223 6224 6225 6226 6227 6228 6229 6230 6231 6232 6233 6234 6235 6236 6237 6238 6239 6240 6241 6242 6243 6244 6245 6246 6247 6248 6249 6250 6251 6252 6253 6254 6255 6256 6257 6258 6259 6260 6261 6262 6263 6264 6265 6266 6267 6268 6269 6270 6271 6272 6273 6274 6275 6276 6277 6278 6279 6280 6281 6282 6283 6284 6285 6286 6287 6288 6289 6290 6291 6292 6293 6294 6295 6296 6297 6298 6299 6300 6301 6302 6303 6304 6305 6306 6307 6308 6309 6310 6311 6312 6313 6314 6315 6316 6317 6318 6319 6320 6321 6322 6323 6324 6325 6326 6327 6328 6329 6330 6331 6332 6333 6334 6335 6336 6337 6338 6339 6340 6341 6342 6343 6344 6345 6346 6347 6348 6349 6350 6351 6352 6353 6354 6355 6356 6357 6358 6359 6360 6361 6362 6363 6364 6365 6366 6367 6368 6369 6370 6371 6372 6373 6374 6375 6376 6377 6378 6379 6380 6381 6382 6383 6384 6385 6386 6387 6388 6389 6390 6391 6392 6393 6394 6395 6396 6397 6398 6399 6400 6401 6402 6403 6404 6405 6406 6407 6408 6409 6410 6411 6412 6413 6414 6415 6416 6417 6418 6419 6420 6421 6422 6423 6424 6425 6426 6427 6428 6429 6430 6431 6432 6433 6434 6435 6436 6437 6438 6439 6440 6441 6442 6443 6444 6445 6446 6447 6448 6449 6450 6451 6452 6453 6454 6455 6456 6457 6458 6459 6460 6461 6462 6463 6464 6465 6466 6467 6468 6469 6470 6471 6472 6473 6474 6475 6476 6477 6478 6479 6480 6481 6482 6483 6484 6485 6486 6487 6488 6489 6490 6491 6492 6493 6494 6495 6496 6497 6498 6499 6500 6501 6502 6503 6504 6505 6506 6507 6508 6509 6510 6511 6512 6513 6514 6515 6516 6517 6518 6519 6520 6521 6522 6523 6524 6525 6526 6527 6528 6529 6530 6531 6532 6533 6534 6535 6536 6537 6538 6539 6540 6541 6542 6543 6544 6545 6546 6547 6548 6549 6550 6551 6552 6553 6554 6555 6556 6557 6558 6559 6560 6561 6562 6563 6564 6565 6566 6567 6568 6569 6570 6571 6572 6573 6574 6575 6576 6577 6578 6579 6580 6581 6582 6583 6584 6585 6586 6587 6588 6589 6590 6591 6592 6593 6594 6595 6596 6597 6598 6599 6600 6601 6602 6603 6604 6605 6606 6607 6608 6609 6610 6611 6612 6613 6614 6615 6616 6617 6618 6619 6620 6621 6622 6623 6624 6625 6626 6627 6628 6629 6630 6631 6632 6633 6634 6635 6636 6637 6638 6639 6640 6641 6642 6643 6644 6645 6646 6647 6648 6649 6650 6651 6652 6653 6654 6655 6656 6657 6658 6659 6660 6661 6662 6663 6664 6665 6666 6667 6668 6669 6670 6671 6672 6673 6674 6675 6676 6677 6678 6679 6680 6681 6682 6683 6684 6685 6686 6687 6688 6689 6690 6691 6692 6693 6694 6695 6696 6697 6698 6699 6700 6701 6702 6703 6704 6705 6706 6707 6708 6709 6710 6711 6712 6713 6714 6715 6716 6717 6718 6719 6720 6721 6722 6723 | """
Agentic Workflow Main Module
---------------------------
This file implements the core agentic workflow for text-to-SQL, including:
- LLM orchestration (Ollama, MCP, fallback)
- Memory management and safety checks
- SQL cleaning, validation, and artifact stripping
- Agentic orchestration (plan/micro/LLM phases)
- Robust fallback and error handling
All agent logic is preserved. Comments clarify key logic and utility functions.
"""
import os
import sys
# Windows console safety: module-level prints (emoji etc.) crash the whole
# import when the console codec is cp1252/latin-1. Reconfigure to UTF-8 with
# replacement so logging can never take the app down.
try:
_enc = (getattr(sys.stdout, "encoding", "") or "").lower()
if _enc and _enc not in ("utf-8", "utf8") and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import asyncio
import re
import gc # Explicit garbage collection import
import signal # For timeouts
import threading # For thread management
import weakref # For weak references to avoid memory leaks
import time # For timing operations
from typing import Dict, Any, List, Optional
import json
import uuid
from datetime import datetime
from pathlib import Path
import psutil # Import for memory monitoring
import requests
# Database-related imports
from sqlalchemy import create_engine, text, Table, MetaData, inspect
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import NoSuchTableError
try:
from .db_uri import normalize_db_uris
except ImportError: # module imported as top-level (not part of the Agent package)
from db_uri import normalize_db_uris
# LLM imports
from llama_index.core import Settings
from llama_index.llms.ollama import Ollama
from llama_index.core.agent import ReActAgent
from llama_index.core.tools import FunctionTool
from llama_index.core.llms import ChatMessage, MessageRole, LLMMetadata, CompletionResponse
try:
from llama_index.core.llms.custom import CustomLLM
except ImportError:
from llama_index.core.llms import CustomLLM
from llama_index.core.storage.chat_store import SimpleChatStore
try:
from huggingface_hub import InferenceClient
except ImportError:
InferenceClient = None
try:
from .invariants import enforce_invariants, classify_question
except (ImportError, ValueError):
try:
from Agent.invariants import enforce_invariants, classify_question
except ImportError:
def enforce_invariants(*args, **kwargs): return args[0] if args else None
def classify_question(*args, **kwargs): return "unknown"
from dotenv import load_dotenv
import os as _os
_dotenv_candidates = [
_os.path.join(_os.path.dirname(__file__), '..', '.env'), # project root .env
_os.path.join(_os.path.dirname(__file__), '.env'), # Agent/.env (most specific)
]
for _dotenv_path in _dotenv_candidates:
if _os.path.isfile(_dotenv_path):
print(f"[DEBUG] Loading .env from: {_dotenv_path}")
load_dotenv(dotenv_path=_dotenv_path, override=False) # Environment variables (Docker) now win over .env files
del _os, _dotenv_candidates, _dotenv_path
print(f"[DEBUG] Before CWD load_dotenv, DB_CONNECTION_URL={os.environ.get('DB_CONNECTION_URL')}")
load_dotenv() # also load CWD .env for any remaining vars
print(f"[DEBUG] After CWD load_dotenv, DB_CONNECTION_URL={os.environ.get('DB_CONNECTION_URL')}")
# Simple initialization
DB_CONNECTION_URL = os.environ.get("DB_CONNECTION_URL", None)
print(f"[DEBUG] Final DB_CONNECTION_URL={DB_CONNECTION_URL}")
if not DB_CONNECTION_URL:
print(
"\n" + "=" * 68 +
"\n[WARN] DB_CONNECTION_URL is not set — starting WITHOUT a database.\n"
" The app still works: connect a database at runtime from the web UI's\n"
" 'Database' panel (connection URL, or upload a .sqlite/.csv file).\n"
" PRE-SET STARTING DATABASE WHERE OTHERS COME FROM:\n"
" To autoload the built-in employee demo instead, set DB_CONNECTION_URL\n"
" in Agent/.env, e.g.\n"
" DB_CONNECTION_URL=postgresql://postgres:postgres@localhost:5432/employee\n" +
"=" * 68
)
# Import our memory management utilities (robust import with fallbacks)
import importlib
import sys
memory_manager = None
safe_check_memory = None
TimeoutManager = None
_last_import_error = None
# Try several import strategies: package-relative, package-absolute, and top-level
try_names = []
if __package__:
# e.g. when imported as Agent.agentic_workflow, __package__ == 'Agent'
try_names.append(f"{__package__}.memory_manager")
# Also try the absolute package path
try_names.append("Agent.memory_manager")
# Finally try top-level fallback
try_names.append("memory_manager")
for modname in try_names:
try:
mod = importlib.import_module(modname)
# Extract expected symbols if present
memory_manager = getattr(mod, 'memory_manager', None)
safe_check_memory = getattr(mod, 'safe_check_memory', None)
TimeoutManager = getattr(mod, 'TimeoutManager', None)
if memory_manager is not None and safe_check_memory is not None and TimeoutManager is not None:
break
except Exception as e:
_last_import_error = e
if memory_manager is None or safe_check_memory is None or TimeoutManager is None:
# Provide a clear diagnostic so startup logs show why import failed
print("Failed to import Agent.memory_manager via tried paths:", try_names)
print("cwd=", __import__('os').getcwd())
print("sys.path[0]=", sys.path[0] if len(sys.path) > 0 else None)
if _last_import_error:
# Re-raise the last error to preserve traceback
raise _last_import_error
else:
raise ImportError("Could not locate memory_manager module; ensure Agent/memory_manager.py exists and package imports are correct")
# Ollama configuration
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
# --- Employee Database Schema for Prompt Injection ---
EMPLOYEE_DB_SCHEMA = (
"Database schema:\n"
"- employee(emp_no, birth_date, first_name, last_name, gender, hire_date)\n"
"- department(dept_no, dept_name)\n"
"- dept_emp(emp_no, dept_no, from_date, to_date)\n"
"- dept_manager(emp_no, dept_no, from_date, to_date)\n"
"- salary(emp_no, salary, from_date, to_date) # column name is 'salary' in this dataset\n"
"- title(emp_no, title, from_date, to_date)\n"
"- views: current_dept_emp (current department per employee), dept_emp_latest_date\n"
"\n"
"CRITICAL Rules:\n"
"- The 'department' table does NOT have a 'to_date' column. Never filter department by to_date.\n"
"- To get CURRENT records (managers, salaries, department assignments), ALWAYS filter by `to_date = '9999-01-01'` in the 'salary', 'dept_emp', 'dept_manager', or 'title' tables.\n"
"- To get MANAGER NAMES, you MUST join `dept_manager` (dm) with `employee` (e) on `emp_no`.\n"
"- To get DEPARTMENT NAMES for employees, join `dept_emp` (de) with `department` (d) on `dept_no`.\n"
"- The 'employee' table does NOT have a 'dept_no' column. To find an employee's department name, you MUST join 'dept_emp' (de) with 'department' (d) on 'dept_no'.\n"
"- Salaries are in table `salary` (column `salary`). Always use `s.to_date = '9999-01-01'` for current pay.\n"
)
# Allow a dedicated finetuned model name (merged LoRA) via FINETUNED_MODEL_NAME env.
# Resolution order:
# 1. FINETUNED_MODEL_NAME (e.g., gemma1b-text2sql)
# 2. OLLAMA_MODEL_NAME (defaults to base gemma3:1b)
# 3. Hard fallback 'gemma3:1b'
FINETUNED_MODEL_NAME = os.environ.get("FINETUNED_MODEL_NAME")
BASE_MODEL_FALLBACK = "qwen2.5-coder:1.5b" #"gemma3:1b-it-qat"
OLLAMA_MODEL_NAME = FINETUNED_MODEL_NAME or os.environ.get("OLLAMA_MODEL_NAME", BASE_MODEL_FALLBACK)
FALLBACK_MODEL_NAME = BASE_MODEL_FALLBACK # Keep fallback stable
if FINETUNED_MODEL_NAME:
print(f"🦙 Using finetuned Ollama model: {OLLAMA_MODEL_NAME} (base fallback: {FALLBACK_MODEL_NAME})")
else:
print(f"🦙 Using base Ollama model: {OLLAMA_MODEL_NAME} (fallback: {FALLBACK_MODEL_NAME})")
# Schema profile global (populated at DB init)
try:
from . import schema_profile as _schema_profile_mod
except Exception:
import Agent.schema_profile as _schema_profile_mod
ACTIVE_SCHEMA_PROFILE = _schema_profile_mod.SchemaProfile(
name="uninitialized",
db_url=None,
is_demo=False,
)
def _schema_notes_block() -> str:
"""Owner-supplied domain hints for the active database, if any.
Structure comes from live introspection, but semantics (what 'sold_date
IS NULL' means, price distinctions, aliases, revenue rules) only the
database owner knows. These notes are labeled authoritative so the LLM
treats them above its own guesses.
"""
try:
notes = (getattr(ACTIVE_SCHEMA_PROFILE, "notes", "") or "").strip()
except Exception:
notes = ""
if not notes:
return ""
return (
"SCHEMA NOTES from the database owner (authoritative for column meanings "
"and business rules):\n" + notes
)
def _dynamic_schema_block(schema_text: str) -> str:
"""Schema section for LLM prompts.
Employee demo profile -> curated EMPLOYEE_DB_SCHEMA block (battle-tested).
Any other live profile -> aggregates the live introspected schema instead,
so no hardcoded table assumptions leak into user databases.
Falls back to the per-relevant-table schema snippets when the profile is
not yet populated. Owner schema notes are appended in every branch.
"""
profile = ACTIVE_SCHEMA_PROFILE
_notes = _schema_notes_block()
try:
if profile is not None and profile.is_employee_db and profile.tables:
return EMPLOYEE_DB_SCHEMA + (("\n\n" + _notes) if _notes else "")
if profile is not None and profile.tables:
if schema_text and schema_text != "(schema unavailable)":
return (
f"Database schema (live):\n{profile.build_digest(max_tables=15)}\n"
f"Focus tables (columns):\n{schema_text}"
) + (("\n\n" + _notes) if _notes else "")
return (
f"Database schema (live):\n{profile.build_digest(max_tables=15)}"
) + (("\n\n" + _notes) if _notes else "")
except Exception as _e:
print(f"[DYNAMIC-SCHEMA] profile digest unavailable: {_e}")
base = f"Database schema:\n{schema_text}" if schema_text else EMPLOYEE_DB_SCHEMA
return base + (("\n\n" + _notes) if _notes else "")
def _current_row_rules(default_rule: str) -> str:
"""Rules text for current-row filtering. The to_date='9999-01-01' pattern is
employee-schema specific; other schemas get a generic recency hint."""
if ACTIVE_SCHEMA_PROFILE.is_employee_db:
return default_rule
return (
"If a table has a date column indicating record validity/ends, filter to the "
"current/active rows when the question implies 'current' or 'now'. "
"Otherwise do NOT invent date filters."
)
# OCR Q&A model configuration
# PRIMARY: Granite Vision VLM (GRANITE_MODEL_PATH env) — used when image file is available
# FALLBACK: lightweight Ollama text model — used for old docs without a saved image
GRANITE_MODEL_PATH = (
os.environ.get("GRANITE_MODEL_PATH") or
os.environ.get("GRANITE_MODEL") or
"qwen2-vl:2b" # local Ollama fallback (dev only)
)
if os.environ.get("USE_HF_CLOUD", "0") in ("1", "true", "True"):
print(f"[GRANITE_VQA] Target Model: {os.environ.get('GROQ_VISION_MODEL', 'qwen/qwen3.8-27b')} (via Groq Vision)")
else:
print(f"[GRANITE_VQA] Target Model: {GRANITE_MODEL_PATH} (via Ollama)")
# Text-only fallback: Ollama model used when no image file exists for the doc.
# NOT the same as GRANITE_MODEL_PATH — this runs via Ollama REST API, not HuggingFace.
OLLAMA_OCR_QA_MODEL = os.environ.get("OLLAMA_OCR_QA_MODEL", "gemma3:1b-it-qat")
print(f"[OCR_QA] Text fallback model (Ollama): {OLLAMA_OCR_QA_MODEL}")
OCR_QA_TIMEOUT = float(os.environ.get("OCR_QA_TIMEOUT", "45"))
print(f"[OCR_QA] Text fallback timeout={OCR_QA_TIMEOUT}s")
def check_memory_safety():
"""Check if there's enough memory to safely process a request"""
# Use our improved memory checking system
return safe_check_memory()
# Global variables for database connection
available_tables = []
inspector = None
sql_database = None
async_engine = None
async_session = None
# True once initialize_database() finished successfully at least once.
# Databases without an async SQLAlchemy dialect (mssql/oracle) keep
# async_session None permanently, so "connected" must not be inferred from it.
_DB_CONNECTED = False
# Initialize chat store directory and files
chat_store_dir = Path('chat_store')
chat_store_dir.mkdir(parents=True, exist_ok=True)
# Define chat store file paths
public_store_path = chat_store_dir/'chat_store_public.json'
private_store_path = chat_store_dir/'chat_store_private.json'
# Initialize empty chat store structure with conversation key
initial_store = {"conversation": []}
# Create JSON files if they don't exist
if not public_store_path.exists():
with open(public_store_path, 'w') as f:
json.dump(initial_store, f, indent=2)
if not private_store_path.exists():
with open(private_store_path, 'w') as f:
json.dump(initial_store, f, indent=2)
# Initialize chat stores — self-healing loader.
# Abrupt process exits (OOM kills, Space runtime restarts) can truncate a
# store file mid-write; a 0-byte or invalid JSON file must not brick the
# worker at import time. Repair to the known empty structure and retry.
def _load_or_repair_chat_store(path: Path) -> SimpleChatStore:
try:
return SimpleChatStore().from_persist_path(str(path))
except (json.JSONDecodeError, ValueError) as store_err:
print(
f"[CHAT-STORE] '{path.name}' unreadable "
f"({store_err.__class__.__name__}) — resetting it"
)
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(initial_store, f, indent=2)
except Exception as repair_err:
print(f"[CHAT-STORE] reset failed: {repair_err}")
return SimpleChatStore()
try:
return SimpleChatStore().from_persist_path(str(path))
except Exception as reload_err:
print(f"[CHAT-STORE] reload after reset failed: {reload_err}")
return SimpleChatStore()
print("Debug: Initializing chat stores")
chat_store = SimpleChatStore()
chat_store_public = _load_or_repair_chat_store(public_store_path)
chat_store_private = _load_or_repair_chat_store(private_store_path)
print("Debug: Chat stores initialized")
# Utility: robustly extract clean SQL from LLM/artifact wrappers
def extract_clean_sql(raw: str) -> str:
"""Remove <<<SQL_START>>>, <<<SQL_END>>>, code fences, and similar wrappers from SQL text."""
import re
if not raw or not isinstance(raw, str):
return raw
s = raw.strip()
# Remove <<<SQL_START>>> ... <<<SQL_END>>> blocks
s = re.sub(r"<<<SQL_START>>>[\s\S]*?<<<SQL_END>>>", lambda m: m.group(0).replace('<<<SQL_START>>>','').replace('<<<SQL_END>>>','').strip(), s, flags=re.IGNORECASE)
# Remove <<<SQL_REVISED_START>>> ... <<<SQL_REVISED_END>>> blocks
s = re.sub(r"<<<SQL_REVISED_START>>>[\s\S]*?<<<SQL_REVISED_END>>>", lambda m: m.group(0).replace('<<<SQL_REVISED_START>>>','').replace('<<<SQL_REVISED_END>>>','').strip(), s, flags=re.IGNORECASE)
# Remove code fences (```sql ... ``` or ```)
s = re.sub(r"```(?:sql)?([\s\S]*?)```", lambda m: m.group(1).strip(), s, flags=re.IGNORECASE)
# Remove any remaining <<<...>>> wrappers
s = re.sub(r"<<<[A-Z_]+>>>", "", s)
# Extract only the SQL portion before any explanations.
# Handles both plain "Explanation:" and markdown "### Explanation:" headers.
_explanation_pattern = r';\s*\n[\s\S]*?(?:#{1,3}\s*[Ee]xplanation|[Ee]xplanation)\s*:'
if re.search(_explanation_pattern, s):
s = re.split(_explanation_pattern, s)[0].rstrip() + ';'
elif re.search(r';\s*\n\s*[Ee]xplanation:', s):
s = re.split(r';\s*\n\s*[Ee]xplanation:', s)[0] + ';'
# Remove leading/trailing whitespace and repeated newlines
s = s.strip()
# Remove any leading/trailing code fence lines
s = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", s, flags=re.MULTILINE)
# Remove any trailing explanation text that might still be there
lines = s.split('\n')
sql_lines = []
for line in lines:
line = line.strip()
# Stop at explanation markers — including markdown ### headers
if re.match(r'^#{1,3}\s*(explanation|note|this query)', line, re.IGNORECASE):
break
if re.match(r'^(explanation|note|this query):', line, re.IGNORECASE):
break
if line.lower().startswith('explanation:'):
break
if re.match(r'^\d+\.\s+', line): # Numbered explanation steps
break
sql_lines.append(line)
s = '\n'.join(sql_lines).strip()
# NEW: Safety check for multiple commands.
# If the LLM outputted multiple queries (separated by semicolons),
# we only want the LAST valid one (which is usually the most refined).
if s.count(';') > 1:
# Split by semicolon, keep only those that look like queries
statements = [stmt.strip() for stmt in s.split(';') if 'select' in stmt.lower()]
if statements:
# Use the last one as it's typically the final refinement
s = statements[-1] + ';'
print(f"[CLEAN_SQL] Multiple queries detected. Selected last valid statement.")
return s.strip()
# -----------------------------
# OCR Q&A dedicated helpers
# -----------------------------
def _get_ocr_text_by_doc_id(doc_id: str) -> str:
try:
p = Path('chat_store') / 'docs' / f"{doc_id}.txt"
if not p.exists():
return ""
return p.read_text(encoding='utf-8')
except Exception:
return ""
def _get_image_path_by_doc_id(doc_id: str) -> str:
"""Return the saved image file path for doc_id, or empty string if not found."""
try:
p = Path('chat_store') / 'docs' / f"{doc_id}.img_path"
if not p.exists():
return ""
return p.read_text(encoding='utf-8').strip()
except Exception:
return ""
def _granite_vision_qa(image_path: str, question: str) -> str:
"""Answer a question about an image using IBM Granite Vision 3.1-2b (direct VLM Q&A).
Loads the model from local HF cache with aggressive quantization to stay within
the RAM budget (4-bit → 8-bit → fp16 fallback chain). The model is explicitly
deleted after generation to free memory for the SQL model to reload.
"""
import gc
try:
import torch
from transformers import AutoProcessor, AutoModelForVision2Seq
from PIL import Image as PILImage
except ImportError as e:
return f"[ERROR] Granite Vision dependencies not installed: {e}"
model_path = GRANITE_MODEL_PATH
cache_dir = os.environ.get("HF_HOME") or r"D:\hf_cache"
offload_dir = os.path.join(cache_dir, "offload")
os.makedirs(offload_dir, exist_ok=True)
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"[GRANITE_VQA] Loading {model_path} on {device} from {cache_dir}")
processor = vlm_model = None
try:
processor = AutoProcessor.from_pretrained(model_path, local_files_only=True, cache_dir=cache_dir)
# Try 4-bit → 8-bit → plain fp16 to fit within RAM budget
load_kwargs = dict(local_files_only=True, cache_dir=cache_dir, low_cpu_mem_usage=True)
loaded = False
for attempt, extra in enumerate([
{"load_in_4bit": True},
{"load_in_8bit": True},
{"device_map": "auto", "offload_folder": offload_dir},
]):
try:
vlm_model = AutoModelForVision2Seq.from_pretrained(model_path, **load_kwargs, **extra)
print(f"[GRANITE_VQA] Loaded (attempt {attempt+1}: {list(extra.keys())[0]})")
loaded = True
break
except Exception as le:
print(f"[GRANITE_VQA] Load attempt {attempt+1} failed: {le}")
if not loaded:
return "[ERROR] Granite Vision could not be loaded — all quantisation attempts failed."
img = PILImage.open(image_path).convert("RGB")
conversation = [
{
"role": "user",
"content": [
{"type": "image", "url": image_path},
{"type": "text", "text": (
f"{question}\n\n"
"Answer concisely using only information visible in the image. "
"If you see a table, read the correct row and column carefully."
)},
],
}
]
inputs = processor.apply_chat_template(
conversation,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
)
# Move inputs to model device
try:
model_device = next(vlm_model.parameters()).device
inputs = {k: v.to(model_device) if hasattr(v, 'to') else v for k, v in inputs.items()}
except Exception:
pass
with torch.no_grad():
output_ids = vlm_model.generate(**inputs, max_new_tokens=256, do_sample=False)
answer = processor.decode(output_ids[0], skip_special_tokens=True)
# Strip echoed prompt (Granite echoes conversation before the answer)
if "ASSISTANT" in answer.upper():
answer = answer.split("ASSISTANT")[-1].strip(" :").strip()
elif question.lower()[:20] in answer.lower():
idx = answer.lower().rfind(question.lower()[:20])
answer = answer[idx + len(question):].strip(" :").strip() if idx != -1 else answer
print(f"[GRANITE_VQA] Answer (chars={len(answer)}): {answer[:200]}")
return answer.strip() or "[ERROR] Granite Vision returned an empty response."
except Exception as e:
print(f"[GRANITE_VQA] Generation failed: {e}")
return f"[ERROR] Granite Vision Q&A failed: {e}"
finally:
# Aggressively free VLM memory so SQL model can reload
try:
del vlm_model, processor
except Exception:
pass
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
print("[GRANITE_VQA] Model unloaded; RAM released.")
def _structure_invoice_text(raw: str) -> str:
"""Convert flat Tesseract OCR output into a structured KEY: VALUE format.
Tesseract merges field labels and values on the same line and squashes
table rows together, causing a 1B LLM to confuse GST numbers with phone
numbers, amounts with dates, etc. This function:
- Recognises common invoice field patterns via regex
- Emits explicit 'FIELD_NAME: value' lines for the LLM
- Preserves the rest of the text verbatim
So the model receives unambiguous context like:
GSTIN (Seller): 24HDE7487RE5RT4
Customer Phone: 9372346666
Customer GSTIN: 07AOLCC1206D126
"""
import re
lines = raw.replace('\r\n', '\n').replace('\r', '\n').split('\n')
structured = []
seen_keys = set()
def emit(key: str, val: str):
tag = f"{key}: {val.strip()}"
if tag not in seen_keys:
seen_keys.add(tag)
structured.append(tag)
# Regex patterns for common Indian invoice fields
patterns = [
# GSTIN – 15-char alphanumeric starting with 2 digits
(r'GSTIN\s*(?:No\.?|:)?\s*([0-9]{2}[A-Z0-9]{13})', 'GSTIN'),
# Phone numbers (10-digit Indian mobile or landline with STD)
(r'(?:PHONE|TEL|MOB(?:ILE)?)\s*[:\.]?\s*([0-9\-\+\s]{8,15})', 'Phone'),
# Invoice number
(r'Invoice\s*No\.?\s*[:\.]?\s*([A-Z0-9/\-]+)', 'Invoice Number'),
# Challan number
(r'Challan\s*No\.?\s*[:\.]?\s*([0-9]+)', 'Challan Number'),
# Invoice / Challan dates
(r'Invoice\s*Date\s*[:\.]?\s*(\d{1,2}[-/]\w{3,9}[-/]\d{2,4})', 'Invoice Date'),
(r'Challan\s*Date\s*[:\.]?\s*(\d{1,2}[-/]\w{3,9}[-/]\d{2,4})', 'Challan Date'),
(r'Due\s*Date\s*[:\.]?\s*(\d{1,2}[-/]\w{3,9}[-/]\d{2,4})', 'Due Date'),
(r'Delivery\s*Date\s*[:\.]?\s*(\d{1,2}[-/]\w{3,9}[-/]\d{2,4})', 'Delivery Date'),
# E-Way number
(r'E-?Way\s*No\.?\s*[:\.]?\s*([A-Z0-9]+)', 'E-Way Number'),
# LR / PO numbers
(r'L\.?R\.?\s*No\.?\s*[:\.]?\s*([0-9]+)', 'LR Number'),
(r'P\.?O\.?\s*No\.?\s*[:\.]?\s*([0-9]+)', 'PO Number'),
# PAN
(r'PAN\s*[:\.]?\s*([A-Z]{5}[0-9]{4}[A-Z])', 'PAN'),
# Bank details
(r'Bank\s*(?:Account|A/?C)\s*(?:No\.?|Number)?\s*[:\.]?\s*([0-9]+)', 'Bank Account Number'),
(r'IFSC\s*[:\.]?\s*([A-Z]{4}0[A-Z0-9]{6})', 'Bank IFSC'),
# Totals
(r'Total\s+(?:Amount)?\s*After\s*Tax\s*[:\.]?\s*[\u20B9Rs\.]*\s*([\d,\.]+)', 'Total Amount After Tax'),
(r'Taxable\s+(?:Amount|Value)\s*[:\.]?\s*([\d,\.]+)', 'Taxable Amount'),
(r'Add\s*[:\.]?\s*IGST\s*[:\.]?\s*([\d,\.]+)', 'IGST Amount'),
]
# Scan all lines for known field patterns
full_text = '\n'.join(lines)
# Track which GSTINs we've seen so we can label seller vs customer
gstin_count = 0
for line in lines:
for pat, label in patterns:
m = re.search(pat, line, re.IGNORECASE)
if m:
val = m.group(1).strip()
if label == 'GSTIN':
gstin_count += 1
lbl = 'Seller GSTIN' if gstin_count == 1 else 'Customer GSTIN'
emit(lbl, val)
elif label == 'Phone':
emit('Customer Phone', val)
else:
emit(label, val)
# Extract company name (typically first non-empty ALL-CAPS-ish line)
for line in lines:
stripped = line.strip()
if len(stripped) > 4 and stripped.isupper() and not re.search(r'\d{5,}', stripped):
emit('Company Name', stripped)
break
# Extract customer name after 'M/S' or 'Bill to'
for i, line in enumerate(lines):
if re.search(r'^(M/?S|Bill\s*to)\b', line.strip(), re.IGNORECASE):
# Customer name is likely the next non-empty line or on same line after M/S
rest = re.sub(r'^(M/?S|Bill\s*to)\s*', '', line.strip(), flags=re.IGNORECASE).strip()
if rest:
emit('Customer Name', rest)
elif i + 1 < len(lines) and lines[i+1].strip():
emit('Customer Name', lines[i+1].strip())
break
# --- Product line items ---
# Scan for rows like: "2 | Stanley Hammer 8295 1.00 PCS 568.00 568.00 9.00 51.12 619.12"
# OCR often squashes all rows onto one long line; use the item-number+pipe as separator.
item_pat = re.compile(
r'\b(\d{1,3})\s*[|Ill1]\s*' # item number + pipe (I/l/1 are common OCR artefacts for |)
r'([A-Za-z][A-Za-z\s\-]{2,40}?)\s+' # product name
r'(\d{4,8})\s+' # HSN / SAC code
r'(\d+(?:\.\d+)?)\s+' # quantity
r'(?:PCS|NOS|KG|LT|MT|SET|BOX|PC|NO|EA)\.?\s+' # unit (consumed but not captured)
r'([\d,]+(?:\.\d+)?)' # rate (first numeric after unit)
r'((?:\s+[\d,]+(?:\.\d+)?){1,4})', # remaining numbers (taxable value, GST, total etc.)
re.IGNORECASE
)
for m in item_pat.finditer(full_text):
item_no = m.group(1)
name = m.group(2).strip()
hsn = m.group(3)
qty = m.group(4)
rate = m.group(5)
rest_nums = re.findall(r'[\d,]+(?:\.\d+)?', m.group(6) or '')
# rest_nums order depends on invoice: [taxable_value, gst%, gst_amount, total]
taxable = rest_nums[0] if len(rest_nums) > 0 else rate
total = rest_nums[-1] if len(rest_nums) > 1 else taxable
label = f"Product {item_no} - {name}"
emit(label, f"HSN={hsn}, Qty={qty}, Rate={rate}, Taxable Value={taxable}, Total={total}")
# Build the final structured block
header = "=== STRUCTURED DOCUMENT FIELDS ==="
body = "=== RAW OCR TEXT ===\n" + raw.strip()
if structured:
return header + '\n' + '\n'.join(structured) + '\n\n' + body
return body
def _ollama_unload_model(model: str) -> bool:
"""Ask Ollama to immediately unload a model from RAM (keep_alive=0).
Uses /api/generate with a minimal prompt and keep_alive=0 to signal Ollama
to release the model. Works for models loaded by any client (LlamaIndex etc).
Returns True if the request succeeded, False otherwise (non-fatal).
"""
base = OLLAMA_BASE_URL.rstrip('/')
try:
# A single-space prompt ensures Ollama actually processes the request
# (empty string may be skipped); keep_alive=0 causes immediate unload.
resp = requests.post(
f"{base}/api/generate",
json={"model": model, "prompt": " ", "keep_alive": 0, "stream": False},
timeout=90.0,
)
print(f"[OCR_QA] Unloaded model '{model}' from Ollama RAM (status={resp.status_code})")
return resp.status_code < 300
except Exception as e:
print(f"[OCR_QA][WARN] Failed to unload '{model}': {e}")
return False
def _ollama_generate_text(model: str, prompt: str, timeout: float) -> str:
"""Minimal REST call to Ollama generate API to avoid llama_index routing.
Uses OLLAMA_BASE_URL env and returns the concatenated response text.
num_ctx is chosen adaptively based on available RAM so we don't cause
excessive paging on memory-constrained machines.
"""
url = f"{OLLAMA_BASE_URL.rstrip('/')}/api/generate"
# Pick num_ctx based on current available memory
# ≥800 MB free -> 2048 (full invoice fits comfortably)
# 400–800 MB -> 1024 (still fits most invoices)
# <400 MB -> 512 (minimal; avoid paging)
try:
stats = memory_manager.get_memory_stats()
avail_mb = stats.get("available_mb", 999.0)
except Exception:
avail_mb = 999.0
if avail_mb >= 800:
num_ctx = 1024
num_predict = 200
elif avail_mb >= 400:
num_ctx = 512
num_predict = 150
else:
num_ctx = 256
num_predict = 100
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"keep_alive": 0, # unload immediately after response — prevents gemma3 squatting in RAM
"options": {
"temperature": 0.2,
"num_ctx": num_ctx,
"num_predict": num_predict,
"num_gpu": -1, # keep on CPU same as Qwen; prevents GPU/CPU swap latency
"num_thread": 2, # 2 threads is sufficient for 1B model on CPU
}
}
try:
# Debug request meta (do not print full prompt to avoid huge logs here)
try:
print(f"[DEBUG][OLLAMA_REQUEST] url={url} model={model} prompt_chars={len(prompt)} timeout={timeout} num_ctx={num_ctx} avail_mb={avail_mb:.0f}")
except Exception:
pass
r = requests.post(url, json=payload, timeout=timeout)
r.raise_for_status()
data = r.json()
# Log only response length, not full body (can be very large)
resp_text = data.get('response') or data.get('text') or str(data)
print(f"[DEBUG][OLLAMA_RESPONSE] model={model} response_chars={len(resp_text)}")
# Non-stream returns {response: "..."}
txt = resp_text
return txt
except Exception as e:
print(f"[ERROR][OLLAMA_API] Ollama generate failed: {e}")
return f"Error: Ollama generate failed: {e}"
def ocr_agent_qa(question: str, doc_id: str, *, model: str | None = None, timeout: float | None = None) -> str:
"""Answer a question about a previously uploaded document.
PRIMARY: If the original image is available (.img_path), routes to Granite Vision
via MCP (granite_vision.qa tool) for direct visual Q&A.
FALLBACK: If only OCR text is available (.txt), builds a grounded prompt and
calls OCR_TEXT_FALLBACK_MODEL (gemma3) via Ollama.
"""
text = _get_ocr_text_by_doc_id(doc_id)
# PRIMARY PATH: Granite Vision direct visual Q&A
image_path = _get_image_path_by_doc_id(doc_id)
# ── DIAGNOSTIC (temporary) ──────────────────────────────────────────
_img_path_file = Path('chat_store') / 'docs' / f"{doc_id}.img_path"
print(f"[OCR_QA_DIAG] doc_id = {doc_id}")
print(f"[OCR_QA_DIAG] .img_path file = {_img_path_file.resolve()} | exists={_img_path_file.exists()}")
print(f"[OCR_QA_DIAG] image_path = {image_path!r}")
print(f"[OCR_QA_DIAG] isfile(image) = {os.path.isfile(image_path) if image_path else 'N/A (empty)'}")
# ────────────────────────────────────────────────────────────────────
if image_path and os.path.isfile(image_path):
print(f"[OCR_QA] Using Granite Vision for doc_id={doc_id}, image={image_path}")
# Only perform RAM swapping/unloading if we are NOT in cloud mode
if not (os.environ.get("USE_HF_CLOUD", "0") in ("1", "true", "True")):
print(f"[OCR_QA] Swapping: unloading SQL model '{OLLAMA_MODEL_NAME}' to free RAM for Granite Vision")
_ollama_unload_model(OLLAMA_MODEL_NAME)
time.sleep(1.0) # let OS reclaim pages
else:
print(f"[OCR_QA] 🌩️ CLOUD MODE: Skipping local RAM swap.")
# Route through MCP so Granite Vision runs as a proper registered agent tool
try:
from .mcp_client import mcp_call_tool
except ImportError:
from Agent.mcp_client import mcp_call_tool
try:
ctx = {
"tool_call": {
"name": "granite_vision.qa",
"args": {
"image_path": image_path,
"question": question,
"model_path": GRANITE_MODEL_PATH,
},
}
}
resp = mcp_call_tool(prompt="run granite vision qa", timeout=300.0, context=ctx)
tool_result = resp.get("tool_result") if isinstance(resp, dict) else None
if isinstance(tool_result, dict):
return tool_result.get("answer", "[ERROR] No answer returned from granite_vision.qa")
return str(tool_result) if tool_result else "[ERROR] granite_vision.qa returned no result"
except Exception as e:
print(f"[OCR_QA] MCP granite_vision.qa failed: {e}")
return f"[ERROR] Vision Q&A timed out or failed. Please ensure Docker has enough RAM (6GB+) and try again. ({e})"
# No image available — this doc was uploaded before the Granite Vision upgrade.
# Returning a clear message instead of a wrong answer from the old gemma3 pipeline.
print(f"[OCR_QA] No image found for doc_id={doc_id} — document predates Granite Vision upgrade")
return (
"⚠️ This document was uploaded before the Granite Vision upgrade. "
"Please click 'Upload Image' to re-upload the image — "
"Granite Vision will then answer your question directly from the image."
)
# ---------------------------------------------------------------------------
# Conversation Memory Index (improves recall vs naive substring search)
# ---------------------------------------------------------------------------
question_memory_index = {} # normalized_question -> last answer
def normalize_question(q: str) -> str:
"""Create a stable normalization of a user question for indexing.
Steps: lowercase, remove punctuation except underscores, collapse whitespace.
"""
import re
q = q.lower()
# remove any document snippet brackets if present (they make later repeats unmatchable)
q = re.sub(r"\[document .*?excerpt end\]", "", q, flags=re.IGNORECASE|re.DOTALL)
# strip bracketed metadata sections
q = re.sub(r"\[[^\]]+\]", "", q)
# punctuation -> space (keep underscores)
q = re.sub(r"[^a-z0-9_]+", " ", q)
q = re.sub(r"\s+", " ", q).strip()
return q
def rebuild_memory_index():
"""Rebuild in-memory index from persisted chat history."""
question_memory_index.clear()
msgs = chat_store_private.get_messages(key="conversation")
last_user = None
for m in msgs:
if m.role == MessageRole.USER:
last_user = m.content
elif m.role == MessageRole.ASSISTANT and last_user:
key = normalize_question(last_user)
question_memory_index[key] = m.content
last_user = None
print(f"[MEM-INDEX] Rebuilt memory index with {len(question_memory_index)} entries")
# Build initial index
try:
rebuild_memory_index()
except Exception as e:
print(f"[MEM-INDEX][WARN] Failed to build initial index: {e}")
# ---------------------------------------------------------------------------
# Feature flags / global controls
# ---------------------------------------------------------------------------
# Disable ALL template-based fallback logic to enforce pure LLM agentic path
DISABLE_TEMPLATE_FALLBACK = True
# Feature toggles
ENABLE_INVARIANTS = True # can be turned off for debugging
USE_SIMPLE_LLM_CALL = True # bypass TimeoutManager for main generation
# Configurable generation timeouts (seconds); can override via env vars
FIRST_ATTEMPT_TIMEOUT = float(os.environ.get("FIRST_ATTEMPT_TIMEOUT", 180)) # Increased to 180s for initial attempt
SECOND_ATTEMPT_TIMEOUT = float(os.environ.get("SECOND_ATTEMPT_TIMEOUT", 150)) # Increased to 150s for retry
THIRD_ATTEMPT_TIMEOUT = float(os.environ.get("THIRD_ATTEMPT_TIMEOUT", 120)) # Increased to 120s for final attempt
DEFAULT_LLM_REQUEST_TIMEOUT = float(os.environ.get("LLM_REQUEST_TIMEOUT", 150))
# Micro-verification timeouts
MICRO_VERIFY_MCP_TIMEOUT = float(os.environ.get("MICRO_VERIFY_MCP_TIMEOUT", 30))
MICRO_VERIFY_LLM_TIMEOUT = float(os.environ.get("MICRO_VERIFY_LLM_TIMEOUT", 60))
# Debug: Print timeout values to verify they're correct
print(f"[TIMEOUT_DEBUG] FIRST_ATTEMPT_TIMEOUT={FIRST_ATTEMPT_TIMEOUT}s")
print(f"[TIMEOUT_DEBUG] SECOND_ATTEMPT_TIMEOUT={SECOND_ATTEMPT_TIMEOUT}s")
print(f"[TIMEOUT_DEBUG] THIRD_ATTEMPT_TIMEOUT={THIRD_ATTEMPT_TIMEOUT}s")
STABLE_REUSE_LLM = True # don't re-init on pure timeout, only on MemoryError
# Absolute safety thresholds to avoid system instability / BSOD risk
ABS_MIN_FREE_MB = float(os.environ.get("LLM_ABSOLUTE_MIN_FREE_MB", "200")) # Lowered for GPU mode
SAFE_POST_QUERY_TARGET_MB = float(os.environ.get("LLM_SAFE_POST_QUERY_TARGET_MB", "300")) # Lowered for GPU mode
# Placeholder tokens that should never appear in final executable SQL
PLACEHOLDER_TOKENS = {
"your title", "some title", "sample title", "your table", "table_name",
"column_name", "some_column", "placeholder", "value_here"
}
PLACEHOLDER_REGEXES = [
r"your_[a-z0-9_]*", # generic your_ placeholder tokens
]
# (Duplicate EMPLOYEE_DB_SCHEMA removed to prevent overwriting)
# Global readiness flag so we can re-attempt handshake before first real generation
LLM_HANDSHAKE_COMPLETE = False
LAST_GENERATED_SQL = None # updated each time a final SQL statement is produced
LAST_MCP_HINT_SQL = None # last validated fast-SQL hint (deterministic fallback path)
# Cached real department names from DB — populated during initialize_database
# Fallback: known employee-DB departments (used if DB query fails)
_cached_dept_names: set = {
'Customer Service', 'Development', 'Finance', 'Human Resources',
'Marketing', 'Production', 'Quality Management', 'Research', 'Sales'
}
# Function to initialize LLM with better memory management
_HF_QUOTA_DOWN = False # sticky flag: set once HF Inference is confirmed out of credits
def build_cloud_llm():
"""Construct the HF Inference API LLM (default: Qwen/Qwen2.5-Coder-7B-Instruct).
This is the same path used on Hugging Face Spaces. It is also used as an
automatic fallback inside initialize_llm() when the configured local
Ollama model is not installed.
Credits-resilience: on HF Inference 402/401/429 (credits exhausted, expired
token, rate limit) the same prompt is re-issued to Groq's OpenAI-compatible
endpoint (GROQ_API_KEY is already a Space secret). The Groq model is
auto-discovered from Groq's live /models list; override with GROQ_LLM_MODEL."""
hf_token = os.environ.get("HF_API_TOKEN")
hf_model = os.environ.get("HF_LLM_MODEL", "Qwen/Qwen2.5-Coder-7B-Instruct")
if not hf_token:
raise RuntimeError("HF_API_TOKEN is not set; cannot use the HF Inference API")
if InferenceClient is None:
raise RuntimeError("huggingface_hub is not installed; cannot use the HF Inference API")
print(f"[INFO] CLOUD MODE: Initializing Official HF InferenceClient ({hf_model})")
class HFClientWrapper(CustomLLM):
# Use a dummy model name that LlamaIndex accepts
model_name: str = hf_model
@property
def metadata(self) -> LLMMetadata:
return LLMMetadata(
context_window=4096,
num_output=1024,
model_name=self.model_name,
)
def complete(self, prompt: str, **kwargs) -> CompletionResponse:
# Provider routing: Qwen via HF Inference is primary. Once HF has
# been confirmed out of credits/rate-limited this process, go
# straight to Groq (saves one dead 402 round-trip per level).
global _HF_QUOTA_DOWN
provider = "hf"
if _HF_QUOTA_DOWN:
provider = "groq"
if provider == "hf":
hf_client = InferenceClient(model=self.model_name, token=hf_token)
try:
resp = hf_client.chat_completion(
messages=[{"role": "user", "content": prompt}],
max_tokens=1024
)
return CompletionResponse(text=resp.choices[0].message.content)
except Exception as hf_exc:
if _is_provider_quota_error(hf_exc):
_HF_QUOTA_DOWN = True
print(f"[LLM-FALLBACK] HF Inference unavailable "
f"({hf_exc.__class__.__name__}); falling back to Groq "
f"(sticky for this process)", flush=True)
else:
raise hf_exc
try:
groq_text = _groq_text_completion(prompt)
return CompletionResponse(text=groq_text)
except Exception as groq_exc:
print(f"[LLM-FALLBACK] Groq fallback failed: {groq_exc}", flush=True)
raise
def stream_complete(self, prompt: str, **kwargs):
# Not needed for our agent, but required by base class
raise NotImplementedError()
def chat(self, messages, **kwargs):
# Convert ChatMessage list to string prompt for stability
prompt = ""
for m in messages:
role = getattr(m, 'role', 'user')
content = getattr(m, 'content', str(m))
prompt += f"{role}: {content}\n"
return self.complete(prompt)
llm = HFClientWrapper()
# Register it in Settings so downstream tools can use it
global LLM_HANDSHAKE_COMPLETE
Settings.llm = llm
LLM_HANDSHAKE_COMPLETE = True
return llm
def _is_provider_quota_error(exc: Exception) -> bool:
"""Detect HF Inference failures that a Groq fallback can plausibly fix:
out of credits (402), expired/missing token (401) and rate limits (429)."""
status = getattr(exc, "status_code", None)
if status is None:
response = getattr(exc, "response", None)
status = getattr(response, "status_code", None)
if status in (401, 402, 429):
return True
msg = str(exc)
return any(sig in msg for sig in (
"402", "Payment Required", "no remaining credits",
"401", "Unauthorized", "429", "rate limit",
))
GROQ_TEXT_MODEL = os.environ.get("GROQ_LLM_MODEL", "") # optional override; empty = auto-discover
_GROQ_MODEL_CACHE = None # cached list of chat-capable Groq model ids
# Static candidates used when /models discovery is unavailable (e.g. key issue).
# Ordered: cheapest/longest-lived first; anything retired is skipped automatically.
_GROQ_CANDIDATE_FALLBACKS = [
"llama-3.1-8b-instant",
"llama-3.3-70b-versatile",
"openai/gpt-oss-20b",
"qwen/qwen3-32b",
]
def _groq_chat_models() -> list:
"""Live model list from Groq, filtered to chat-capable ids (no ASR/TTS/guard)."""
global _GROQ_MODEL_CACHE
if _GROQ_MODEL_CACHE is not None:
return _GROQ_MODEL_CACHE
groq_key = os.environ.get("GROQ_API_KEY")
text_models: list = []
if groq_key:
try:
r = requests.get(
"https://api.groq.com/openai/v1/models",
headers={"Authorization": f"Bearer {groq_key}"},
timeout=15,
)
r.raise_for_status()
ids = [m.get("id") for m in r.json().get("data", []) if m.get("id")]
# Non-chat families: ASR, TTS (incl. Orpheus/CanopyLabs voices,
# whose ids carry no "tts" token), guard models, embeddings.
exclude_tokens = ("whisper", "tts", "playai", "guard", "orpheus",
"canopylabs", "asr", "embed")
text_models = [
i for i in ids
if not any(tok in i.lower() for tok in exclude_tokens)
]
except Exception as models_err:
print(f"[LLM-FALLBACK] Groq /models listing failed: {models_err}", flush=True)
_GROQ_MODEL_CACHE = text_models
return text_models
def _groq_text_completion(prompt: str, max_tokens: int = 1024, timeout: float = 90.0) -> str:
"""Chat completion against Groq's OpenAI-compatible endpoint.
Model resolution: GROQ_LLM_MODEL override -> live /models discovery ->
static candidates. Retired model ids (404/400) are skipped automatically,
so model deprecations on Groq's side never break the fallback."""
groq_key = os.environ.get("GROQ_API_KEY")
if not groq_key:
raise RuntimeError("GROQ_API_KEY is not set; Groq text-LLM fallback unavailable")
candidates: list = []
if GROQ_TEXT_MODEL:
candidates.append(GROQ_TEXT_MODEL)
candidates.extend(_groq_chat_models())
# ALWAYS merge the static known-good ids: the live /models listing can
# return non-chat families (filtered) or be temporarily empty — the static
# tail guarantees resumable candidates. Duplicates are removed below.
candidates.extend(_GROQ_CANDIDATE_FALLBACKS)
# Prefer Qwen-family ids (closest match to the dormant HF primary,
# Qwen/Qwen2.5-Coder) while keeping the explicit override untouched.
ordered = candidates[:1] # explicit override, if set, stays first
ordered += sorted(
candidates[1:],
key=lambda m: 0 if "qwen" in m.lower() else 1, # stable sort: same bucket keeps live-list order
)
seen: set = set()
candidates = [c for c in ordered if not (c in seen or seen.add(c))][:8]
if not candidates:
raise RuntimeError("No Groq text models available (check GROQ_API_KEY)")
last_err: Exception | None = None
for model in candidates:
try:
r = requests.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {groq_key}"},
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
},
timeout=timeout,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except requests.HTTPError as http_err:
status = getattr(getattr(http_err, "response", None), "status_code", None)
if status in (404, 400):
# Model id retired/invalid on Groq's side -> try the next one
print(f"[LLM-FALLBACK] Groq model '{model}' unavailable ({status}); trying next candidate", flush=True)
_GROQ_MODEL_CACHE = None # invalidate cache; refresh on next call
last_err = http_err
continue
last_err = http_err
break # auth/quota/network errors: retrying other models won't help
raise RuntimeError(f"All Groq candidates failed: {last_err}")
def _ollama_model_available(model_name: str, base_url: str) -> bool:
"""Return True if the given model tag exists on the Ollama server.
A failed probe (server down, network error) conservatively returns True so
legacy behavior (handshake + retry warnings) is preserved.
"""
try:
r = requests.get(f"{(base_url or '').rstrip('/')}/api/tags", timeout=3)
r.raise_for_status()
installed = set()
for m in r.json().get("models", []):
nm = (m.get("name") or "").strip()
if nm:
installed.add(nm)
installed.add(nm.split(":")[0])
return model_name in installed or model_name.split(":")[0] in installed
except Exception:
return True
def initialize_llm(progressive_reduction=0):
"""
Initialize the LLM model with Ollama with proper memory management.
Args:
progressive_reduction: Level of resource reduction (0=normal, 1=reduced, 2=minimal)
Returns:
An initialized Ollama LLM instance
"""
# Make sure we have a clean slate
gc.collect()
# Get current memory stats
global LLM_HANDSHAKE_COMPLETE
stats = memory_manager.get_memory_stats()
available_mb = stats["available_mb"]
# CLOUD MODE: If USE_HF_CLOUD is enabled, skip local GPU/memory checks
if os.environ.get("USE_HF_CLOUD", "0") in ("1", "true", "True"):
if not os.environ.get("HF_API_TOKEN"):
print("[WARN] HF_API_TOKEN not found! Falling back to local Ollama even though USE_HF_CLOUD=1")
elif InferenceClient is None:
print("[WARN] huggingface_hub not installed! Falling back to local Ollama.")
else:
return build_cloud_llm()
# Hard safety abort first
if available_mb < ABS_MIN_FREE_MB:
raise MemoryError(f"Refusing to initialize LLM: free {available_mb:.1f}MB < ABS_MIN_FREE_MB {ABS_MIN_FREE_MB}MB (system safety)")
# Automatically determine if we need to increase resource reduction based on memory
if available_mb < memory_manager.critical_mb * 1.5:
# Force at least level 2 (minimal) if memory is near critical
progressive_reduction = max(progressive_reduction, 2)
print(f"Memory critically low ({available_mb:.2f}MB): Forcing minimal resource mode")
elif available_mb < memory_manager.threshold_mb:
# Force at least level 1 (reduced) if memory is below threshold
progressive_reduction = max(progressive_reduction, 1)
print(f"Memory low ({available_mb:.2f}MB): Using reduced resource mode")
# Determine resource usage level based on available memory and progressive reduction
# Allow environment overrides for mode thresholds
try:
env_ultra = float(os.environ.get("LLM_ULTRA_MB", "800"))
env_min = float(os.environ.get("LLM_MIN_MB", "600"))
except Exception:
env_ultra, env_min = 400.0, 200.0
ultra_low_memory_mode = available_mb < env_ultra or progressive_reduction >= 1
minimal_mode = available_mb < env_min or progressive_reduction >= 2
# Adjust parameters based on available memory
# Allow explicit overrides so user can test larger context to reduce repeated token delays
ctx_override = os.environ.get("LLM_CTX" )
thread_override = os.environ.get("LLM_THREADS")
try:
# Set absolute floor at 512 to prevent "forgetting" the schema hints
ctx_size = int(ctx_override) if ctx_override else (512 if minimal_mode else 768 if ultra_low_memory_mode else 1024)
except Exception:
ctx_size = 512
try:
num_thread = int(thread_override) if thread_override else 1
except Exception:
num_thread = 1
timeout = 12.0 if minimal_mode else 18.0 if ultra_low_memory_mode else 25.0
# Log mode based on memory constraints
mode_desc = "minimal" if minimal_mode else "ultra-low" if ultra_low_memory_mode else "low"
print(f"Initializing LLM with ctx_size={ctx_size}, num_thread={num_thread}, timeout={timeout:.1f}s in {mode_desc} memory mode")
# Use smaller model if in minimal mode and memory is critically low
# Permit explicit model override via env for experimentation (e.g. smaller model to prevent timeouts)
model_name = os.environ.get("LLM_MODEL_OVERRIDE") or (
FALLBACK_MODEL_NAME if minimal_mode and available_mb < memory_manager.critical_mb * 1.2 else OLLAMA_MODEL_NAME
)
# Local Ollama model missing? Auto-fallback to the HF Inference API instead
# of burning handshake budget against a 404. This mirrors the HF Spaces
# configuration (Qwen/Qwen2.5-Coder-7B-Instruct) on a local machine.
if not _ollama_model_available(model_name, OLLAMA_BASE_URL):
print(f"[LLM_INIT] Ollama model '{model_name}' not found on {OLLAMA_BASE_URL}")
if os.environ.get("HF_API_TOKEN") and InferenceClient is not None:
print(f"[LLM_INIT] Auto-falling back to HF Inference API "
f"({os.environ.get('HF_LLM_MODEL', 'Qwen/Qwen2.5-Coder-7B-Instruct')})")
try:
return build_cloud_llm()
except Exception as cloud_err:
print(f"[LLM_INIT] Cloud fallback failed: {cloud_err} — continuing with local Ollama anyway")
else:
print("[LLM_INIT] No HF_API_TOKEN set; cannot auto-fallback to cloud. "
"Set USE_HF_CLOUD=1 + HF_API_TOKEN in Agent/.env, or pull the model with 'ollama pull'.")
# Create the LLM instance with optimized settings but do NOT register yet.
# We perform a lightweight handshake first and then register only if memory allows.
llm = None
try:
llm = Ollama(
model=model_name,
base_url=OLLAMA_BASE_URL,
temperature=0.05, # Restored from 7.7
request_timeout=DEFAULT_LLM_REQUEST_TIMEOUT,
additional_kwargs={
# Optimized parameters for memory efficiency
"num_ctx": ctx_size,
"num_batch": 1, # Restored from 7.7
"num_gpu": -1, # Keep GPU but with stable batching
"f16_kv": True, # Half-precision for key/value cache
"mirostat": 0, # Disable mirostat sampling
"num_thread": num_thread,
"seed": 42 # Consistent seed
}
)
# Handshake strategy: if strict mode enabled, we block until success (within max timeout budget);
# otherwise we warn and continue, with a later retry on first real query.
strict_mode = os.environ.get("LLM_HANDSHAKE_STRICT", "0") not in ("0", "false", "False")
base_timeout = float(os.environ.get("LLM_HANDSHAKE_TIMEOUT", "30")) # single-attempt timeout
max_total = float(os.environ.get("LLM_HANDSHAKE_MAX_TOTAL", "120")) # total budget across attempts
handshake_prompts = ["SELECT 1;", "-- warmup\nSELECT 1;"]
attempt = 0
start_all = time.time()
handshake_ok = False
while not handshake_ok and (time.time() - start_all) < max_total and attempt < len(handshake_prompts):
hp = handshake_prompts[attempt]
attempt += 1
remaining_budget = max_total - (time.time() - start_all)
this_timeout = min(base_timeout, remaining_budget)
print(f"[HS] Handshake attempt {attempt} timeout={this_timeout:.1f}s remaining_budget={remaining_budget:.1f}s")
try:
# Use direct call bounded by TimeoutManager to avoid internal indefinite waits.
resp = TimeoutManager.run_with_timeout(lambda: llm.complete(hp), timeout=this_timeout)
# Some wrappers return object with .text, others raw string
_txt = getattr(resp, 'text', None)
if resp is not None and (_txt is None or 'error' not in str(_txt).lower()):
elapsed_all = time.time() - start_all
print(f"[HS] LLM handshake succeeded in {elapsed_all:.2f}s (model={model_name})")
handshake_ok = True
break
except TimeoutError:
print(f"[HS][WARN] Handshake attempt {attempt} timed out after {this_timeout:.1f}s")
except Exception as e_hs:
print(f"[HS][WARN] Handshake attempt {attempt} error: {e_hs}")
# If model just started loading, give it a short pause before next attempt
time.sleep(1.5)
if not handshake_ok:
msg = "[HS][WARN] Handshake not successful within budget; will retry on first real query." if not strict_mode else "[HS][ERROR] Strict handshake mode enabled and handshake failed."
print(msg)
if strict_mode:
# In strict mode, raise to stop startup (user explicitly wants guaranteed readiness)
raise TimeoutError("Strict LLM handshake failed")
else:
# Mark readiness
LLM_HANDSHAKE_COMPLETE = True
# Check memory after initialization/handshake to ensure we didn't consume too much
after_stats = memory_manager.get_memory_stats()
memory_used = available_mb - after_stats.get("available_mb", 0)
if memory_used > 100: # If we used more than 100MB just for initialization
print(f"⚠️ Warning: LLM initialization consumed {memory_used:.2f}MB")
# Post-init safety check
if after_stats.get('available_mb', 0) < ABS_MIN_FREE_MB:
print(f"[SAFETY] Post-init free memory {after_stats.get('available_mb',0):.1f}MB < ABS_MIN_FREE_MB {ABS_MIN_FREE_MB}MB; tearing down instance")
try:
if hasattr(llm, 'close'): llm.close()
except Exception:
pass
raise MemoryError("Post-init memory below absolute floor; aborted")
# Attempt to register the instance with the memory manager now that it's healthy.
try:
memory_manager.register_llm_instance(llm)
except MemoryError as me:
# Registration refused due to low memory; leave the llm unregistered but return it
print(f"MemoryManager refused to register LLM: {me}")
except Exception as reg_err:
# Non-fatal: log and continue returning the llm (unregistered)
print(f"Warning: unexpected error registering LLM: {reg_err}")
return llm
except Exception:
# Ensure we free any partial resources on failure
try:
if llm is not None:
try:
memory_manager.unregister_llm_instance(llm)
except Exception:
pass
finally:
# Best-effort GC and working set trim
gc.collect()
try:
memory_manager.force_collect_garbage()
except Exception:
pass
raise
# Create a function to run LLM safely with proper timeouts and cleanup
def run_llm_with_timeout(llm, prompt, timeout=30.0, retries=1):
"""
Run an LLM with proper timeout handling and resource cleanup.
Args:
llm: The LLM instance to use
prompt: The prompt to send to the LLM
timeout: Timeout in seconds
retries: Number of retries on timeout or memory error
Returns:
The LLM response
Raises:
TimeoutError: If all attempts time out
MemoryError: If memory is insufficient after retries
"""
last_error = None
for attempt in range(retries + 1):
print(f"[LLM][ATTEMPT] Attempt {attempt+1}/{retries+1} for prompt (len={len(prompt)})")
# Check memory before attempt and log status
stats = memory_manager.get_memory_stats()
if stats['available_mb'] < ABS_MIN_FREE_MB:
print(f"[LLM][MEMORY] Insufficient memory before attempt: {stats['available_mb']:.1f}MB < ABS_MIN_FREE_MB {ABS_MIN_FREE_MB}MB")
raise MemoryError(f"Available memory {stats['available_mb']:.1f}MB < ABS_MIN_FREE_MB {ABS_MIN_FREE_MB}MB; aborting generation")
print(f"Memory check before attempt {attempt+1}: {stats['available_mb']:.2f}MB available (ABS_MIN_FREE_MB={ABS_MIN_FREE_MB}MB)")
if stats['available_mb'] < memory_manager.threshold_mb:
# Try with more aggressive memory reduction
reduction_level = attempt + 1
print(f"[LLM][MEMORY] Memory low, creating LLM with reduction level {reduction_level}")
# Release the current LLM instance if it exists
if 'llm' in locals() and llm is not None:
try:
memory_manager.unregister_llm_instance(llm)
llm = None
except Exception as e:
print(f"Error unregistering LLM: {str(e)}")
# Force garbage collection
gc.collect()
memory_manager.force_collect_garbage()
# Check if memory is critically low
stats = memory_manager.get_memory_stats()
if stats['available_mb'] < memory_manager.critical_mb:
print(f"🚨 CRITICAL MEMORY CONDITION: Only {stats['available_mb']:.2f}MB available!")
raise MemoryError(f"System memory critically low: {stats['available_mb']:.2f}MB available")
# Create a new LLM with more aggressive resource reduction
llm = initialize_llm(progressive_reduction=reduction_level)
# Register with memory manager
memory_manager.register_llm_instance(llm)
try:
# Run the LLM with timeout using ThreadManager for safety
print(f"[LLM][RUN] Running LLM (attempt {attempt+1}/{retries+1}) with {timeout:.1f}s timeout")
# Start timing
start_time = time.time()
# First, try talking to an external MCP server if available. This centralizes
# model & tool orchestration. If MCP is unreachable, fall back to local LLM.
# Prefer local LLM first (preserves pre-MCP behaviour). If local LLM fails or times out,
# attempt to use MCP as a fallback so an unavailable MCP won't break everything.
with memory_manager.suspend_emergency("llm_generation"):
result = None # Initialize result to avoid UnboundLocalError
try:
print("Attempting local LLM completion first...")
# Wrap the LLM call to ensure we pass prompt correctly and bound it with our TimeoutManager
result = TimeoutManager.run_with_timeout(lambda: llm.complete(prompt), timeout=timeout)
except TimeoutError as local_to:
print(f"Local LLM completion timed out after {timeout}s: {local_to}; attempting MCP fallback...")
try:
from .mcp_client import mcp_complete
mcp_text = mcp_complete(prompt, timeout=min( max(5.0, timeout*0.5 ), timeout ))
class _Resp:
def __init__(self, text):
self.text = text
result = _Resp(mcp_text)
print(f"MCP fallback succeeded after local LLM timeout")
except Exception as mcp_err:
print(f"MCP completion failed or unavailable: {mcp_err}")
# If MCP is clearly unreachable (connection refused), don't treat as critical
if "connection refused" in str(mcp_err).lower() or "max retries exceeded" in str(mcp_err).lower():
print("[MCP] Server appears to be down - continuing without MCP fallback")
# Re-raise the original timeout error for retry logic
raise local_to
except Exception as local_err:
print(f"Local LLM completion failed: {local_err}; attempting MCP fallback...")
try:
from .mcp_client import mcp_complete
mcp_text = mcp_complete(prompt, timeout=min( max(5.0, timeout*0.5 ), timeout ))
class _Resp:
def __init__(self, text):
self.text = text
result = _Resp(mcp_text)
print(f"MCP fallback succeeded after local LLM failure")
except Exception as mcp_err:
print(f"MCP completion failed or unavailable: {mcp_err}")
# If MCP is clearly unreachable (connection refused), don't treat as critical
if "connection refused" in str(mcp_err).lower() or "max retries exceeded" in str(mcp_err).lower():
print("[MCP] Server appears to be down - continuing without MCP fallback")
# For timeout errors, try a longer timeout retry instead of immediate failure
if isinstance(local_err, TimeoutError) and attempt < retries:
print(f"[RETRY] Will retry with longer timeout due to initial timeout")
# Don't re-raise immediately, let the retry logic handle it
raise local_err
else:
# Re-raise original local error to be handled by outer retry logic
raise local_err
# Log completion time
elapsed = time.time() - start_time
print(f"LLM completed in {elapsed:.2f}s")
# Check memory after completion
stats = memory_manager.get_memory_stats()
print(f"Memory after LLM: {stats['available_mb']:.2f}MB available")
# If after execution we are below safety target, proactively free (soft protection)
if stats['available_mb'] < SAFE_POST_QUERY_TARGET_MB:
try:
print(f"[SAFETY] Free memory {stats['available_mb']:.1f}MB < SAFE_POST_QUERY_TARGET_MB {SAFE_POST_QUERY_TARGET_MB}MB; releasing LLM to protect system")
memory_manager.unregister_llm_instance(llm)
except Exception:
pass
memory_manager.force_collect_garbage()
# Force cleanup to prevent memory leaks
memory_manager.force_collect_garbage()
return result
except (TimeoutError, MemoryError) as e:
last_error = e
print(f"[LLM][FAIL] Attempt {attempt+1} failed: {type(e).__name__}: {str(e)}")
if isinstance(e, TimeoutError):
print(f"[LLM][TIMEOUT] Timeout occurred for prompt (len={len(prompt)}). Consider simplifying the query or increasing system resources.")
# Unregister LLM from memory manager
try:
memory_manager.unregister_llm_instance(llm)
except Exception as unreg_error:
print(f"Error unregistering LLM: {str(unreg_error)}")
# Aggressive cleanup after error
for _ in range(3): # Multiple cleanup attempts
gc.collect()
time.sleep(0.1) # Brief pause to allow OS to reclaim memory
memory_manager.force_collect_garbage()
if attempt < retries:
# INCREASE timeout for subsequent attempts to handle initialization overhead
timeout = min(180.0, timeout * 1.5) # Increase by 50% each retry, cap at 3 minutes
print(f"[LLM][RETRY] Retrying with increased timeout={timeout:.1f}s (attempt {attempt+2})")
# Escalate LLM reduction after 2 timeouts
if isinstance(e, TimeoutError) and attempt >= 1:
print(f"[LLM][ESCALATE] Escalating to more aggressive LLM reduction after repeated timeouts.")
try:
llm = initialize_llm(progressive_reduction=attempt+2)
memory_manager.register_llm_instance(llm)
except Exception as esc_err:
print(f"[LLM][ESCALATE][FAIL] Could not escalate LLM: {esc_err}")
else:
# Last attempt failed
print("[LLM][FAIL] All LLM attempts failed for this prompt.")
print(f"[LLM][USER] The LLM failed to respond after multiple attempts. Please try a simpler question, check your system resources, or increase the timeout settings if possible.")
# Final cleanup before raising error
memory_manager.force_collect_garbage()
raise last_error
except Exception as e:
# For other exceptions, cleanup and don't retry
print(f"LLM error: {type(e).__name__}: {str(e)}")
# Unregister LLM from memory manager
try:
memory_manager.unregister_llm_instance(llm)
except Exception as unreg_error:
print(f"Error unregistering LLM: {str(unreg_error)}")
# Cleanup
memory_manager.force_collect_garbage()
raise
# Initialize LLM — guard against double-import (Agent.agentic_workflow vs agentic_workflow)
# Both module paths share the same Ollama process, so re-running initialize_llm() causes
# redundant handshakes and wastes ~440MB of KV-cache allocation each time.
_LLM_INIT_SENTINEL = "Agent.__agentic_workflow_llm_initialized__"
import sys as _sys
if _sys.modules.get(_LLM_INIT_SENTINEL) is None:
_sys.modules[_LLM_INIT_SENTINEL] = True # mark as done before call (prevents re-entry)
llm = initialize_llm()
Settings.llm = llm
else:
# Module already initialized in this process — reuse the existing LLM instance
print("[LLM_INIT] Skipping duplicate initialize_llm() — module already initialized in this process")
try:
# Settings.llm should already be set; fall back to a fresh init only if truly missing
if getattr(Settings, 'llm', None) is None:
llm = initialize_llm()
Settings.llm = llm
else:
llm = Settings.llm
except Exception:
llm = initialize_llm()
Settings.llm = llm
def get_salary_column_name() -> str:
"""Helper to determine if the salary column is named 'salary' or 'amount'."""
global inspector, available_tables
if not inspector or 'salary' not in available_tables:
return 'salary' # default fallback
try:
cols = [c['name'].lower() for c in inspector.get_columns(table_name='salary')]
if 'salary' in cols:
return 'salary'
if 'amount' in cols:
return 'amount'
return cols[0] if cols else 'salary'
except Exception:
return 'salary'
async def initialize_database(uri: Optional[str] = None, schema_notes: Optional[str] = None) -> Dict[str, Any]:
"""Initialize the database connection and return metadata.
schema_notes: owner-supplied domain hints to attach to the active schema
profile. None = leave existing notes untouched.
"""
global available_tables, inspector, sql_database, async_engine, async_session, _DB_CONNECTED
metadata = {
"uri": None,
"tables": [],
"sql_database": None,
"inspector": None
}
try:
# Use provided URI or fallback to global environment-loaded variable
effective_uri = uri or DB_CONNECTION_URL
if not effective_uri:
print("[DB-INIT] No connection URI provided; skipping database initialization.")
return metadata
metadata["uri"] = effective_uri
# ---- Database-agnostic engine wiring (Agent/db_uri.py) -------------
# Maps bare schemes (mysql://, mssql://, oracle://, ...) onto the
# drivers this project ships, for both the sync and the async layer.
norm = normalize_db_uris(effective_uri)
family = norm["family"]
sync_uri = norm["sync_uri"]
async_uri = norm["async_uri"]
print(f"[DB-INIT] dialect family: {family or 'unknown'} | sync URI -> {sync_uri.split('@')[-1] if '@' in sync_uri else sync_uri}")
is_cloud_db = 'neon.tech' in effective_uri or 'sslmode=' in effective_uri
_is_postgres = family == "postgresql"
# Create sync engine for inspector / row stats / schema profile
engine_kwargs = {}
if _is_postgres and is_cloud_db:
# Neon-style managed Postgres: ensure TLS on the sync connection
print("[DB-INIT] Cloud database detected (Neon/SSL). Enabling SSL support.")
if 'sslmode=' not in sync_uri:
sep = '&' if '?' in sync_uri else '?'
sync_uri += f"{sep}sslmode=require"
metadata["sql_database"] = create_engine(sync_uri, **engine_kwargs)
sql_database = metadata["sql_database"]
# Create async engine for query execution (when a driver exists).
# mssql / oracle stay sync-only: SELECTs run through the sync engine.
# NOTE: async_engine / async_session are reset here so a profile switch
# away from an async-capable database cannot leak a stale session.
async_engine = None
async_session = None
if async_uri:
if _is_postgres:
# Strip sslmode from the URL query parameters for asyncpg
# compatibility; TLS is passed via connect_args instead.
if '?' in async_uri:
base_part, query_part = async_uri.split('?', 1)
new_params = [p for p in query_part.split('&') if not p.lower().startswith('sslmode=')]
async_uri = base_part + ('?' + '&'.join(new_params) if new_params else '')
try:
# For asyncpg, we must pass SSL requirements via connect_args
async_kwargs = {}
if _is_postgres and is_cloud_db:
async_kwargs["connect_args"] = {"ssl": "require"}
async_engine = create_async_engine(async_uri, **async_kwargs)
async_session = sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
)
except Exception as _async_err:
print(
f"[DB-INIT] async engine unavailable for '{family}' "
f"({_async_err.__class__.__name__}: {_async_err}) — "
"SELECTs will run through the sync engine."
)
async_engine = None
async_session = None
if async_session is None:
print(
f"[DB-INIT] no async SQLAlchemy driver for '{family}'; "
"query execution uses the sync engine in a worker thread."
)
metadata["async_ready"] = async_session is not None
# Create inspector
metadata["inspector"] = inspect(metadata["sql_database"])
inspector = metadata["inspector"]
# Get available tables
available_tables = metadata["inspector"].get_table_names() + metadata["inspector"].get_view_names()
# Populate active schema profile (after inspector available)
try:
ACTIVE_SCHEMA_PROFILE.populate_from_inspector(inspector, sql_database)
tblset = ACTIVE_SCHEMA_PROFILE.available_tables_set
if all(x in tblset for x in ["employee", "department", "dept_emp", "dept_manager", "salary", "title"]):
ACTIVE_SCHEMA_PROFILE.is_demo = True
ACTIVE_SCHEMA_PROFILE.name = "demo_employee"
else:
ACTIVE_SCHEMA_PROFILE.is_demo = False
ACTIVE_SCHEMA_PROFILE.name = "user_schema"
ACTIVE_SCHEMA_PROFILE.db_url = effective_uri
print(f"[DB-INIT] schema profile '{ACTIVE_SCHEMA_PROFILE.name}' "
f"({len(ACTIVE_SCHEMA_PROFILE.tables)} tables) ready")
except Exception as _e:
print(f"[DB-INIT] schema profile population warning: {_e}")
# Initialize tables
for table in available_tables:
metadata_obj = MetaData()
try:
table_obj = Table(table, metadata_obj, autoload_with=metadata["sql_database"])
metadata["tables"].append(table_obj)
print(f"Initialized table: {table}")
except NoSuchTableError as e:
print(f'"{e}" table not found!')
continue
# Attach owner-supplied schema notes (domain hints), if provided.
# None means "no change" so generic re-inits never wipe stored notes.
if schema_notes is not None:
ACTIVE_SCHEMA_PROFILE.notes = (schema_notes or "").strip()
print(f"[DB-INIT] schema notes attached ({len(ACTIVE_SCHEMA_PROFILE.notes)} chars)")
_DB_CONNECTED = True
return metadata
except Exception as e:
print(f"Error initializing database: {str(e)}")
_DB_CONNECTED = False
return metadata
def list_tables() -> str:
"""Returns a list of available tables in the database."""
global available_tables, inspector
try:
table_list = []
for table_name in available_tables:
table_info = inspector.get_table_comment(table_name=table_name).get('text', '')
table_info = f'Table Description: {table_info}\n' if table_info else ''
table_list.append(f'Table Name: {table_name}\n{table_info}')
result = '\n'.join(table_list)
return result
except Exception as e:
return f"Error listing tables: {str(e)}"
def describe_tables(tables: Optional[List[str]] = None) -> str:
"""Describes the specified tables in the database."""
global available_tables, inspector, sql_database
try:
table_names = tables or available_tables
table_schemas = []
for table_name in table_names:
try:
# Create table object
metadata_obj = MetaData()
table = Table(table_name, metadata_obj, autoload_with=sql_database)
# Get schema
schema = f"{table_name} columns:\n"
# Get columns
columns = inspector.get_columns(table_name=table_name)
for column in columns:
col_name = column["name"]
col_type = str(column["type"])
schema += f"- {col_name}: {col_type}\n"
table_schemas.append(f"{schema}")
except NoSuchTableError as e:
print(f"Table '{table_name}' not found: {str(e)}")
continue
if not table_schemas:
return "No valid tables found to describe."
result = "\n".join(table_schemas)
return result
except Exception as e:
return f"Error describing tables: {str(e)}"
async def _fetch_rows_via_active_engine(sql: str):
"""Execute read-only SQL through whichever engine layer is active.
Every SQLAlchemy-async-capable family driving this project (postgresql /
mysql / sqlite) goes through the async session; databases without an
async dialect (mssql via pymssql, oracle via oracledb) run through the
sync engine inside a worker thread so the event loop stays responsive.
Returns (rows, column_names) — raw SQLAlchemy Row objects + column keys.
"""
if async_session is not None:
async with async_session() as session:
result = await session.execute(text(sql))
rows = result.fetchall()
cols = result.keys()
else:
if sql_database is None:
raise RuntimeError("No database engine is connected")
def _exec_sync():
with sql_database.connect() as _conn:
_res = _conn.execute(text(sql))
_rows = _res.fetchall()
_cols = list(_res.keys())
return _rows, _cols
rows, cols = await asyncio.to_thread(_exec_sync)
return rows, cols
async def execute_query(query: str, skip_invariants: bool = False) -> List[Dict[str, Any]]:
"""Execute an SQL query and return the results as a list of dictionaries."""
global async_session
def sanitize_sql(raw: str) -> str:
"""Extract clean SQL, remove wrappers, and conservatively extract the first SQL statement (SELECT/WITH)."""
import re
s = extract_clean_sql(raw)
if not s or not isinstance(s, str):
return s
s = s.strip()
m = re.search(r"\b(select|with)\b", s, flags=re.IGNORECASE)
if not m:
return s
candidate = s[m.start():]
lines = candidate.splitlines()
kept = []
for ln in lines:
stripped = ln.strip()
if not stripped:
kept.append(ln)
continue
# Stop at bullet/annotation lines or obvious assistant notes
if re.match(r'^[-]{1,}', stripped):
break
if re.match(r'^(User Question:|Previous Attempt:|Use correct|Do NOT|Error executing|Result Presenter Agent)', stripped, flags=re.IGNORECASE):
break
kept.append(ln)
cleaned = '\n'.join(kept).strip()
# If the cleaned SQL looks like a bare SELECT of columns (no FROM), try to recover a FROM clause
if cleaned and not re.search(r"\bfrom\b", cleaned, flags=re.IGNORECASE):
fm = re.search(r"\bfrom\b[\s\S]*?(;|$)", candidate, flags=re.IGNORECASE)
if fm:
from_clause = fm.group(0).strip()
if re.search(r"^\s*select\b", from_clause, flags=re.IGNORECASE):
subfm = re.search(r"\bfrom\b[\s\S]*?(;|$)", from_clause, flags=re.IGNORECASE)
if subfm:
from_clause = subfm.group(0).strip()
cleaned = cleaned.rstrip(';').strip() + '\n' + from_clause
else:
return ""
if cleaned and not cleaned.rstrip().endswith(';'):
cleaned = cleaned + ';'
return cleaned
try:
sanitized = sanitize_sql(query)
if sanitized != query:
print("Notice: sanitizing SQL before execution (removed LLM commentary)")
print(f"Sanitized SQL: {sanitized}")
print(f"Executing query: {sanitized}")
# Ensure we actually have a SELECT/WITH to run
if not sanitized or not re.search(r"\b(select|with)\b", sanitized, flags=re.IGNORECASE):
return [{"error": "No valid SQL found after sanitization"}]
# Be conservative: SELECT statements that lack a FROM clause are likely
# partial outputs from the LLM (e.g. column list only). Reject these
# unless they are a simple literal/select expression (e.g. SELECT 1;)
if re.search(r'^\s*select\b', sanitized, flags=re.IGNORECASE) and not re.search(r"\bfrom\b", sanitized, flags=re.IGNORECASE):
# Allow very small literal selects like `SELECT 1;` or `SELECT 'x';`
if not re.match(r"^\s*select\s+[0-9'\"]+\s*;\s*$", sanitized, flags=re.IGNORECASE):
return [{"error": "Sanitized SQL missing FROM clause; rejected to avoid invalid execution"}]
rows, result_keys = await _fetch_rows_via_active_engine(sanitized)
# Convert to list of dictionaries
if rows and result_keys:
return [dict(zip(result_keys, row)) for row in rows]
elif result_keys: # We have columns but no rows
print(f"Query executed successfully but returned no rows. Columns: {list(result_keys)}")
return []
else: # No rows and potentially no columns
print("Query executed but returned no results")
return []
except Exception as e:
error_message = str(e)
print(f"Error executing query: {error_message}")
# Extract error details for better debugging
error_type = type(e).__name__
print(f"Error type: {error_type}")
# Check for specific error types and provide more context
if "column" in error_message.lower() and "does not exist" in error_message.lower():
print("Column reference error detected - this may be a schema misunderstanding")
elif "syntax error" in error_message.lower():
print("SQL syntax error detected - the query structure is invalid")
elif "relation" in error_message.lower() and "does not exist" in error_message.lower():
print("Table reference error detected - the table may not exist or has a different name")
# AUTO-FIX: Detect PostgreSQL GroupingError (missing GROUP BY) and repair inline
if ("groupingerror" in error_type.lower() or
"must appear in the group by clause" in error_message.lower()):
print("[AUTO-FIX][GROUP_BY] Detected PostgreSQL GroupingError — attempting GROUP BY repair")
try:
col_match = re.search(
r'column\s+"?([^"]+)"?\s+must appear in the group by',
error_message, re.IGNORECASE
)
fixed_query = sanitized
if col_match:
raw_col = col_match.group(1).strip() # e.g. "title.title"
col_bare = raw_col.split('.')[-1] # e.g. "title"
insertion = len(fixed_query)
for clause in [r'\bORDER\s+BY\b', r'\bLIMIT\b']:
m = re.search(clause, fixed_query, re.IGNORECASE)
if m and m.start() < insertion:
insertion = m.start()
semi = re.search(r'\s*;', fixed_query)
if semi and semi.start() < insertion:
insertion = semi.start()
if 'GROUP BY' in fixed_query.upper():
fixed_query = re.sub(
r'\b(GROUP\s+BY\b\s+\S+)',
rf'\1, {col_bare}',
fixed_query, count=1, flags=re.IGNORECASE
)
else:
fixed_query = fixed_query[:insertion] + f' GROUP BY {col_bare}' + fixed_query[insertion:]
print(f"[AUTO-FIX][GROUP_BY] Repaired: {fixed_query[:200]}")
try:
fixed_rows, fixed_keys = await _fetch_rows_via_active_engine(fixed_query)
if fixed_rows and fixed_keys:
return [dict(zip(fixed_keys, row)) for row in fixed_rows]
elif fixed_keys:
return []
return []
except Exception as _fix_err:
print(f"[AUTO-FIX][GROUP_BY] repaired query still failed: {_fix_err}")
return [{"error": str(_fix_err)}]
except Exception as gb_err:
print(f"[AUTO-FIX][GROUP_BY] Repair failed: {gb_err}")
# NEW: Detect missing FROM-clause/alias errors and trigger LLM regeneration
if not skip_invariants and ("missing from-clause entry for table" in error_message.lower() or "undefinedtableerror" in error_type.lower() or "missing from-clause entry" in error_message.lower() or "no such table" in error_message.lower() or "invalid reference to FROM-clause entry" in error_message.lower() or "missing alias" in error_message.lower() or "ambiguous column" in error_message.lower() or "alias" in error_message.lower() and "does not exist" in error_message.lower()):
print("[INVARIANTS] Detected alias/FROM-clause error. Triggering LLM regeneration for correct join order and alias usage.")
try:
from .invariants import build_invariants, evaluate_invariants, regenerate_with_constraints, classify_question
# Use the last question from context if available, else fallback
# (You may need to pass the question explicitly if not in scope)
regen_question = getattr(execute_query, 'last_question', None)
if not regen_question:
regen_question = "Please regenerate the SQL with correct join order and alias usage."
cat = classify_question(regen_question)
invs = build_invariants(cat, regen_question)
missing = evaluate_invariants(query, invs)
if missing:
regen_sql = regenerate_with_constraints(regen_question, query, missing, Settings.llm, run_llm_with_timeout, attempt=0)
if regen_sql:
print("[INVARIANTS] LLM regenerated SQL after alias/FROM-clause error. Re-executing.")
# Try to execute the regenerated SQL
return await execute_query(regen_sql)
except Exception as regen_alias_err:
print(f"[INVARIANTS] Alias/FROM-clause regeneration failed: {regen_alias_err}")
# If error indicates a missing column, attempt a lightweight heuristic repair and re-run once
col_match = re.search(r'column\s+"?([a-zA-Z0-9_]+)"?\s+does not exist', error_message, flags=re.IGNORECASE)
if col_match:
missing_col = col_match.group(1)
print(f"Detected missing column '{missing_col}', attempting heuristic repair...")
repaired = repair_missing_column(query, missing_col)
if repaired:
print(f"Repaired query to attempt: {repaired}")
try:
rep_rows, rep_keys = await _fetch_rows_via_active_engine(repaired)
if rep_rows and rep_keys:
return [dict(zip(rep_keys, row)) for row in rep_rows]
elif rep_keys:
return []
else:
return []
except Exception as re_e:
print(f"Repaired query execution failed: {re_e}")
return [{"error": str(re_e)}]
# Return the error for formatting
return [{"error": error_message}]
def validate_sql_query(query: str) -> Dict[str, Any]:
"""Validate a SQL query without executing it."""
try:
# Always clean SQL before validation
sql = extract_clean_sql(query)
ql = sql.lower()
# Use regex to check for SELECT and FROM as word boundaries (robust to newlines/indentation)
if not re.search(r'\bselect\b', ql) and not re.search(r'\bwith\b', ql):
return {
"valid": False,
"reason": "Query must start with SELECT or WITH"
}
if not re.search(r'\bfrom\b', ql):
return {
"valid": False,
"reason": "Query must include FROM clause"
}
is_pure_aggregate = (
("count(" in ql or "avg(" in ql or "min(" in ql or "max(" in ql or "sum(" in ql)
and " group by " not in ql
)
if ("limit" not in ql) and not is_pure_aggregate:
return {"valid": False, "reason": "Query should include LIMIT clause to prevent excessive results"}
return {
"valid": True,
"reason": "Query syntax appears valid"
}
except Exception as e:
return {
"valid": False,
"reason": f"Validation exception: {str(e)}"
}
def repair_missing_column(query: str, missing_col: str) -> Optional[str]:
"""Attempt a small, safe repair when a column is reported missing.
Heuristic currently supports the common case where `salary` is referenced
but belongs to the `salary` table rather than `employee`.
Returns a repaired SQL string (including LIMIT) or None if no repair.
"""
try:
q = query.strip()
q_lower = q.lower()
# Find FROM clause and optional alias (e.g. FROM employee e)
m = re.search(r'\bfrom\s+([a-zA-Z_][\w]*)(?:\s+([a-zA-Z_][\w]*))?', q, flags=re.IGNORECASE)
if not m:
return None
base_table = m.group(1)
base_alias = m.group(2)
# Avoid treating SQL keywords as aliases (e.g., 'ORDER' accidentally captured)
SQL_KEYWORDS = {"order","by","where","group","limit","join","on","having","union","except","intersect"}
if base_alias and base_alias.lower() in SQL_KEYWORDS:
base_alias = None
alias = base_alias if base_alias else base_table
# Only handle the common salary case conservatively
if missing_col.lower() == 'salary':
# Avoid double-injecting if salary already joined or qualified
if re.search(r'\bjoin\s+salary\b', q_lower) or re.search(r's\.salary|salary\s+\w', q_lower):
return None
# Find end of the table name token to insert join before the next clause
table_token_re = re.search(r'\bfrom\s+' + re.escape(base_table) + r'\b', q, flags=re.IGNORECASE)
if not table_token_re:
return None
insert_pos = table_token_re.end()
join_clause = f" JOIN salary s ON {alias}.emp_no = s.emp_no"
repaired = q[:insert_pos] + join_clause + q[insert_pos:]
# Qualify ORDER BY salary -> s.salary
repaired = re.sub(r'order\s+by\s+salary\b', 'ORDER BY s.salary', repaired, flags=re.IGNORECASE)
# Ensure terminating semicolon and LIMIT presence
if 'limit' not in repaired.lower():
if repaired.strip().endswith(';'):
repaired = repaired[:-1].strip() + ' LIMIT 100;'
else:
repaired = repaired.strip() + ' LIMIT 100;'
else:
if not repaired.strip().endswith(';'):
repaired = repaired.strip() + ';'
return repaired
return None
except Exception:
return None
def enhanced_validate_sql(query: str) -> Dict[str, Any]:
"""Stricter validation to catch placeholders, unknown tables/columns.
Returns dict(valid: bool, issues: List[str]). Lightweight heuristic (no full parse).
"""
issues: List[str] = []
q_lower = query.lower()
# Reject obvious placeholders
for token in PLACEHOLDER_TOKENS:
if token in q_lower:
issues.append(f"Contains placeholder token '{token}'")
for rx in PLACEHOLDER_REGEXES:
if re.search(rx, q_lower):
issues.append("Contains generic placeholder pattern (your_*)")
# Must contain SELECT ... FROM ... pattern (robust to newlines/indentation)
if not re.search(r'\bselect\b', q_lower) or not re.search(r'\bfrom\b', q_lower):
issues.append("Missing SELECT or FROM clause")
# Extract referenced tables (rough pattern)
# IMPORTANT: Strip EXTRACT(...FROM...) expressions first to prevent false table detection.
# e.g. EXTRACT(YEAR FROM hire_date) would otherwise match 'hire_date' as a table name.
referenced_tables = set()
try:
query_for_table_scan = re.sub(r'\bEXTRACT\s*\([^)]+\)', '', query, flags=re.IGNORECASE)
table_tokens = re.findall(r'(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+as)?\s*([a-zA-Z_][a-zA-Z0-9_]*)?', query_for_table_scan, flags=re.IGNORECASE)
for base, _alias in table_tokens:
referenced_tables.add(base.lower())
except Exception:
pass
avail_lower = [t.lower() for t in available_tables]
for t in referenced_tables:
if t and t not in avail_lower:
issues.append(f"Unknown table '{t}'")
# Build schema cache once
schema_cache = {}
try:
for tbl in available_tables:
try:
cols = [c['name'].lower() for c in inspector.get_columns(table_name=tbl)]
schema_cache[tbl.lower()] = set(cols)
except Exception:
continue
except Exception:
pass
# Alias mapping
alias_map = {}
try:
alias_defs = re.findall(r'(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*)', query, flags=re.IGNORECASE)
for real, al in alias_defs:
alias_map[al.lower()] = real.lower()
except Exception:
pass
# Column references alias.column
try:
col_refs = re.findall(r'([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)', query)
# Skip common SQL functions/keywords that could appear before a dot (edge cases)
skip_alias = {"select","where","and","or","on","group","order","limit","count","avg","sum","min","max"}
for alias, col in col_refs:
if alias.lower() in skip_alias:
continue
mapped_table = alias_map.get(alias.lower(), alias.lower())
cols = schema_cache.get(mapped_table)
if cols is not None and col.lower() not in cols:
issues.append(f"Unknown column '{alias}.{col}'")
except Exception:
pass
# Detect bare (unqualified) column names used in WHERE / SELECT that don't exist in any referenced table
try:
# Extract simple bare identifiers between SELECT and FROM and after WHERE
bare_tokens = set()
select_part = query.split('FROM')[0] if 'FROM' in query.upper() else query
where_parts = re.split(r'WHERE', query, flags=re.IGNORECASE)
if len(where_parts) > 1:
where_clause = where_parts[1]
else:
where_clause = ''
token_pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*)\b'
keywords = {"select","from","where","and","or","on","group","by","order","limit","count","avg","sum","min","max","as","distinct","case","when","then","end"}
for part in [select_part, where_clause]:
for m in re.findall(token_pattern, part):
ml = m.lower()
if ml not in keywords and not any(ml == a or ml in schema_cache.get(a, set()) for a in alias_map.keys()):
# Check if token is a column in ANY referenced table
if not any(ml in cols for cols in schema_cache.values()):
bare_tokens.add(m)
# Filter obviously numeric or alias tokens
for tok in list(bare_tokens):
if tok.isdigit():
bare_tokens.remove(tok)
# Report tokens that look like columns but unknown
for tok in sorted(bare_tokens):
# Skip if it is a table name
if tok.lower() in avail_lower:
continue
# Skip placeholder patterns already captured
if tok.lower().startswith('your_'):
continue
# Only flag if appears near '=' in query (likely used as column)
if re.search(rf'\b{tok}\b\s*=|=\s*\b{tok}\b', query):
issues.append(f"Unqualified unknown identifier '{tok}'")
except Exception:
pass
# LIMIT optional for pure single-row aggregates (no GROUP BY)
is_pure_aggregate = (
("count(" in q_lower or "avg(" in q_lower or "min(" in q_lower or "max(" in q_lower or "sum(" in q_lower)
and " group by " not in q_lower
)
if "limit" not in q_lower and not is_pure_aggregate:
issues.append("Missing LIMIT clause")
if not q_lower.strip().endswith(';'):
issues.append("Missing terminating semicolon")
return {"valid": len(issues) == 0, "issues": issues}
def format_results(results: List[Dict[str, Any]], question: str) -> str:
"""Format query results into a user-friendly response."""
if not results:
try:
_db_label = ACTIVE_SCHEMA_PROFILE.name
except Exception:
_db_label = "the active database"
return f"I couldn't find any data matching your query in {_db_label}."
if "error" in results[0]:
error_msg = results[0]["error"]
# Provide more specific error messages for common errors
if "column" in error_msg.lower() and "does not exist" in error_msg.lower():
column_match = re.search(r'column "([^"]+)" does not exist', error_msg.lower())
if column_match:
bad_column = column_match.group(1)
return f"I encountered an error with the database query. The column '{bad_column}' doesn't exist in the database schema. This might be due to a typo or incorrect schema understanding."
elif "relation" in error_msg.lower() and "does not exist" in error_msg.lower():
table_match = re.search(r'relation "([^"]+)" does not exist', error_msg.lower())
if table_match:
bad_table = table_match.group(1)
return f"I encountered an error with the database query. The table '{bad_table}' doesn't exist in the database. Available tables are: {', '.join(available_tables)}."
# Generic error message
return f"I encountered an error while querying the database: {error_msg}"
# Convert any Decimal objects to float for JSON serialization
processed_results = []
for row in results:
processed_row = {}
for key, value in row.items():
# Handle Decimal type for JSON serialization
if hasattr(value, '__class__') and value.__class__.__name__ == 'Decimal':
processed_row[key] = float(value)
else:
processed_row[key] = value
processed_results.append(processed_row)
# Handle all result sets directly without using LLM to save memory
# First, check memory before potential LLM usage
if not check_memory_safety():
print("Low memory detected, using direct formatting instead of LLM")
return format_results_direct(processed_results, question)
# For result sets up to 50 rows, format directly (avoids LLM narrating from 3-sample rows)
if len(processed_results) <= 50:
return format_results_direct(processed_results, question)
# For larger result sets, try to use LLM but have a fallback ready
try:
# Check memory before LLM call
if not check_memory_safety():
print("Low memory before LLM formatting, falling back to direct formatting")
return format_results_direct(processed_results, question)
# Extract just the first 3 rows to reduce context size
sample_results = processed_results[:3]
sample_str = str(sample_results)
# Create a minimal prompt (profile-aware intro)
try:
_intro = ("Based on the employee database..." if ACTIVE_SCHEMA_PROFILE.is_employee_db
else f"Based on the {ACTIVE_SCHEMA_PROFILE.name} database...")
except Exception:
_intro = "Based on the database..."
prompt = f"""Summarize database results for: "{question}"
Sample (first 3 of {len(results)} rows):
{sample_str}
Start with "{_intro}"
"""
# Generate the formatted response with a short timeout
# Use a fresh LLM instance to avoid state issues
format_llm = initialize_llm()
response = format_llm.complete(prompt, timeout=5.0)
return response.text.strip()
except Exception as e:
print(f"Error formatting results with LLM: {str(e)}")
# Fallback to direct formatting
return format_results_direct(processed_results, question)
def format_results_direct(processed_results: List[Dict[str, Any]], question: str) -> str:
"""
Format results directly without using an LLM.
This is a memory-efficient fallback when the LLM approach isn't possible.
"""
question_lower = question.lower()
# Start with a profile-aware intro
try:
if ACTIVE_SCHEMA_PROFILE.is_employee_db:
summary = "Based on the employee database, "
else:
summary = f"Based on the {ACTIVE_SCHEMA_PROFILE.name} database, "
except Exception:
summary = "Based on the database, "
# Determine the type of query to customize the response
# Gender distribution queries — always show a table
result_columns = list(processed_results[0].keys()) if processed_results else []
has_gender_col = any('gender' in c.lower() for c in result_columns)
if has_gender_col or 'gender' in question_lower or 'male' in question_lower or 'female' in question_lower:
summary += "here is the gender breakdown:\n\n"
headers = result_columns
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
if isinstance(val, (int, float)) and any(k in header.lower() for k in ["count", "num", "total"]):
values.append(f"{val:,}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
return summary
if "average salary" in question_lower and "department" in question_lower:
summary += "here's the average salary by department:\n\n"
# Create a markdown table for department salaries
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
# Format salary numbers with dollar signs and commas
if isinstance(val, (int, float)) and "salary" in header.lower():
values.append(f"${val:,.2f}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
elif "count" in question_lower or "how many" in question_lower or "number of" in question_lower:
# For count queries
if len(processed_results) == 1 and len(processed_results[0]) == 1:
# Simple count result (like total number of employees)
key = list(processed_results[0].keys())[0]
count = processed_results[0][key]
if "department" in question_lower and "employee" in question_lower:
# Extract department name from question if possible
dept_match = re.search(
r'(?:in|for|of|within)\s+(?:the\s+)?([A-Za-z\s&]+?)\s+department',
question_lower
)
dept_name = dept_match.group(1).strip().title() if dept_match else "that department"
summary += f"there are {count:,} employees working in the {dept_name} department."
elif "employee" in question_lower:
summary += f"there are {count:,} employees in the database."
else:
summary += f"the count is {count:,}."
else:
# Multiple rows with counts (like employees per department)
summary += "here are the counts:\n\n"
# Create a markdown table
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
# Format count numbers with commas
if isinstance(val, (int, float)) and ("count" in header.lower() or header.lower() == "count"):
values.append(f"{val:,}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
elif "manager" in question_lower or "managers" in question_lower:
# For manager queries
summary += "here are the department managers:\n\n"
# Create a markdown table
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
else:
# Generic handling for other types of queries
if len(processed_results) <= 10:
summary += "here are the results:\n\n"
# Create a markdown table
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
# Format currency values
if isinstance(val, (int, float)) and "salary" in header.lower():
values.append(f"${val:,.2f}")
# Format count values
elif isinstance(val, (int, float)) and ("count" in header.lower() or header.lower() == "count"):
values.append(f"{val:,}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
else:
# For large result sets
summary += f"I found {len(processed_results):,} results. Here are the first 10:\n\n"
# Create a markdown table with just the first 10 rows
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results[:10]:
values = []
for header in headers:
val = row.get(header, "")
# Format currency values
if isinstance(val, (int, float)) and "salary" in header.lower():
values.append(f"${val:,.2f}")
# Format count values
elif isinstance(val, (int, float)) and ("count" in header.lower() or header.lower() == "count"):
values.append(f"{val:,}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
return summary
def check_history(question: str) -> Dict[str, Any]:
"""Check if a similar question has been asked before."""
# 1. Direct normalized lookup
norm = normalize_question(question)
ans = question_memory_index.get(norm)
if ans:
return {"found": True, "previous_question": question, "previous_answer": ans}
# 2. Lightweight fuzzy / token overlap search over recent history
try:
private_messages = chat_store_private.get_messages(key="conversation")
qa_pairs = []
cur_q = None
for msg in private_messages[-400:]: # limit scan window
if msg.role == MessageRole.USER:
cur_q = msg.content
elif msg.role == MessageRole.ASSISTANT and cur_q:
qa_pairs.append((cur_q, msg.content))
cur_q = None
qa_pairs.reverse()
import re
target_tokens = set(norm.split())
best = None
best_score = 0.0
threshold = float(os.environ.get("MEMORY_FUZZY_THRESHOLD", "0.6"))
for qtext, answer in qa_pairs:
if any(k in qtext.lower() for k in ["re-execute","reexecute","regenerate","try again"]):
continue
q_norm = normalize_question(qtext)
tokens = set(q_norm.split())
if not tokens:
continue
overlap = len(tokens & target_tokens)
union = len(tokens | target_tokens)
jacc = overlap / union if union else 0.0
if jacc > best_score:
best_score = jacc
best = (qtext, answer)
if best_score >= 0.95: # early exit on near-perfect match
break
if best and best_score >= threshold:
print(f"[MEM-INDEX] Fuzzy match score={best_score:.2f} retrieved prior answer")
return {"found": True, "previous_question": best[0], "previous_answer": best[1]}
except Exception as e:
print(f"[MEM-INDEX][WARN] Fuzzy history scan failed: {e}")
return {"found": False}
def update_history(question: str, answer: str) -> None:
"""Update chat history with new question and answer."""
# Check if this is a re-execution command
is_reexecute = any(keyword in question.lower() for keyword in
["re-execute", "reexecute", "regenerate", "try again"])
# If it's a re-execution command, also save a version without the command words
if is_reexecute:
# Create a clean version of the question (without re-execute commands)
clean_question = question.lower()
for keyword in ["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"]:
clean_question = clean_question.replace(keyword, "").strip()
# Add the clean version to history as well
clean_user_message = ChatMessage(
role=MessageRole.USER,
content=clean_question
)
clean_assistant_message = ChatMessage(
role=MessageRole.ASSISTANT,
content=answer
)
# Add clean version to chat store
chat_store_private.add_message(key="conversation", message=clean_user_message)
chat_store_private.add_message(key="conversation", message=clean_assistant_message)
# Create messages for the original question
user_message = ChatMessage(
role=MessageRole.USER,
content=question
)
assistant_message = ChatMessage(
role=MessageRole.ASSISTANT,
content=answer
)
# Skip storing if answer appears to be an error or pure system notice
lowered = answer.lower()
error_indicators = [
"error while querying",
"syntax error",
"encountered an error",
"critically low on available memory",
"low on available memory",
"all query generation methods failed",
"cannot proceed due to low memory",
"i'm sorry, but your system is",
"failed to initialize"
]
is_error_answer = any(tok in lowered for tok in error_indicators)
if is_error_answer:
print("[MEM-HISTORY] Skipping storage of error answer")
else:
chat_store_private.add_message(key="conversation", message=user_message)
chat_store_private.add_message(key="conversation", message=assistant_message)
# Maintain a cap of recent successful QA pairs (e.g., last 200 user+assistant pairs)
try:
msgs = chat_store_private.get_messages(key="conversation")
# Count assistant messages to approximate pairs
max_pairs = int(os.environ.get("MEMORY_MAX_PAIRS", "200"))
# We'll rebuild keeping only last max_pairs*2 messages (user+assistant)
if max_pairs > 0:
trimmed = []
# Walk backwards collecting pairs
pair_msgs = []
user_seen = 0
for m in reversed(msgs):
pair_msgs.append(m)
if m.role == MessageRole.USER:
user_seen += 1
if user_seen >= max_pairs:
break
trimmed = list(reversed(pair_msgs))
if len(trimmed) < len(msgs):
chat_store_private._data["conversation"] = trimmed # internal structure update
print(f"[MEM-HISTORY] Trimmed history to last {user_seen} user turns")
except Exception as trim_err:
print(f"[MEM-HISTORY][WARN] Failed to trim history: {trim_err}")
# Persist only if stored
if not is_error_answer:
chat_store_private.persist(str(private_store_path))
print("Chat history updated successfully")
# Update memory index
try:
key = normalize_question(question)
question_memory_index[key] = answer
except Exception as e:
print(f"[MEM-INDEX][WARN] Failed to index question: {e}")
def get_doc_text(doc_id: str) -> str:
"""Read persisted OCR text for a given doc_id from chat_store/docs/{doc_id}.txt.
Returns the raw text or empty string if not found.
"""
try:
p = Path('chat_store') / 'docs' / f"{doc_id}.txt"
if not p.exists():
return ""
return p.read_text(encoding='utf-8')
except Exception:
return ""
# Define the tool functions
def memory_check(question: str) -> str:
"""Check if this question has been asked before."""
# Check if user explicitly requested to re-execute
force_reexecute = any(keyword in question.lower() for keyword in
["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"])
# Remove re-execution keywords from the question for memory checking
clean_question = question.lower()
for keyword in ["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"]:
clean_question = clean_question.replace(keyword, "").strip()
# If force re-execute, skip memory check and use the current question
if force_reexecute:
# Extract the actual question from the re-execute command
# clean_question already has re-execute keywords removed
if clean_question and len(clean_question.strip()) > 0:
print(f"[MEMORY] Re-execute requested. Using current question: '{clean_question}'")
return f"User requested to re-execute the query. Bypassing memory check."
else:
# If no question provided after re-execute, try to find previous question
try:
private_messages = chat_store_private.get_messages(key="conversation")
if len(private_messages) >= 2:
# Find the last user question before this re-execute command
for i in range(len(private_messages) - 1, -1, -1):
if private_messages[i].role == "user":
last_user_msg = private_messages[i].content
# Skip if this is also a re-execute command or empty
if last_user_msg and len(last_user_msg.strip()) > 0 and not any(kw in last_user_msg.lower() for kw in ["re-execute", "reexecute", "regenerate", "try again"]):
print(f"[MEMORY] Found original question for re-execute: {last_user_msg}")
return f"RE_EXECUTE_ORIGINAL: {last_user_msg}"
except Exception as e:
print(f"Error retrieving original question for re-execute: {e}")
# If we can't find a previous question, just bypass memory check
print("[MEMORY] No previous question found, bypassing memory check for re-execute")
return f"User requested to re-execute the query. Bypassing memory check."
# Check memory with the cleaned question
result = check_history(clean_question)
if result["found"]:
# Check if the previous answer was an error
if "error" in result["previous_answer"].lower() or "encountered an error" in result["previous_answer"].lower():
print("Previous answer contained an error. Suggesting to re-execute.")
return f"FOUND_IN_MEMORY_WITH_ERROR: {result['previous_answer']}"
else:
return f"FOUND_IN_MEMORY: {result['previous_answer']}"
else:
return "No previous answer found. I'll create a SQL query for this."
##### Hardcoded SQL template fallback system removed to enforce pure LLM-only generation #####
def ensure_llm_ready():
"""Ensure the global LLM has completed a successful handshake. If not, attempt one now
with an extended timeout (single attempt) so first user query doesn't fail silently.
Non-raising on failure; query generation will still attempt but will log status.
"""
global LLM_HANDSHAKE_COMPLETE, llm
if LLM_HANDSHAKE_COMPLETE:
return
print("[HS][LAZY] Performing deferred handshake before first generation...")
try:
# Attempt a single prompt with generous timeout (can be overridden)
timeout = float(os.environ.get("LLM_HANDSHAKE_TIMEOUT_FIRST_QUERY", os.environ.get("LLM_HANDSHAKE_TIMEOUT", "45")))
resp = TimeoutManager.run_with_timeout(lambda: llm.complete("SELECT 1;"), timeout=timeout)
if resp is not None:
LLM_HANDSHAKE_COMPLETE = True
print(f"[HS][LAZY] Deferred handshake succeeded (timeout={timeout}s)")
except Exception as e:
print(f"[HS][LAZY][WARN] Deferred handshake failed: {e}")
# ----------------------------------------------------------------------------
# Lightweight instrumentation utilities (no external deps) to diagnose timeouts
# ----------------------------------------------------------------------------
INSTRUMENT_GENERATION = os.environ.get("GEN_INSTRUMENT", "1") in ("1","true","True")
_GEN_EVENT_COUNTER = 0
def _approx_tokens(text: str) -> int:
# Crude heuristic; avoids importing a tokenizer. Good enough for relative scale.
if not text:
return 0
# Average 4 chars per token for English + SQL mix
return max(1, len(text)//4)
def _log_gen(stage: str, **kv):
if not INSTRUMENT_GENERATION:
return
global _GEN_EVENT_COUNTER
_GEN_EVENT_COUNTER += 1
# Build compact k=v list; truncate any large values
parts = [f"{k}={str(v)[:160]}" for k,v in kv.items()]
print(f"[GEN][INS][{_GEN_EVENT_COUNTER}][{stage}] " + " ".join(parts))
def _shrink_schema_if_needed(prompt: str, schema_text: str) -> str:
"""If prompt becomes very large (> ~9k chars) aggressively shrink schema section.
Returns (maybe) new schema_text. Keep logic minimal & reversible."""
if len(prompt) < 9000:
return schema_text
lines = schema_text.splitlines()
# Keep only first 2 table lines if huge
reduced = []
kept_tables = 0
for ln in lines:
if ln.strip():
reduced.append(ln)
if ':' in ln:
kept_tables += 1
if kept_tables >= 2:
break
reduced.append("...schema trimmed for brevity...")
new_schema = "\n".join(reduced)
return new_schema
# ===============================================================================
# 3-LEVEL AGENTIC SQL GENERATION ARCHITECTURE
# ===============================================================================
# Helper functions for the 3-level SQL generation approach:
# - Level 1: Pure LLM intelligence (no assistance)
# - Level 2: LLM + SQL Fixer enhancement
# - Level 3: LLM + MCP hints + SQL Fixer
#
# Each level attempts to generate SQL, and if it fails, escalates to the next level.
# Loop prevention: Strict level progression, single attempt per level, no recursive calls.
# ===============================================================================
def classify_fast_sql_intent(q: str):
"""
Classify user question to determine if it matches a fast SQL (MCP) intent pattern.
Returns (tool_name, tool_args) or (None, None) if no match.
This function extracts the intent classification logic from query_craft to make it reusable.
"""
ql = q.lower()
patterns = [
# Put salary range FIRST to avoid conflicts with other patterns
(r"salary range.*department|maximum and minimum salary.*department", ("fast_sql.salary_range_department", {})),
# DISABLED - NO HARDCODED QUERIES: (r"gender pay gap|pay gap|salary gap.*(male|female)|salary gap.*department|salary.*difference.*male.*female|pay.*difference.*male.*female", ("fast_sql.gender_pay_gap", {})),
(r"highest average salary|department .*highest average salary|which department has the highest average salary", ("fast_sql.department_highest_avg_salary", {})),
# Make top paid pattern more specific to avoid false matches
(r"top \d+ .*paid|highest[ -]paid employees|top paid employees", ("fast_sql.top_paid_employees", {})),
(r"employee count by department|employees per department|how many employees .*department|count of employees .*department", ("fast_sql.employee_count_by_department", {})),
(r"list departments|show departments|what departments", ("fast_sql.list_departments", {})),
(r"how many employees|total employees|employee count", ("fast_sql.count_employees", {})),
(r"highest and lowest salary|highest.*lowest salary|lowest.*highest salary", ("fast_sql.salary_extremes", {})),
(r"department managers|list managers per department|managers of each department", ("fast_sql.department_manager_listing", {})),
]
for pat, result in patterns:
try:
m = re.search(pat, ql)
except Exception:
m = None
if m:
if result[0] == 'fast_sql.top_paid_employees':
nm = re.search(r"top\s+(\d+)", ql)
top_n = int(nm.group(1)) if nm else 10
return result[0], {"top_n": top_n, "question": q}
if result[0] == 'fast_sql.salary_range_department':
dm = re.search(r"salary range .*department (?:for|of) ([A-Za-z &'-]+)", ql)
dept_name = dm.group(1).strip().title() if dm else 'Development'
return result[0], {"department_name": dept_name, "question": q}
return result[0], {"question": q}
return None, None
def _mcp_generic_schema_hint(question: str, _fast_log=None):
"""
Dynamic-schema Level 3 grounding: query the generic MCP tools
(schema.list_tables / schema.describe_table / schema.sample_data) and
build a verified schema+sample hint from the LIVE database. This replaces
the employee-specific fast_sql skeletons for any user-uploaded database.
Returns a text block (not raw SQL) used as grounding context in prompts.
"""
def _log(stage: str, **kv):
if _fast_log:
_fast_log(stage, **kv)
try:
from .mcp_client import mcp_call_tool
def _call(name, args, timeout=30.0):
ctx = {"tool_call": {"name": name, "args": args or {}}}
resp = mcp_call_tool(prompt=f"(generic schema hint) {name}", timeout=timeout, context=ctx)
return resp.get('tool_result') if isinstance(resp, dict) else None
# 1) Tables in the live DB
list_res = _call("schema.list_tables", {})
tables = list_res.get("tables") if isinstance(list_res, dict) else None
if not tables:
_log('GENERIC_HINT', result="no tables returned")
return None
# 2) Pick the tables whose names appear in the question (cheap keyword match)
ql = question.lower()
matched = [t for t in tables if t and (t.lower().replace('_', ' ') in ql or t.lower() in ql)]
if not matched:
# Fall back to FK-neighbors heuristic: take up to 3 largest tables
matched = tables[:3]
# 3) Describe + sample each matched table (bounded)
sections = []
for t in matched[:5]:
desc = _call("schema.describe_table", {"table": t})
cols = desc.get("columns") if isinstance(desc, dict) else None
if not cols:
continue
sample = _call("schema.sample_data", {"table": t, "limit": 2})
rows = sample.get("rows") if isinstance(sample, dict) else None
section = f"{t}({', '.join(map(str, cols))})"
if rows:
section += f" e.g. rows: {rows[:2]}"
sections.append(section)
if not sections:
_log('GENERIC_HINT', result="describe failed")
return None
hint = (
"VERIFIED LIVE SCHEMA (from actual database inspection):\n"
+ "\n".join(f"- {s}" for s in sections)
+ "\nUse ONLY these tables/columns unless another table is required to join them."
)
_owner_notes = _schema_notes_block()
if _owner_notes:
hint += "\n\n" + _owner_notes
_log('GENERIC_HINT', tables=matched, chars=len(hint))
return hint
except Exception as e:
_log('GENERIC_HINT_FAIL', error=str(e))
return None
def get_mcp_hint_if_available(question: str):
"""
Attempt to retrieve a SQL hint from MCP server based on question classification.
Returns SQL string as a hint (not to be used directly), or None if unavailable.
This is Level 3 assistance - MCP provides hints to guide the LLM, not hardcoded SQL.
"""
ENABLE_FAST_SQL = os.environ.get("ENABLE_MCP_FAST_TOOLS", "1") not in ("0", "false", "False")
FAST_SQL_LOG = os.environ.get("FAST_SQL_LOG", "1") not in ("0", "false", "False")
def _fast_log(stage: str, **kv):
if FAST_SQL_LOG:
parts = [f"{k}={str(v)[:140]}" for k, v in kv.items()]
print(f"[FAST][{stage}] " + " ".join(parts))
if not ENABLE_FAST_SQL:
return None
# Dynamic schema support: the 14 fast_sql.* tools are purpose-built for the
# employee demo schema. For user schemas, ground Level 3 with live schema +
# sample data from the generic MCP tools instead.
if not getattr(ACTIVE_SCHEMA_PROFILE, "is_employee_db", False):
return _mcp_generic_schema_hint(question, _fast_log)
tool_name, tool_args = classify_fast_sql_intent(question)
if not tool_name:
return None
_fast_log('INTENT', tool=tool_name, args=tool_args)
try:
from .mcp_client import mcp_call_tool
ctx = {"tool_call": {"name": tool_name, "args": tool_args or {}}}
start_fast = time.time()
resp = mcp_call_tool(prompt=f"(fast sql) {question}", timeout=45.0, context=ctx)
dur = round(time.time() - start_fast, 3)
tool_res = resp.get('tool_result') if isinstance(resp, dict) else None
if isinstance(tool_res, dict) and 'sql' in tool_res:
sql = tool_res['sql']
_fast_log('TOOL_SQL', ms=int(dur*1000), sql_preview=sql[:160])
# Remember for the deterministic post-3-level fallback (see the
# "All 3 levels failed" block): if every LLM call fails due to
# provider outage/credits, this schema-true SQL still answers.
global LAST_MCP_HINT_SQL
LAST_MCP_HINT_SQL = sql
return sql # Return as hint
else:
_fast_log('NO_SQL', raw=tool_res)
return None
except Exception as fast_err:
_fast_log('FALLBACK', error=str(fast_err))
return None
def is_sql_valid(sql: str, question: str = None) -> bool:
"""
Validation check for generated SQL with both syntactic and semantic checks.
Args:
sql: The SQL query to validate
question: Optional user question for semantic validation
Returns:
True if SQL appears valid both syntactically and semantically, False otherwise.
"""
if not sql or not isinstance(sql, str):
return False
# [LEVEL 1] Call enhanced_validate_sql for strict schema and column checking
validation = enhanced_validate_sql(sql)
if not validation["valid"]:
print(f"[VALIDATION] Rejected by enhanced_validate_sql: {validation['issues']}")
return False
sql_lower = sql.lower().strip()
# Quick placeholder check.
# NOTE: "<" and ">" are LEGITIMATE comparison operators ("price > average",
# "count < 5", "<>"). They were once listed here and SILENTLY rejected every
# greater/less-than question on every database. Real placeholders are caught
# by the regexes below (<TABLE_NAME> style) — keep operators legal.
PLACEHOLDER_TOKENS = ["...", "___", "placeholder", "example", "your_"]
PLACEHOLDER_REGEXES = [
r"<[A-Z_]+>", # <TABLE_NAME>, <COLUMN>
r"\b(table_name|column_name|your_\w+)\b"
]
for token in PLACEHOLDER_TOKENS:
if token in sql_lower:
print(f"[VALIDATION] Rejected: placeholder token '{token}'")
return False
for regex in PLACEHOLDER_REGEXES:
if re.search(regex, sql, re.IGNORECASE):
print(f"[VALIDATION] Rejected: placeholder pattern /{regex}/")
return False
# Reject hardcoded department codes (D001-D009) - indicates incomplete query
# These suggest LLM didn't join to department table to filter by name
if ACTIVE_SCHEMA_PROFILE.is_employee_db and re.search(r"'D00[0-9]'", sql, re.IGNORECASE):
print("[VALIDATION] Rejected: Contains hardcoded department code (needs dept_name filter)")
return False
# STRICT SCHEMA CHECK: Reject common hallucinations before they hit the DB
# employee (e) does NOT have dept_no or salary
if ACTIVE_SCHEMA_PROFILE.is_employee_db:
if re.search(r'\b[eE]\.dept_no\b|employee\.dept_no\b', sql, re.IGNORECASE):
print("[VALIDATION] Rejected: Table 'employee' lacks 'dept_no'. Bridging through 'dept_emp' is required.")
return False
if re.search(r'\b[eE]\.salary\b|employee\.salary\b', sql, re.IGNORECASE):
print("[VALIDATION] Rejected: Table 'employee' lacks 'salary'. Join with 'salary' table is required.")
return False
# MANAGER JOIN CHECK: Note that dept_manager should be used for manager queries.
# We do NOT hard-reject here — adjust_manager_query() in apply_semantic_adjusters
# will regenerate the SQL using generate_manager_list_sql() if dept_manager is absent.
# Hard-rejecting here blocks all 3 levels before the adjuster ever gets a chance to fix it.
if (ACTIVE_SCHEMA_PROFILE.is_employee_db and question
and any(k in question.lower() for k in ["manager", "managers", "who manages"])):
if 'dept_manager' not in sql.lower() and 'manager_name' not in sql.lower():
print("[VALIDATION] Warning: Manager query missing dept_manager — adjuster will fix after this level succeeds.")
# ===== SEMANTIC VALIDATION =====
# Check if question asks for specific department but SQL doesn't filter by it
if question and ACTIVE_SCHEMA_PROFILE.is_employee_db:
print(f"[VALIDATION DEBUG] Checking semantic validation for question: {question[:100]}")
question_lower = question.lower()
# Department-specific query patterns
dept_patterns = [
(r'\bin\s+(development|finance|sales|marketing|research|production|hr|human\s+resources|quality\s+management|customer\s+service)\s+(department|dept)\b', 1),
(r'\b(development|finance|sales|marketing|research|production|hr|human\s+resources|quality\s+management|customer\s+service)\s+department\b', 1),
]
for pattern, group_idx in dept_patterns:
match = re.search(pattern, question_lower, re.IGNORECASE)
if match:
dept_name = match.group(group_idx).strip()
print(f"[VALIDATION DEBUG] Found department mention: '{dept_name}'")
# Normalization
dept_map = {
'hr': 'Human Resources',
'human resources': 'Human Resources',
'quality management': 'Quality Management',
'customer service': 'Customer Service'
}
dept_name_proper = dept_map.get(dept_name.lower(), dept_name.title())
print(f"[VALIDATION DEBUG] Normalized to: '{dept_name_proper}'")
print(f"[VALIDATION DEBUG] Checking if '{dept_name_proper.lower()}' is in SQL")
print(f"[VALIDATION DEBUG] SQL being checked (first 200 chars): {sql_lower[:200]}")
# Check if SQL contains this department name as a filter
if dept_name_proper.lower() not in sql_lower:
print(f"[VALIDATION] Rejected: Question asks for '{dept_name_proper}' but SQL doesn't filter by it")
print(f"[VALIDATION] This likely returns ALL departments instead of just '{dept_name_proper}'")
return False
else:
print(f"[VALIDATION DEBUG] Department filter found in SQL - validation passed")
break # First matching pattern is sufficient — no need to check remaining patterns
else:
print("[VALIDATION DEBUG] No question provided for semantic validation")
# ===== ALIAS VALIDATION =====
# Check if SQL references aliases that aren't defined in FROM/JOIN clauses
# Extract defined aliases from FROM and JOIN clauses
defined_aliases = set()
# Match FROM table alias and JOIN table alias patterns
from_pattern = r'\bFROM\s+(\w+)\s+(?:AS\s+)?(\w+)\b'
join_pattern = r'\bJOIN\s+(\w+)\s+(?:AS\s+)?(\w+)\b'
# Exclude SQL keywords and common column names from alias matching
_SQL_KEYWORDS = {'where', 'group', 'order', 'limit', 'having', 'join', 'on',
'and', 'or', 'not', 'in', 'as', 'by', 'select', 'from', 'set',
'emp_no', 'dept_no', 'from_date', 'to_date', 'first_name', 'last_name'}
# Strip EXTRACT(... FROM ...) to avoid matching column names as tables
_sql_for_alias = re.sub(r'\bEXTRACT\s*\([^)]+\)', 'EXTRACT_EXPR', sql, flags=re.IGNORECASE)
_sql_for_alias_lower = _sql_for_alias.lower()
for match in re.finditer(from_pattern, _sql_for_alias, re.IGNORECASE):
table_name = match.group(1).lower()
alias = match.group(2).lower()
# Skip SQL keywords mistakenly matched as aliases
if alias in _SQL_KEYWORDS or table_name in _SQL_KEYWORDS:
continue
if alias != table_name:
defined_aliases.add(alias)
print(f"[VALIDATION DEBUG] Found alias '{alias}' for table '{table_name}'")
for match in re.finditer(join_pattern, _sql_for_alias, re.IGNORECASE):
table_name = match.group(1).lower()
alias = match.group(2).lower()
if alias in _SQL_KEYWORDS or table_name in _SQL_KEYWORDS:
continue
if alias != table_name:
defined_aliases.add(alias)
print(f"[VALIDATION DEBUG] Found alias '{alias}' for table '{table_name}'")
# NEW: Handle comma-separated joins like FROM table1 t1, table2 t2
# Only look at the part BEFORE the first JOIN or WHERE to avoid condition noise
comma_from_pattern = r'\bFROM\s+(.*?)(?=\bJOIN\b|\bWHERE\b|$)'
comma_match = re.search(comma_from_pattern, _sql_for_alias, re.IGNORECASE | re.DOTALL)
if comma_match:
from_clause = comma_match.group(1)
# Find all pairs of word+word (table alias)
alias_pairs = re.findall(r'(\w+)\s+(?:AS\s+)?(\w+)', from_clause, re.IGNORECASE)
for table_name, alias in alias_pairs:
t_low = table_name.lower()
a_low = alias.lower()
if t_low in _SQL_KEYWORDS or a_low in _SQL_KEYWORDS:
continue
defined_aliases.add(a_low)
print(f"[VALIDATION DEBUG] Found comma-join alias '{a_low}'")
# Find all alias references (pattern: alias.column)
alias_ref_pattern = r'\b([a-z_]\w*)\.(\w+)\b'
used_aliases = set()
for match in re.finditer(alias_ref_pattern, sql_lower, re.IGNORECASE):
alias = match.group(1)
# Skip if it's a table name (not an alias) — dynamic: use the live profile's
# table set so this works for any user-uploaded schema, not just employees
_skip_tables = ACTIVE_SCHEMA_PROFILE.available_tables_set or {
'employee', 'department', 'salary', 'dept_emp', 'dept_manager', 'title', 'titles'}
if alias not in _skip_tables:
used_aliases.add(alias)
# Check for undefined aliases
undefined_aliases = used_aliases - defined_aliases
if undefined_aliases:
print(f"[VALIDATION] Rejected: SQL references undefined aliases: {undefined_aliases}")
print(f"[VALIDATION] Defined aliases: {defined_aliases}")
print(f"[VALIDATION] Used aliases: {used_aliases}")
return False
# Basic structure check
if not sql.strip().endswith(';'):
return False
return True
def level_1_pure_llm(question: str, schema_text: str, relevant_tables: list,
base_llm, hints: str = "", timeout: float = 35.0):
"""
Level 1: Pure LLM intelligence with no assistance.
Args:
question: User's natural language question
schema_text: Formatted schema information
relevant_tables: List of relevant table names
base_llm: The LLM instance to use
timeout: Timeout for LLM call in seconds
Returns:
Valid SQL string or None if generation fails
"""
print("[LEVEL_1] Attempting pure LLM generation (no hints, no assistance)")
_CURRENT_ROW_RULE_EMP = "ALWAYS filter to_date = '9999-01-01' to get current records in salary, dept_emp, and dept_manager."
prompt = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"You are an SQL expert. Convert the natural language question into a SINGLE valid PostgreSQL query.\n"
f"Question: {question}\n"
f"Available tables: {', '.join(relevant_tables)}\n"
f"Table schemas (columns):\n{schema_text}\n"
f"Rules: Do NOT invent column names. {_current_row_rules(_CURRENT_ROW_RULE_EMP)} "
f"Append LIMIT 100 ONLY if a specific count (like 'top 10') is not requested. "
f"The query must end with a single semicolon. Return ONLY raw SQL."
f"Be precise with column names and table joins."
)
try:
start_t = time.time()
raw = base_llm.complete(prompt=prompt)
gen_dur = time.time() - start_t
text_out = raw.text if hasattr(raw, 'text') else str(raw)
print(f"[LEVEL_1] LLM responded in {gen_dur:.2f}s")
# Extract SQL from code fences if present
sql_query = text_out.strip()
if '```' in sql_query:
parts = re.split(r"```(?:sql)?", sql_query, flags=re.IGNORECASE)
sql_query = '\n'.join(p for p in parts if 'select' in p.lower())
if sql_query.lower().startswith('sql'):
sql_query = sql_query[3:].strip()
# Clean and validate with semantic checks
sql_query = extract_clean_sql(sql_query)
if is_sql_valid(sql_query, question):
print(f"[LEVEL_1] ✓ Generated valid SQL: {sql_query[:100]}...")
return sql_query
else:
print(f"[LEVEL_1] ✗ Generated SQL failed validation. SQL: {sql_query}")
return None
except Exception as e:
print(f"[LEVEL_1] ✗ Failed with error: {type(e).__name__}: {e}")
return None
def generate_micro_gen_hint(question: str) -> str:
"""
Generate micro-gen SQL hint based on question pattern.
This is called LAZILY inside Level 2, only when Level 1 fails.
Args:
question: User's natural language question
Returns:
SQL hint string or None if no pattern matches
"""
question_lower = question.lower()
# Salary range by department pattern
if re.search(r'\b(maximum and minimum|max.*min|min.*max|salary range)\b.*\b(development|dept|department)\b', question_lower):
print("[MICRO-GEN][LAZY] Detected salary range in department question")
try:
# Extract department name from question
dept_name = "Development" # Default
if "development" in question_lower:
dept_name = "Development"
elif "sales" in question_lower:
dept_name = "Sales"
elif "marketing" in question_lower:
dept_name = "Marketing"
elif "finance" in question_lower:
dept_name = "Finance"
elif "human resources" in question_lower:
dept_name = "Human Resources"
elif "research" in question_lower:
dept_name = "Research"
elif "production" in question_lower:
dept_name = "Production"
# Smart Hint: If question asks for EACH/ALL/Distribution, don't filter by a single department
is_global = any(k in question_lower for k in ["each", "all", "every", "distribution", "per department", "by department"])
# Build SQL hint
where_clause = f"WHERE d.dept_name = '{dept_name}' " if not is_global else " "
hint_sql = (
f"SELECT d.dept_name, MAX(s.salary) AS max_salary, MIN(s.salary) AS min_salary "
f"FROM salary s "
f"JOIN employee e ON s.emp_no = e.emp_no "
f"JOIN dept_emp de ON e.emp_no = de.emp_no "
f"JOIN department d ON de.dept_no = d.dept_no "
f"{where_clause}"
f"AND s.to_date = '9999-01-01' "
f"AND de.to_date = '9999-01-01' "
f"{'GROUP BY d.dept_name' if is_global else ''};"
)
if is_global:
print(f"[MICRO-GEN][LAZY] Generated GLOBAL hint (no department filter)")
else:
print(f"[MICRO-GEN][LAZY] Generated hint for {dept_name}")
return hint_sql
except Exception as e:
print(f"[MICRO-GEN][LAZY] Failed: {e}")
return None
# Add more patterns here as needed
# ...
return None
def level_2_llm_with_fixer(question: str, schema_text: str, relevant_tables: list,
base_llm, hints: str = "", timeout: float = 45.0):
"""
Level 2: LLM + Micro-gen hints + SQL Fixer enhancement.
This level LAZILY generates schema-aware hints from micro-generators
(only when Level 1 fails and Level 2 is invoked).
Then applies fix_common_sql_errors for repair.
Args:
question: User's natural language question
schema_text: Formatted schema information
relevant_tables: List of relevant table names
base_llm: The LLM instance to use
timeout: Timeout for LLM call in seconds
Returns:
Valid SQL string or None if generation fails
"""
print("[LEVEL_2] Attempting LLM + Micro-gen hints + SQL Fixer")
# LAZY GENERATION: Only generate micro-hint NOW (not before Level 1)
micro_gen_hint = generate_micro_gen_hint(question)
# Build hint text if micro-gen provided one
hint_text = ""
if micro_gen_hint:
hint_text = (
f"\n💡 MICRO-GEN HINT (schema-aware pattern):\n{micro_gen_hint}\n\n"
f"Use this as a guide but adapt column/table names to match the actual schema.\n"
)
print(f"[LEVEL_2] Micro-gen hint available: {micro_gen_hint[:100]}...")
else:
print("[LEVEL_2] No micro-gen hint available, using enhanced prompt only")
_CURRENT_ROW_RULE_EMP = "ALWAYS filter to_date = '9999-01-01' to get current records in salary, dept_emp, and dept_manager."
# Enhanced prompt with optional micro-gen hint
prompt = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{hint_text}"
f"Question: {question}\n"
f"Tables: {', '.join(relevant_tables)}\n"
f"Table schemas:\n{schema_text}\n"
f"Rules: {_current_row_rules(_CURRENT_ROW_RULE_EMP)} "
f"Append LIMIT 100 ONLY if a specific count (like 'top 10') is not requested. "
f"The query must end with a single semicolon. Return ONLY SQL (PostgreSQL)."
f"Be precise with column names and table joins."
)
try:
start_t = time.time()
raw = base_llm.complete(prompt=prompt)
gen_dur = time.time() - start_t
text_out = raw.text if hasattr(raw, 'text') else str(raw)
print(f"[LEVEL_2] LLM responded in {gen_dur:.2f}s")
# Extract SQL from code fences if present
sql_query = text_out.strip()
if '```' in sql_query:
parts = re.split(r"```(?:sql)?", sql_query, flags=re.IGNORECASE)
sql_query = '\n'.join(p for p in parts if 'select' in p.lower())
if sql_query.lower().startswith('sql'):
sql_query = sql_query[3:].strip()
# Clean and apply fixer
sql_query = extract_clean_sql(sql_query)
sql_query = fix_common_sql_errors(sql_query)
# Additional validation
base_val = validate_sql_query(sql_query)
if not base_val['valid']:
sql_query = fix_query(sql_query, base_val['reason'])
if is_sql_valid(sql_query, question):
print(f"[LEVEL_2] ✓ Generated and fixed valid SQL: {sql_query[:100]}...")
return sql_query
else:
print(f"[LEVEL_2] ✗ Generated SQL failed validation after fixes")
return None
except Exception as e:
print(f"[LEVEL_2] ✗ Failed with error: {type(e).__name__}: {e}")
return None
def level_3_mcp_hints_llm_fixer(question: str, schema_text: str, relevant_tables: list,
base_llm, hints: str = "", timeout: float = 55.0):
"""
Level 3: LLM + MCP hints + SQL Fixer (MCP provides hints to guide the LLM).
This is the final escalation level. MCP tools provide SQL patterns as hints,
which are injected into the prompt to guide the LLM's generation.
Args:
question: User's natural language question
schema_text: Formatted schema information
relevant_tables: List of relevant table names
base_llm: The LLM instance to use
timeout: Timeout for LLM call in seconds
Returns:
Valid SQL string or None if generation fails
"""
print("[LEVEL_3] Attempting LLM + MCP hints + SQL Fixer")
# Try to get MCP hint
mcp_hint = get_mcp_hint_if_available(question)
# Construct hint text
micro_hint_text = ""
if mcp_hint:
micro_hint_text = (
f"\n🎯 HINT FROM MCP - GROUNDING CONTEXT:\n{mcp_hint}\n\n"
f"Use this verified context (schema, sample data or SQL pattern) as guidance but adapt as needed for the schema.\n"
)
print(f"[LEVEL_3] MCP hint available: {mcp_hint[:100]}...")
else:
print("[LEVEL_3] No MCP hint available, proceeding with enhanced prompt")
# Enhanced prompt with MCP hint if available
prompt = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{micro_hint_text}"
f"You are an SQL expert. Convert the natural language question into a SINGLE valid PostgreSQL query.\n"
f"Question: {question}\n"
f"Available tables: {', '.join(relevant_tables)}\n"
f"Table schemas (columns):\n{schema_text}\n"
f"Rules: Do NOT invent column names. Use existing columns from provided schemas. "
f"Use the LIMIT value requested in the question, or default to LIMIT 100; if none is mentioned. "
f"End with a semicolon. Return ONLY raw SQL.\n"
f"CRITICAL: If a hint is provided above, use it as a pattern guide but ensure it matches the actual schema."
)
try:
start_t = time.time()
raw = base_llm.complete(prompt=prompt)
gen_dur = time.time() - start_t
text_out = raw.text if hasattr(raw, 'text') else str(raw)
print(f"[LEVEL_3] LLM responded in {gen_dur:.2f}s")
# Extract SQL from code fences if present
sql_query = text_out.strip()
if '```' in sql_query:
parts = re.split(r"```(?:sql)?", sql_query, flags=re.IGNORECASE)
sql_query = '\n'.join(p for p in parts if 'select' in p.lower())
if sql_query.lower().startswith('sql'):
sql_query = sql_query[3:].strip()
# Clean and apply comprehensive fixes
sql_query = extract_clean_sql(sql_query)
sql_query = fix_common_sql_errors(sql_query)
# Additional validation and fixes
base_val = validate_sql_query(sql_query)
if not base_val['valid']:
sql_query = fix_query(sql_query, base_val['reason'])
if is_sql_valid(sql_query, question):
print(f"[LEVEL_3] ✓ Generated valid SQL with MCP guidance: {sql_query[:100]}...")
return sql_query
else:
print(f"[LEVEL_3] ✗ Generated SQL failed validation after all fixes")
return None
except Exception as e:
print(f"[LEVEL_3] ✗ Failed with error: {type(e).__name__}: {e}")
return None
def query_craft(question: str) -> str:
"""Generate SQL via LLM (no hardcoded templates) with minimal retries and semantic post-fixes.
Reconstructed clean version after refactor issues.
"""
# Make sure LLM handshake succeeded or attempt deferred handshake now
ensure_llm_ready()
print(f"[QUERY_CRAFT] Received question: {question}")
question_lower = question.lower()
# ---------------- DISABLED: Old MCP tool dispatch (replaced by 3-level architecture) -----------------
# The 3-level architecture handles MCP hints in Level 3 via get_mcp_hint_if_available()
# This old code was causing duplicate MCP calls and wasting ~31s
ENABLE_FAST_SQL = False # FORCE DISABLED to prevent duplicate MCP calls
FAST_SQL_LOG = os.environ.get("FAST_SQL_LOG", "1") not in ("0","false","False")
def _fast_log(stage: str, **kv):
if FAST_SQL_LOG:
parts = [f"{k}={str(v)[:140]}" for k,v in kv.items()]
print(f"[FAST][{stage}] " + " ".join(parts))
def classify_fast_sql_intent(q: str):
ql = q.lower()
patterns = [
# Put salary range FIRST to avoid conflicts with other patterns
(r"salary range.*department|maximum and minimum salary.*department", ("fast_sql.salary_range_department", {})),
# DISABLED - NO HARDCODED QUERIES: (r"gender pay gap|pay gap|salary gap.*(male|female)|salary gap.*department|salary.*difference.*male.*female|pay.*difference.*male.*female", ("fast_sql.gender_pay_gap", {})),
(r"highest average salary|department .*highest average salary|which department has the highest average salary", ("fast_sql.department_highest_avg_salary", {})),
# Make top paid pattern more specific to avoid false matches
(r"top \d+ .*paid|highest[- ]paid employees|top paid employees", ("fast_sql.top_paid_employees", {})),
(r"employee count by department|employees per department|how many employees .*department|count of employees .*department", ("fast_sql.employee_count_by_department", {})),
(r"list departments|show departments|what departments", ("fast_sql.list_departments", {})),
(r"how many employees|total employees|employee count", ("fast_sql.count_employees", {})),
(r"highest and lowest salary|highest.*lowest salary|lowest.*highest salary", ("fast_sql.salary_extremes", {})),
(r"department managers|list managers per department|managers of each department", ("fast_sql.department_manager_listing", {})),
]
for pat, result in patterns:
try:
m = re.search(pat, ql)
except Exception:
m = None
if m:
if result[0] == 'fast_sql.top_paid_employees':
nm = re.search(r"top\s+(\d+)", ql)
top_n = int(nm.group(1)) if nm else 10
return result[0], {"top_n": top_n, "question": q}
if result[0] == 'fast_sql.salary_range_department':
dm = re.search(r"salary range .*department (?:for|of) ([A-Za-z &'-]+)", ql)
dept_name = dm.group(1).strip().title() if dm else 'Development'
return result[0], {"department_name": dept_name, "question": q}
return result[0], {"question": q}
return None, None
micro_hint = None
if ENABLE_FAST_SQL:
tool_name, tool_args = classify_fast_sql_intent(question)
if tool_name:
_fast_log('INTENT', tool=tool_name, args=tool_args)
try:
from .mcp_client import mcp_call_tool
ctx = {"tool_call": {"name": tool_name, "args": tool_args or {}}}
start_fast = time.time()
resp = mcp_call_tool(prompt=f"(fast sql) {question}", timeout=45.0, context=ctx)
dur = round(time.time() - start_fast, 3)
tool_res = resp.get('tool_result') if isinstance(resp, dict) else None
if isinstance(tool_res, dict) and 'sql' in tool_res:
sql = tool_res['sql']
_fast_log('TOOL_SQL', ms=int(dur*1000), sql_preview=sql[:160])
# Use as a hint, not as a direct return
micro_hint = sql
else:
_fast_log('NO_SQL', raw=tool_res)
except Exception as fast_err:
_fast_log('FALLBACK', error=str(fast_err))
# EARLY SHORT-CIRCUIT for salary extremes: use as hint only
if tool_name == 'fast_sql.salary_extremes':
try:
if 'salary' in available_tables:
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
scol = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
scol = 'salary'
parts = [f"SELECT MAX(s.{scol}) AS highest_salary, MIN(s.{scol}) AS lowest_salary", "FROM salary s"]
try:
if any(c['name'] == 'to_date' for c in inspector.get_columns(table_name='salary')):
parts.append("WHERE s.to_date IS NULL")
except Exception:
pass
agg_sql = " ".join(parts)
if not agg_sql.strip().endswith(';'):
agg_sql += ';'
_fast_log('EARLY_EXTREMES', sql=agg_sql)
micro_hint = agg_sql
except Exception as early_err:
_fast_log('EARLY_EXTREMES_FAIL', err=str(early_err))
# EARLY SHORT-CIRCUIT for salary range by department: use as hint only
elif tool_name == 'fast_sql.salary_range_department':
try:
if 'salary' in available_tables and 'department' in available_tables:
dept_name = tool_args.get('department_name', 'Development')
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
scol = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
scol = 'salary'
parts = [
f"SELECT MAX(s.{scol}) AS max_salary, MIN(s.{scol}) AS min_salary",
"FROM salary s",
"JOIN employee e ON s.emp_no = e.emp_no",
"JOIN dept_emp de ON e.emp_no = de.emp_no",
"JOIN department d ON de.dept_no = d.dept_no",
f"WHERE d.dept_name = '{dept_name}'"
]
try:
if any(c['name'] == 'to_date' for c in inspector.get_columns(table_name='salary')):
parts.append("AND s.to_date IS NULL")
if any(c['name'] == 'to_date' for c in inspector.get_columns(table_name='dept_emp')):
parts.append("AND de.to_date IS NULL")
except Exception:
pass
range_sql = " ".join(parts)
if not range_sql.strip().endswith(';'):
range_sql += ';'
_fast_log('EARLY_RANGE_DEPT', sql=range_sql, dept=dept_name)
micro_hint = range_sql
except Exception as early_err:
_fast_log('EARLY_RANGE_DEPT_FAIL', err=str(early_err))
# proceed to normal path
# Infer relevant tables heuristically from question.
# Employee-keyword routing only applies to the demo employee schema; for
# user schemas we use a keyword-to-table-name matcher over the LIVE tables.
table_set = set()
if ACTIVE_SCHEMA_PROFILE.is_employee_db:
if any(k in question_lower for k in ["manager", "managers"]):
table_set.update(["department", "dept_manager", "employee"])
if any(k in question_lower for k in ["average salary", "avg salary", "salary"]):
table_set.update(["salary", "dept_emp", "department", "employee"])
if "department" in question_lower:
table_set.add("department")
if any(k in question_lower for k in ["engineer", "title", "analyst", "developer", "staff"]):
table_set.add("title")
if not table_set:
# default minimal context
table_set.add("employee")
else:
# Generic matcher: match question keywords against live table names
for t in available_tables:
t_norm = (t or "").replace('_', ' ').lower().strip()
if t_norm and len(t_norm) > 3 and t_norm.rstrip('s') in question_lower:
table_set.add(t)
# Also consider FK-reachable neighbors of matched tables
if table_set and ACTIVE_SCHEMA_PROFILE.tables:
for matched in list(table_set):
for fk in ACTIVE_SCHEMA_PROFILE.fk_by_table.get(matched, []) or []:
ref = fk.get("referred_table")
if ref and ref in available_tables:
table_set.add(ref)
if not table_set:
table_set.update(ACTIVE_SCHEMA_PROFILE.tables[:4])
# Keep only existing tables
relevant_tables = [t for t in table_set if t in available_tables]
if not relevant_tables:
relevant_tables = available_tables[:4] # small slice fallback
# Build lightweight schema snippets for relevant tables (column names only) to reduce hallucinated columns
def _collect_schema_snippets(tables: List[str], per_table_col_cap: int = 15) -> List[str]:
out: List[str] = []
try:
for tbl in tables:
try:
cols = [c['name'] for c in inspector.get_columns(table_name=tbl)]
if cols:
shown = cols[:per_table_col_cap]
out.append(f"{tbl}: {', '.join(shown)}")
except Exception:
continue
except Exception:
pass
return out
schema_snippets = _collect_schema_snippets(relevant_tables)
schema_text = "\n".join(schema_snippets) if schema_snippets else "(schema unavailable)"
# ---------------- DISABLED: Plan Phase (not needed by 3-level architecture) ----------------
# The 3-level architecture generates SQL directly without needing a separate planning step
# This was adding ~25s overhead
ENABLE_PLAN_PHASE = False # FORCE DISABLED to eliminate redundant LLM call
plan_dict: Optional[Dict[str, Any]] = None
if ENABLE_PLAN_PHASE:
plan_prompt = (
"You are a planning assistant. Given a natural language question, output ONLY JSON describing the SQL plan.\n"
"Keys: tables (list of table names), joins (list of join conditions like 'a.col = b.col'), filters (list of simple predicates),"
" group_by (list of columns), metrics (list of aggregate expressions), order_by (optional string), limit (int or null).\n"
"Do not include explanatory text. Return strict JSON only.\n"
f"Question: {question}\nJSON:"
)
try:
# Short timeout plan step to avoid long stall
_plan_start = time.time()
plan_resp = Settings.llm.complete(plan_prompt, timeout=10.0)
_plan_dur = time.time() - _plan_start
_log_gen("plan", dur_s=round(_plan_dur,2), prompt_chars=len(plan_prompt), est_tokens=_approx_tokens(plan_prompt))
raw_plan = plan_resp.text if hasattr(plan_resp, 'text') else str(plan_resp)
# Extract first JSON object
import json as _json, re as _re
m = _re.search(r"\{.*\}", raw_plan, flags=_re.DOTALL)
if m:
candidate = m.group(0)
try:
plan_dict = _json.loads(candidate)
except Exception:
plan_dict = None
# Basic validation
if plan_dict:
ptables = plan_dict.get('tables') or []
if not isinstance(ptables, list):
plan_dict = None
else:
# Ensure tables exist
cleaned = [t for t in ptables if isinstance(t, str) and t in available_tables]
if not cleaned:
plan_dict = None
else:
plan_dict['tables'] = cleaned
# Replace relevant_tables with planned subset to shrink prompt
relevant_tables = cleaned
schema_snippets = _collect_schema_snippets(relevant_tables, per_table_col_cap=12)
schema_text = "\n".join(schema_snippets) if schema_snippets else "(schema unavailable)"
print(f"[PLAN] Accepted plan tables={cleaned} joins={plan_dict.get('joins')} metrics={plan_dict.get('metrics')}")
else:
print("[PLAN][WARN] Could not parse plan JSON; continuing without plan phase")
except Exception as plan_err:
print(f"[PLAN][WARN] Plan phase failed: {plan_err}")
def _build_sql_prompt_from_plan(plan: Dict[str, Any]) -> str:
"""Construct a concise prompt using the validated plan dict."""
return (
f"You are an expert SQL generator. Use ONLY the provided plan and schema to write ONE PostgreSQL query.\n"
f"Question: {question}\n"
f"Plan tables: {', '.join(plan.get('tables', []))}\n"
f"Plan joins: {plan.get('joins', [])}\n"
f"Plan filters: {plan.get('filters', [])}\n"
f"Plan group_by: {plan.get('group_by', [])}\n"
f"Plan metrics: {plan.get('metrics', [])}\n"
f"Plan order_by: {plan.get('order_by', '')}\n"
f"Plan limit: {plan.get('limit', 100)}\n"
f"Table schemas (columns):\n{schema_text}\n"
"Rules: Do NOT invent columns. Apply all joins & filters. Ensure current rows by filtering to_date IS NULL where relevant."
" End with LIMIT 100; Return ONLY raw SQL."
)
# Lightweight hints (pure text, not SQL)
hints = []
if "manager" in question_lower:
hints.append("Join department d, dept_manager dm, employee e")
if "average" in question_lower and "salary" in question_lower:
# Check if a specific department is mentioned — give a targeted hint
_dept_names = ["marketing", "sales", "development", "research", "finance",
"human resources", "production", "quality management", "customer service"]
_mentioned_dept = next((d.title() for d in _dept_names if d in question_lower), None)
if _mentioned_dept:
hints.append(
f"Filter by specific department: FROM salary s "
f"JOIN dept_emp de ON s.emp_no = de.emp_no "
f"JOIN department d ON de.dept_no = d.dept_no "
f"WHERE d.dept_name = '{_mentioned_dept}' AND s.to_date IS NULL AND de.to_date IS NULL. "
f"dept_emp has NO dept_name column — you MUST join department d to filter by name."
)
else:
hints.append("AVG(s.salary) per department; join salary s, dept_emp de, department d; dept_emp has NO dept_name — join department d to get name")
if "count" in question_lower or "how many" in question_lower:
hints.append("Use COUNT(*) or COUNT(DISTINCT ...) with LIMIT 100")
# Explicit richer hint for employee count by department to encourage correct join early
if ("department" in question_lower and any(k in question_lower for k in ["how many", "count", "number of"]) and any(k in question_lower for k in ["employee", "employees"])):
hints.append("Join department d with dept_emp de ON d.dept_no = de.dept_no; filter current rows de.to_date IS NULL; SELECT d.dept_name, COUNT(*) AS employee_count GROUP BY d.dept_name ORDER BY employee_count DESC")
if "engineer" in question_lower:
hints.append("Filter title table for specific engineer title t.title")
query_hints = '; '.join(hints)
# Normalized title extraction (heuristic)
normalized_title = None
title_map = [
(r"senior engineers?", "Senior Engineer"),
(r"staff engineers?", "Staff Engineer"),
(r"assistant engineers?", "Assistant Engineer"),
(r"engineers?", "Engineer"),
(r"analysts?", "Analyst"),
(r"developers?", "Developer"),
]
for pat, canon in title_map:
if re.search(pat, question_lower):
normalized_title = canon
break
# Detect explicit top-paid / salary ranking questions to provide a stronger textual hint
is_salary_rank = False
try:
if re.search(r"top\s+\d+.*paid", question_lower) or re.search(r"highest[- ]paid", question_lower) or ("top" in question_lower and "paid" in question_lower):
is_salary_rank = True
except Exception:
is_salary_rank = False
salary_rank_hint = ""
if is_salary_rank or "salary" in question_lower:
col = get_salary_column_name()
salary_rank_hint = (
f"\nNote: Salaries are stored in table `salary` using column `{col}`. "
f"You MUST join `salary` s ON e.emp_no = s.emp_no. "
f"Always filter s.to_date IS NULL for current pay."
)
# Inject directly into question for maximum visibility to small models
question = f"{question}\n\n# HINT: {salary_rank_hint}"
# Quick targeted micro-generator fallback: for employee-count-by-department
# questions, try the small focused generator first to avoid multi-attempt LLM
# failures that result in the generic "Unable to generate SQL" error.
try:
try:
cat = classify_question(question)
except Exception:
cat = None
if cat == "EMP_COUNT_BY_DEPT":
try:
# Check if question asks about a SPECIFIC department by name
_dept_match = re.search(
r'(?:in|for|of|within|at)\s+(?:the\s+)?([A-Za-z\s&]+?)\s+department',
question_lower
)
_specific_dept_name = None
if _dept_match:
_candidate = _dept_match.group(1).strip().title()
# Validate against cached real department names (populated at DB init)
# This automatically excludes generic words like 'each', 'every', etc.
for _rd in _cached_dept_names:
if _candidate.lower() == _rd.lower():
_specific_dept_name = _rd # Use exact DB casing
break
if _specific_dept_name:
_specific_hint = _build_sql(
select_cols=["COUNT(DISTINCT de.emp_no) AS employee_count"],
base_table="dept_emp de",
joins=["JOIN department d ON de.dept_no = d.dept_no"],
where=[f"d.dept_name = '{_specific_dept_name}'", "de.to_date IS NULL"]
)
print(f"[MICRO-HINT] Injecting specific-department employee count hint for: {_specific_dept_name}")
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{_specific_hint}"
else:
# Generic all-departments query — use the existing micro-generator
rescue_sql = generate_employee_count_by_department_sql(question)
if rescue_sql:
print("[MICRO-HINT] Using micro-generated employee-count-by-department SQL as LLM hint only.")
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{rescue_sql}"
except Exception as micro_err:
print(f"[MICRO-HINT] Micro-generator failed: {micro_err}")
except Exception:
# Non-fatal; continue to normal LLM generation
pass
base_llm = Settings.llm
# If this is a straightforward top-N salary ranking question, try a safe micro-generator
# that builds a correct SQL using the actual inspector/schema to avoid hallucinated columns.
try:
if is_salary_rank:
from math import floor
def try_micro_top_paid():
m = re.search(r"top\s+(\d+)", question.lower())
n = int(m.group(1)) if m else 10
if 'salary' not in available_tables or 'employee' not in available_tables:
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
if 'salary' in cols:
col_name = 'salary'
elif 'amount' in cols:
col_name = 'amount'
elif cols:
col_name = cols[0]
else:
col_name = 'salary'
except Exception:
col_name = 'salary'
def _local_build_sql(select_cols, base_table, joins=None, where=None,
group_by=None, order_by=None, limit=None):
parts = []
parts.append("SELECT " + ", ".join(select_cols))
parts.append("FROM " + base_table)
if joins:
for j in joins:
parts.append(j)
if where:
parts.append("WHERE " + " AND ".join(where))
if group_by:
parts.append("GROUP BY " + ", ".join(group_by))
if order_by:
parts.append("ORDER BY " + order_by)
if limit:
parts.append(f"LIMIT {limit}")
sql = " ".join(parts)
if not sql.strip().endswith(';'):
sql = sql + ';'
return sql
select_cols = [f"e.emp_no", f"e.first_name", f"e.last_name", f"s.{col_name} AS salary"]
joins = ["JOIN salary s ON e.emp_no = s.emp_no"]
where = ["s.to_date IS NULL"]
order_by = f"s.{col_name} DESC"
sql = _local_build_sql(select_cols=select_cols, base_table="employee e", joins=joins, where=where, order_by=order_by, limit=n)
return sql
micro_sql = try_micro_top_paid()
if micro_sql:
print("[MICRO-HINT] Using micro-generated top-paid SQL as LLM hint only.")
# Inject as hint to LLM prompt, do not return directly
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{micro_sql}"
# Small helper to assemble SQL from runtime pieces (avoids storing static SQL templates)
def _build_sql(select_cols: List[str], base_table: str, joins: Optional[List[str]] = None,
where: Optional[List[str]] = None, group_by: Optional[List[str]] = None,
order_by: Optional[str] = None, limit: Optional[int] = None) -> str:
parts: List[str] = []
parts.append("SELECT " + ", ".join(select_cols))
parts.append("FROM " + base_table)
if joins:
for j in joins:
parts.append(j)
if where:
parts.append("WHERE " + " AND ".join(where))
if group_by:
parts.append("GROUP BY " + ", ".join(group_by))
if order_by:
parts.append("ORDER BY " + order_by)
if limit:
parts.append(f"LIMIT {limit}")
sql = " ".join(parts)
if not sql.strip().endswith(';'):
sql = sql + ';'
return sql
# Additional micro-generators for common question types
# 1) highest and lowest salary across all departments
if re.search(r"highest\s+and\s+lowest\s+salary|highest.*lowest|lowest.*highest", question.lower()):
def gen_high_low():
if 'salary' not in available_tables:
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = [f"MAX(s.{col}) AS highest_salary", f"MIN(s.{col}) AS lowest_salary"]
return _build_sql(select_cols=select_cols, base_table="salary s", where=["s.to_date IS NULL"], limit=None)
v = gen_high_low()
if v:
print('[MICRO-HINT] Built high/low salary micro SQL proposal (used as LLM hint only)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 2) department with highest average salary
if re.search(r"which department has the highest average salary|department.*highest average salary", question.lower()):
def gen_dept_highest_avg():
if not all(t in available_tables for t in ['department','dept_emp','salary']):
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = ["d.dept_name", f"AVG(s.{col}) AS avg_salary"]
joins = ["JOIN dept_emp de ON d.dept_no = de.dept_no", "JOIN salary s ON de.emp_no = s.emp_no"]
where = ["de.to_date IS NULL", "s.to_date IS NULL"]
return _build_sql(select_cols=select_cols, base_table="department d", joins=joins, where=where, group_by=["d.dept_name"], order_by="avg_salary DESC", limit=1)
v = gen_dept_highest_avg()
if v:
print('[MICRO-HINT] Built department highest-average-salary SQL (used as LLM hint only)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# Manager gender queries: inject hint-only (L1 still runs full 3-level architecture)
# The 1.5b model hallucinates dept_name='Management' without guidance.
# Hint-only keeps 3-level intact while correcting the hallucination.
_mgr_gender = re.search(r'(female|male|woman|women|man|men)\s+managers|managers.*(female|male|gender)|how many.*(female|male).*(manager)', question.lower())
if _mgr_gender:
_gw = _mgr_gender.group(1) or _mgr_gender.group(2) or ''
_gv = 'F' if _gw in ('female', 'woman', 'women') else 'M' if _gw in ('male', 'man', 'men') else None
if _gv:
_hint_sql = (f"SELECT COUNT(DISTINCT dm.emp_no) AS manager_count "
f"FROM dept_manager dm "
f"JOIN employee e ON dm.emp_no = e.emp_no "
f"WHERE e.gender = '{_gv}' AND dm.to_date IS NULL;")
else:
_hint_sql = ("SELECT e.gender, COUNT(DISTINCT dm.emp_no) AS manager_count "
"FROM dept_manager dm "
"JOIN employee e ON dm.emp_no = e.emp_no "
"WHERE dm.to_date IS NULL "
"GROUP BY e.gender;")
print(f'[MICRO-HINT] Injecting manager gender hint (gender={_gv or "breakdown"})')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{_hint_sql}"
# 5) average salary by job title and department — hint-only (3-level architecture preserved)
if re.search(r"average salary by job title and department|average salary by title and department|avg salary.*title.*department|avg salary.*department.*title", question.lower()):
def gen_avg_salary_title_dept_hint():
required = ['title','dept_emp','department','salary']
if not all(t in available_tables for t in required):
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = ["d.dept_name", "t.title", f"AVG(s.{col}) AS avg_salary"]
joins = ["JOIN dept_emp de ON t.emp_no = de.emp_no", "JOIN department d ON de.dept_no = d.dept_no", f"JOIN salary s ON t.emp_no = s.emp_no"]
where = ["t.to_date IS NULL", "de.to_date IS NULL", "s.to_date IS NULL"]
return _build_sql(select_cols=select_cols, base_table="title t", joins=joins, where=where, group_by=["d.dept_name", "t.title"], order_by="d.dept_name, avg_salary DESC", limit=100)
v = gen_avg_salary_title_dept_hint()
if v:
print('[MICRO-HINT] Injecting avg salary by title+dept hint (3-level preserved)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 6) employees of each gender in management positions — hint-only (3-level preserved)
# L1 hallucinates WHERE title='MANAGER' on employee table (title is a separate table).
# Hint guides it to use dept_manager JOIN instead.
if re.search(r"employees of each gender.*management|how many employees of each gender are in management|gender.*management position|management.*gender", question.lower()):
def gen_gender_in_management_hint():
if not all(t in available_tables for t in ['dept_manager', 'employee']):
return None
select_cols = ["e.gender", "COUNT(DISTINCT dm.emp_no) AS count_in_management"]
joins = ["JOIN employee e ON dm.emp_no = e.emp_no"]
return _build_sql(select_cols=select_cols, base_table="dept_manager dm", joins=joins, where=["dm.to_date IS NULL"], group_by=["e.gender"])
v = gen_gender_in_management_hint()
if v:
print('[MICRO-HINT] Injecting gender-in-management hint (3-level preserved)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 7a) senior staff by department — hint-only
# L1 misses: (1) title table JOIN, (2) LIKE '%Senior%' filter → returns all depts with no senior filter
if re.search(r'(most senior staff|senior staff.*department|department.*senior staff|which department.*senior|senior employee.*department)', question.lower()):
def gen_senior_staff_by_dept():
required = ['title', 'dept_emp', 'department']
if not all(t in available_tables for t in required):
return None
select_cols = ["d.dept_name", "COUNT(DISTINCT t.emp_no) AS senior_count"]
joins = ["JOIN dept_emp de ON t.emp_no = de.emp_no", "JOIN department d ON de.dept_no = d.dept_no"]
where = ["t.title LIKE '%Senior%'", "t.to_date IS NULL", "de.to_date IS NULL"]
return _build_sql(select_cols=select_cols, base_table="title t", joins=joins, where=where, group_by=["d.dept_name"], order_by="senior_count DESC", limit=100)
v = gen_senior_staff_by_dept()
if v:
print('[MICRO-HINT] Injecting senior-staff-by-dept hint (3-level preserved)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 7b) gender pay gap per department: construct AVG male/female and gap percentage
# Extended regex to cover paraphrased phrasings — Qwen 1.5b fails on these without hints
if re.search(
r"gender pay gap|pay gap.*gender|gender.*pay gap"
r"|salary gap.*gender|salary gap.*(male|female)|salary gap.*department"
r"|salary difference.*(male|female)|salary difference.*gender"
r"|male.*female.*salary|female.*male.*salary"
r"|salary.*comparison.*(male|female|gender)"
r"|(male|female).*salary.*difference|(male|female).*pay.*difference"
r"|average salary.*gender|salary.*by gender",
question.lower()
):
def gen_gender_pay_gap():
required = ['department', 'dept_emp', 'employee', 'salary']
if not all(t in available_tables for t in required):
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = [
"d.dept_name",
f"AVG(CASE WHEN e.gender = 'M' THEN s.{col} END) AS avg_male_salary",
f"AVG(CASE WHEN e.gender = 'F' THEN s.{col} END) AS avg_female_salary",
f"(AVG(CASE WHEN e.gender = 'M' THEN s.{col} END) - AVG(CASE WHEN e.gender = 'F' THEN s.{col} END)) AS gap",
f"CASE WHEN AVG(CASE WHEN e.gender = 'M' THEN s.{col} END) = 0 THEN NULL ELSE ((AVG(CASE WHEN e.gender = 'M' THEN s.{col} END) - AVG(CASE WHEN e.gender = 'F' THEN s.{col} END)) / NULLIF(AVG(CASE WHEN e.gender = 'M' THEN s.{col} END),0)) * 100 END AS pct_gap"
]
joins = [
"JOIN dept_emp de ON d.dept_no = de.dept_no",
"JOIN employee e ON de.emp_no = e.emp_no",
"JOIN salary s ON e.emp_no = s.emp_no"
]
where = ["de.to_date IS NULL", "s.to_date IS NULL"]
sql = _build_sql(select_cols=select_cols, base_table="department d", joins=joins, where=where, group_by=["d.dept_name"], order_by="gap DESC", limit=100)
# Validate before using as hint
try:
val = enhanced_validate_sql(sql)
if val.get('valid'):
print(f"[MICRO-HINT] Built gender pay gap SQL using column '{col}': {sql}")
return sql
else:
print(f"[MICRO-HINT] Gender pay gap SQL failed validation: {val.get('issues')}")
return None
except Exception as e:
print(f"[MICRO-HINT] Error validating gender pay gap SQL: {e}")
return None
v = gen_gender_pay_gap()
if v:
print('[MICRO-HINT] Using micro-generated gender pay gap SQL as LLM hint only.')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 7c) Trend over time / over the years (temporal aggregation)
# NOTE: No hardcoded SQL hints here — the semantic clarifier at line ~3511 already rewrites
# the question with explicit EXTRACT(YEAR FROM ...) and GROUP BY instructions, which is
# sufficient for the LLM. Hardcoded hints were removed to avoid schema coupling.
# The validator fix (strip EXTRACT from table-scan) ensures valid EXTRACT queries pass.
if re.search(r"trend over the years|changed over time|over time|over the years|by year|per year", question.lower()):
print('[MICRO-HINT] Temporal trend query detected — semantic clarifier will inject EXTRACT(YEAR) instruction.')
# 8) maximum and minimum salary overall or for a specific department (user question may use max/min or maximum/minimum)
ql = question.lower()
if re.search(r'(max(imum)?\s+and\s+min(imum)?|highest\s+and\s+lowest).*salary', ql):
def gen_global_max_min():
if 'salary' not in available_tables:
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
sql = build_sql([
f"MAX(s.{col}) AS max_salary",
f"MIN(s.{col}) AS min_salary"
], base_table='salary s', where=["s.to_date IS NULL"], limit=None)
try:
val = enhanced_validate_sql(sql)
if val.get('valid'):
print('[MICRO-HINT] Built global max/min salary SQL (used as LLM hint only)')
return sql
else:
print(f"[MICRO-HINT] Global max/min salary SQL failed validation: {val.get('issues')}")
return None
except Exception as e:
print(f"[MICRO-HINT] Error validating global max/min salary SQL: {e}")
return None
# Department-specific variant if a department name appears
dept_name = None
# Collect known dept names dynamically if possible
try:
if 'department' in available_tables:
dept_rows = []
# Attempt simple query to fetch names (guarded; optional)
# Avoid asynchronous complexity here; just rely on common names fallback if fails
dept_rows = ["Development","Sales","Marketing","Finance","Human Resources","Research","Production"]
except Exception:
pass
# Heuristic extraction: look for word ending with 'ment' etc or known names
for candidate in ["development","sales","marketing","finance","human resources","research","production"]:
if candidate in ql:
dept_name = candidate.title()
if dept_name.lower() == 'Human Resources':
dept_name = 'Human Resources'
break
def gen_dept_max_min(dept):
required = ['department','dept_emp','salary']
if not all(t in available_tables for t in required):
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = ["d.dept_name", f"MAX(s.{col}) AS max_salary", f"MIN(s.{col}) AS min_salary"]
joins = ["JOIN dept_emp de ON d.dept_no = de.dept_no", "JOIN salary s ON de.emp_no = s.emp_no"]
where = ["de.to_date IS NULL", "s.to_date IS NULL"]
if dept:
safe_dept = dept.replace("'", "''")
where.append(f"d.dept_name = '{safe_dept}'")
sql = build_sql(select_cols=select_cols, base_table='department d', joins=joins, where=where, group_by=["d.dept_name"], limit=1 if dept else None)
try:
val = enhanced_validate_sql(sql)
if val.get('valid'):
print('[MICRO-HINT] Built dept max/min salary SQL (used as LLM hint only)')
return sql
else:
print(f"[MICRO-HINT] Dept max/min salary SQL failed validation: {val.get('issues')}")
return None
except Exception as e:
print(f"[MICRO-HINT] Error validating dept max/min salary SQL: {e}")
return None
sql_mm = gen_dept_max_min(dept_name) if dept_name else gen_global_max_min()
if sql_mm:
print('[MICRO-HINT] Using micro-generated max/min salary SQL as LLM hint only.')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{sql_mm}"
except Exception:
# On any failure, fall back to normal LLM generation
pass
max_retries = 3 # Increased to 3 retries with progressive timeout increases
# Semantic clarification for ambiguous terms
# Transform ambiguous natural language into clearer, more specific terms
clarified_question = question
# "salary range" should be interpreted as "maximum and minimum salary"
if re.search(r'\bsalary range\b', question, re.IGNORECASE):
clarified_question = re.sub(r'\bsalary range\b', 'maximum and minimum salary', clarified_question, flags=re.IGNORECASE)
print(f"[SEMANTIC_CLARIFICATION] 'salary range' → 'maximum and minimum salary'")
# "pay range" should also be interpreted as "maximum and minimum salary"
if re.search(r'\bpay range\b', question, re.IGNORECASE):
clarified_question = re.sub(r'\bpay range\b', 'maximum and minimum salary', clarified_question, flags=re.IGNORECASE)
print(f"[SEMANTIC_CLARIFICATION] 'pay range' → 'maximum and minimum salary'")
# "compensation range" should be interpreted as "maximum and minimum salary"
if re.search(r'\bcompensation range\b', question, re.IGNORECASE):
clarified_question = re.sub(r'\bcompensation range\b', 'maximum and minimum salary', clarified_question, flags=re.IGNORECASE)
print(f"[SEMANTIC_CLARIFICATION] 'compensation range' -> 'maximum and minimum salary'")
# "trend over the years" / "hiring trend" / "trend by year" / "changed over time"
# should be interpreted as an annual aggregate query, NOT individual rows.
if re.search(r'\b(trend|trends|change|changed|changing)\b.{0,40}\b(year|years|annual|annually|time|history)\b', question, re.IGNORECASE) or \
re.search(r'\b(year|years|annual|time|history)\b.{0,40}\b(trend|trends|change|changed|changing)\b', question, re.IGNORECASE) or \
re.search(r'\bover time\b', question, re.IGNORECASE):
# Detect the subject (hiring vs salary)
if re.search(r'\bsalar(y|ies)\b', question, re.IGNORECASE):
clarified_question = (
f"Show the average salary per year, "
f"grouped by year using EXTRACT(YEAR FROM from_date), ordered by year. "
f"Original question: {clarified_question}"
)
elif re.search(r'\bhir(e|ed|ing)\b', question, re.IGNORECASE):
clarified_question = (
f"Show the count of employees hired per year, "
f"grouped by year using EXTRACT(YEAR FROM hire_date), ordered by year. "
f"Original question: {clarified_question}"
)
else:
clarified_question = (
f"Show the count of records per year, "
f"grouped by year using EXTRACT(YEAR FROM from_date), ordered by year. "
f"Original question: {clarified_question}"
)
print(f"[SEMANTIC_CLARIFICATION] 'trend over time' -> annual aggregate (GROUP BY year)")
# Use the clarified question for the rest of the processing
if clarified_question != question:
print(f"[SEMANTIC_CLARIFICATION] Original: {question}")
print(f"[SEMANTIC_CLARIFICATION] Clarified: {clarified_question}")
question = clarified_question
# DISABLED: Eager micro-generator (now runs lazily inside Level 2)
# This code previously ran BEFORE Level 1, wasting ~5s when Level 1 succeeded.
# Now micro-gen is called on-demand inside level_2_llm_with_fixer() via generate_micro_gen_hint()
micro_hint = None # Keep variable for backward compatibility, but always None
# # Special micro-generator for salary range in department questions
# # This should trigger early to provide the correct MIN/MAX aggregation
# if re.search(r'\b(maximum and minimum|max.*min|min.*max|salary range)\b.*\b(development|dept|department)\b', question.lower()):
# print("[MICRO-GEN] Detected salary range in department question, generating specific SQL")
# try:
# # Extract department name
# dept_name = "Development" # Default
# if "development" in question.lower():
# dept_name = "Development"
# elif "sales" in question.lower():
# dept_name = "Sales"
# elif "marketing" in question.lower():
# dept_name = "Marketing"
# elif "finance" in question.lower():
# dept_name = "Finance"
# elif "human resources" in question.lower():
# dept_name = "Human Resources"
# elif "research" in question.lower():
# dept_name = "Research"
# elif "production" in question.lower():
# dept_name = "Production"
#
# # Generate the correct SQL for salary range by department
# range_sql = (
# f"SELECT MAX(s.salary) AS max_salary, MIN(s.salary) AS min_salary "
# f"FROM salary s "
# f"JOIN employee e ON s.emp_no = e.emp_no "
# f"JOIN dept_emp de ON e.emp_no = de.emp_no "
# f"JOIN department d ON de.dept_no = d.dept_no "
# f"WHERE d.dept_name = '{dept_name}' "
# f"AND s.to_date IS NULL "
# f"AND de.to_date IS NULL;"
# )
# print(f"[MICRO-GEN] Generated salary range SQL for {dept_name}: {range_sql}")
# micro_hint = range_sql
# except Exception as e:
# print(f"[MICRO-GEN] Failed to generate salary range SQL: {e}")
# Build a salary hint prefix if needed (keeps lines tidy and avoids complex concatenation in the dict)
salary_prefix = (salary_rank_hint + "\n") if salary_rank_hint else ""
# If a micro-generated SQL is available, add it as a strong hint to the LLM prompt
micro_hint_text = f"\n🎯 CRITICAL TEMPLATE - FOLLOW EXACTLY:\n{micro_hint}\n\nThis is the EXACT SQL pattern you MUST use. Do NOT generate a different query structure. Adapt ONLY the column names if needed but keep the same aggregation logic.\n" if micro_hint else ""
# Special instructions for salary range questions
salary_range_instruction = ""
if "maximum and minimum" in question.lower() or "salary range" in question.lower():
salary_range_instruction = "\nIMPORTANT: For salary range questions, use MAX() and MIN() aggregation functions, not individual salary listings. Filter by the specific department mentioned.\n"
prompt0 = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{salary_prefix}You are an SQL expert. Convert the natural language question into a SINGLE valid PostgreSQL query.\n"
f"Question: {question}\n"
f"Available tables: {', '.join(relevant_tables)}\n"
f"Table schemas (columns):\n{schema_text}\n"
f"Hints: {query_hints if query_hints else 'Use only existing tables/columns.'}\n"
f"{salary_range_instruction}"
f"{micro_hint_text}"
"Rules: Do NOT invent column names. Prefer existing columns from provided schemas. End with LIMIT 100; and a semicolon. Return ONLY raw SQL."
"\n\nIMPORTANT: If a template SQL is provided above, follow its EXACT structure and logic. Do not generate a different query pattern."
)
prompt1 = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{salary_prefix}Question: {question}\n"
f"Tables: {', '.join(relevant_tables)} (columns listed above may have been seen)\n"
f"{micro_hint_text}"
"Return ONLY SQL (PostgreSQL) ending with LIMIT 100; Avoid inventing columns."
"\n\nCRITICAL: If a template SQL is shown above with 🎯, copy its structure exactly."
)
prompt2 = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{micro_hint_text}"
f"SELECT -- refinement attempt\n-- Question: {question}\n-- Use ONLY actual columns. LIMIT 100 at end.\n"
"-- CRITICAL: Follow the 🎯 template structure above exactly if provided."
)
# If plan present, first attempt will use plan-specific prompt; otherwise fallback to tiered prompts
prompts = {}
if plan_dict:
prompts[0] = _build_sql_prompt_from_plan(plan_dict)
# Provide a refined shorter second attempt without full schema (adaptive shrinking)
minimal_schema_text = "\n".join(_collect_schema_snippets(relevant_tables, per_table_col_cap=6))
prompts[1] = prompts[0] + f"\n-- Minimal schema view:\n{minimal_schema_text}\n"
prompts[2] = prompt2 # fallback generic refinement
else:
prompts = {0: prompt0, 1: prompt1, 2: prompt2}
# ===============================================================================
# 3-LEVEL PROGRESSIVE ENHANCEMENT (replaces retry loop)
# ===============================================================================
# Try Level 1 → Level 2 → Level 3 in strict sequence
# Each level gets ONE attempt. No retries, no loops, no backtracking.
# ===============================================================================
base_llm = Settings.llm
ensure_llm_ready() # Make sure LLM is initialized
# Helper function to apply semantic adjusters (extracted from original retry loop)
def apply_semantic_adjusters(sql: str, q: str, norm_title) -> str:
"""Apply semantic post-fixes to generated SQL."""
sql = adjust_title_query(sql, q, norm_title)
sql = adjust_manager_query(sql, q)
sql = adjust_department_query(sql, q)
sql = adjust_avg_salary_department_query(sql, q)
# DISABLED: This was overriding LLM's correct SQL with department filters
# sql = adjust_employee_count_by_department_query(sql, q)
return sql
print("="*80)
print("[3-LEVEL ARCHITECTURE] Starting progressive SQL generation")
print("="*80)
# Level 1: Pure LLM (no assistance)
sql_query = level_1_pure_llm(
question=question,
schema_text=schema_text,
relevant_tables=relevant_tables,
base_llm=base_llm,
timeout=FIRST_ATTEMPT_TIMEOUT
)
if sql_query:
sql_query = apply_semantic_adjusters(sql_query, question, normalized_title)
print(f"[3-LEVEL] ✓ SUCCESS at Level 1 (Pure LLM)")
print(f"[GEN] Final SQL: {sql_query}")
return sql_query
print("[3-LEVEL] Level 1 failed, escalating to Level 2...")
# Level 2: LLM + Micro-gen hints + SQL Fixer (generates hint lazily)
sql_query = level_2_llm_with_fixer(
question=question,
schema_text=schema_text,
relevant_tables=relevant_tables,
base_llm=base_llm,
timeout=SECOND_ATTEMPT_TIMEOUT
)
if sql_query:
sql_query = apply_semantic_adjusters(sql_query, question, normalized_title)
print(f"[3-LEVEL] ✓ SUCCESS at Level 2 (LLM + Micro-gen + Fixer)")
print(f"[GEN] Final SQL: {sql_query}")
return sql_query
print("[3-LEVEL] Level 2 failed, escalating to Level 3 (final attempt)...")
# Level 3: LLM + MCP Hints + SQL Fixer (final escalation)
sql_query = level_3_mcp_hints_llm_fixer(
question=question,
schema_text=schema_text,
relevant_tables=relevant_tables,
base_llm=base_llm,
timeout=THIRD_ATTEMPT_TIMEOUT
)
if sql_query:
sql_query = apply_semantic_adjusters(sql_query, question, normalized_title)
print(f"[3-LEVEL] ✓ SUCCESS at Level 3 (LLM + MCP Hints + Fixer)")
print(f"[GEN] Final SQL: {sql_query}")
return sql_query
# All 3 levels failed
print("[3-LEVEL] ✗ All 3 levels failed to generate valid SQL")
# Deterministic last resort: the MCP fast-SQL hint produced during Level 3
# is schema-true and question-matched. If all LLM calls failed because of
# provider outage / exhausted credits, return it instead of erroring out.
if LAST_MCP_HINT_SQL:
try:
if is_sql_valid(LAST_MCP_HINT_SQL, question):
print("[3-LEVEL] ✓ FALLBACK: using deterministic MCP fast-SQL hint as final answer")
print(f"[GEN] Final SQL: {LAST_MCP_HINT_SQL[:200]}")
return LAST_MCP_HINT_SQL
except Exception as fb_err:
print(f"[3-LEVEL] MCP fast-SQL fallback skipped: {fb_err}")
raise ValueError("Unable to generate SQL query after 3-level attempts; try rephrasing.")
# salvage path (unreachable due to raise above, kept for reference)
def fix_query(query: str, reason: str) -> str:
"""Fix issues with a SQL query."""
# Add LIMIT if missing
if "limit" not in query.lower():
if query.strip().endswith(';'):
query = query[:-1] + " LIMIT 100;"
else:
query += " LIMIT 100"
return query
def build_sql(select_cols: List[str], base_table: str, joins: Optional[List[str]] = None,
where: Optional[List[str]] = None, group_by: Optional[List[str]] = None,
order_by: Optional[str] = None, limit: Optional[int] = None) -> str:
"""Module-level SQL builder to assemble SQL strings from components.
Use this instead of embedding literal SQL strings.
"""
parts: List[str] = []
parts.append("SELECT " + ", ".join(select_cols))
parts.append("FROM " + base_table)
if joins:
for j in joins:
parts.append(j)
if where:
parts.append("WHERE " + " AND ".join(where))
if group_by:
parts.append("GROUP BY " + ", ".join(group_by))
if order_by:
parts.append("ORDER BY " + order_by)
if limit:
parts.append(f"LIMIT {limit}")
sql = " ".join(parts)
if not sql.strip().endswith(';'):
sql = sql + ';'
return sql
## NOTE: Hardcoded fallback SQL function removed to honor no-hardcoded-SQL policy.
## All SQL is generated by LLM or micro-generators as hints only.
def fix_common_sql_errors(query: str) -> str:
"""Fix common SQL errors that are seen in LLM-generated queries."""
# Debug: print SQL before fixing
try:
print("[DEBUG][SQL_FIXER][BEFORE]", query)
except Exception:
pass
# Safety check: If the input doesn't look like SQL at all, return it unchanged
# This prevents the fixer from corrupting non-SQL text responses
if not query or not isinstance(query, str):
return query
query_lower = query.lower().strip()
# Check if it contains SQL keywords
sql_keywords = ['select', 'from', 'where', 'join', 'group by', 'order by', 'having', 'with']
has_sql_keywords = any(keyword in query_lower for keyword in sql_keywords)
# If it doesn't contain SQL keywords and looks like prose, return unchanged
if not has_sql_keywords and len(query.split()) > 10:
print("[DEBUG][SQL_FIXER] Input doesn't appear to be SQL, skipping fixes")
return query
import re # Ensure re is imported at the top
# Quick sanitization: remove accidental literal tokens written by LLMs such as the word
# 'semicolon' (models sometimes append the word 'semicolon' instead of a real ';').
# Also collapse duplicate semicolons and trim whitespace before semicolons.
try:
# Replace patterns like ';semicolon;' 'semicolon;' '; semicolon' or lone 'semicolon' with a single ';'
query = re.sub(r';?\s*semicolon\s*;?', ';', query, flags=re.IGNORECASE)
# Collapse repeated semicolons into a single one
query = re.sub(r';{2,}', ';', query)
# Remove spaces before semicolon
query = re.sub(r'\s+;', ';', query)
query = query.strip()
except Exception:
# If anything goes wrong here, continue without failing the sanitizer
pass
# Strip LLM explanation/commentary that appears AFTER the SQL semicolon
# Pattern: "; <blank line(s)> <explanation text>", e.g. ";\n\nThis query works as follows:"
# Also handles prose like ";\n\nNote:", ";\n\nThe above query:", etc.
try:
semi_then_blank = re.search(r';\s*\n\s*\n', query)
if semi_then_blank:
query = query[:semi_then_blank.start() + 1]
print("[DEBUG][SQL_FIXER][STRIP_EXPLANATION] Stripped LLM explanation text after semicolon")
except Exception:
pass
# Step 1: Fix table names
for singular, plural in [
("department", "departments"),
("employee", "employees"),
("salary", "salaries"),
("title", "titles"),
("dept_emp", "dept_emps"),
("dept_manager", "dept_managers")
]:
if singular in available_tables and plural in query:
query = query.replace(plural, singular)
# Step 2: Fix misnamed columns
for wrong, correct in [
("department_id", "dept_no"),
("employee_id", "emp_no"),
("manager_id", "emp_no"),
("emp_id", "emp_no"),
("dept_id", "dept_no"),
("id", "emp_no"),
("s.amount", "s.salary"), # Fix hallucinated salary column name
("salary.amount", "salary.salary"), # Fix without alias too
("s.pay", "s.salary"), # Another common hallucination
("s.wage", "s.salary"), # Another common hallucination
("e.name", "e.first_name"), # Fix employee name hallucination
("d.name", "d.dept_name"), # Fix department name hallucination (also handled elsewhere)
("t.name", "t.title") # Fix title name hallucination
]:
if wrong in query:
query = query.replace(wrong, correct)
# Step 2b: Fix dept_no used with a department NAME value instead of a dept code
# e.g. "WHERE dept_no = 'Sales'" → "WHERE dept_no IN (SELECT dept_no FROM department WHERE dept_name = 'Sales')"
# Department codes look like 'd001'...'d009'. If the value is a word, it's a name not a code.
try:
dept_no_name_pattern = re.search(
r"\bdept_no\s*=\s*'([A-Za-z][A-Za-z\s&]*)'",
query, re.IGNORECASE
)
if dept_no_name_pattern:
bad_value = dept_no_name_pattern.group(1)
# Only fix if it looks like a dept name (not already a code like 'd007')
if not re.match(r'^d\d+$', bad_value.strip(), re.IGNORECASE):
correct_subquery = f"dept_no IN (SELECT dept_no FROM department WHERE dept_name = '{bad_value}')"
query = re.sub(
r"\bdept_no\s*=\s*'" + re.escape(bad_value) + r"'",
correct_subquery,
query, flags=re.IGNORECASE
)
print(f"[DEBUG][SQL_FIXER][DEPT_NO_NAME_FIX] Replaced dept_no = '{bad_value}' with subquery lookup")
except Exception:
pass
# Step 3: JOIN syntax repairs (pure regex replacements, no external state)
join_fix_patterns = [
(r'JOIN\s+([a-z][a-z0-9_]*)\s+ON\s+JOIN\.([a-z][a-z0-9_]*)', r'JOIN \1 ON \1.\2'),
(r'JOIN\s+([a-z][a-z0-9_]*)\s+ON\s+([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)\s*=\s*JOIN\.([a-z][a-z0-9_]*)', r'JOIN \1 ON \2.\3 = \1.\4'),
(r'JOIN\.([a-z][a-z0-9_]*)', r'd.\1')
]
for pattern, replacement in join_fix_patterns:
try:
query = re.sub(pattern, replacement, query, flags=re.IGNORECASE)
except Exception:
pass
# Step 4: alias normalization - fix the broken pattern that creates "s AS s"
print("[DEBUG][SQL_FIXER][ALIAS_NORM][BEFORE]", query)
# First, fix any existing "table alias AS alias" patterns to just "table alias"
# This fixes patterns like "FROM salary s AS s" -> "FROM salary s"
query = re.sub(r'\b(\w+)\s+(\w+)\s+AS\s+\2\b', r'\1 \2', query, flags=re.IGNORECASE)
# Then normalize "FROM table AS alias" to "FROM table alias"
for table, alias in [("department","d"),("employee","e"),("salary","s"),("dept_emp","de"),("dept_manager","dm"),("titles","t")]:
query = re.sub(rf'\bFROM\s+{table}\s+AS\s+{alias}\b', f'FROM {table} {alias}', query, flags=re.IGNORECASE)
query = re.sub(rf'\bJOIN\s+{table}\s+AS\s+{alias}\b', f'JOIN {table} {alias}', query, flags=re.IGNORECASE)
if f"{table} {alias}" in query and f"{table}." in query:
print(f"[DEBUG][SQL_FIXER][ALIAS_NORM][REPLACE] {table}. -> {alias}.")
query = query.replace(f"{table}.", f"{alias}.")
if f"{table} AS {alias}" in query and f"{table}." in query:
print(f"[DEBUG][SQL_FIXER][ALIAS_NORM][REPLACE] {table}. -> {alias}.")
query = query.replace(f"{table}.", f"{alias}.")
print("[DEBUG][SQL_FIXER][ALIAS_NORM][AFTER]", query)
# (Removed self-join fixer logic; reverted to previous state)
# Step 5: to_date misuse corrections
if "employee" in query and "e.to_date" in query:
if "salary s" in query or "s.salary" in query:
query = query.replace("e.to_date = '9999-01-01'", "s.to_date = '9999-01-01'")
elif "dept_emp de" in query or "de.dept_no" in query:
query = query.replace("e.to_date = '9999-01-01'", "de.to_date = '9999-01-01'")
elif "dept_manager dm" in query or "dm.dept_no" in query:
query = query.replace("e.to_date = '9999-01-01'", "dm.to_date = '9999-01-01'")
if "department" in query.lower():
# Robustly remove to_date from department table as it doesn't exist there
# Handles d.to_date, department.to_date, and various whitespace/casing
for dept_ref in ["d", "department"]:
# Pattern: AND/WHERE [alias.]to_date = '9999-01-01'
# 1. Handle as part of a larger WHERE clause (with AND)
query = re.sub(rf"\bAND\s+{dept_ref}\.to_date\s*=\s*'9999-01-01'", "", query, flags=re.IGNORECASE)
query = re.sub(rf"\b{dept_ref}\.to_date\s*=\s*'9999-01-01'\s+AND\b", "", query, flags=re.IGNORECASE)
# 2. Handle as the only clause in WHERE
query = re.sub(rf"\bWHERE\s+{dept_ref}\.to_date\s*=\s*'9999-01-01'", "", query, flags=re.IGNORECASE)
# If we removed the only WHERE clause, we might have leftover spacing issues
query = re.sub(r"\bWHERE\s*LIMIT\b", "LIMIT", query, flags=re.IGNORECASE)
query = re.sub(r"\bWHERE\s*GROUP\b", "GROUP", query, flags=re.IGNORECASE)
query = re.sub(r"\bWHERE\s*ORDER\b", "ORDER", query, flags=re.IGNORECASE)
query = re.sub(r"\bWHERE\s*;", ";", query, flags=re.IGNORECASE)
# Early detection: pure salary extremes aggregate (avoid over-sanitizing)
_q_lower = query.lower().strip()
simple_extremes = False
if _q_lower.startswith('select') and 'min(' in _q_lower and 'max(' in _q_lower and ' from salary' in _q_lower and ' join ' not in _q_lower:
simple_extremes = True
# Step 6: ensure a conservative LIMIT to protect UI if missing (skip for pure aggregate extremes)
try:
has_limit = re.search(r"\blimit\s+\d+\b", query, flags=re.IGNORECASE) is not None
except Exception:
has_limit = " limit " in query.lower()
FORCE_LIMIT = os.environ.get("FORCE_LIMIT_APPEND", "0") in ("1","true","True")
if (FORCE_LIMIT or (not simple_extremes)) and "select" in query.lower() and not has_limit:
if query.strip().endswith(';'):
query = query[:-1] + " LIMIT 100;"
else:
if not query.strip().endswith(';'):
query = query.rstrip(';')
query += " LIMIT 100;"
# Collapse duplicate LIMITs that may already exist due to earlier generations
try:
query = re.sub(r"(LIMIT\s+\d+)(?:\s+LIMIT\s+\d+)+", r"\1", query, flags=re.IGNORECASE)
except Exception:
pass
# Inject missing alias t if needed
if re.search(r"from\s+title\b", query, re.IGNORECASE) and re.search(r"t\.(?:emp_no|title|to_date)", query, re.IGNORECASE) and not re.search(r"from\s+title\s+(?:as\s+)?t\b", query, re.IGNORECASE):
query = re.sub(r"from\s+title\b", "FROM title t", query, flags=re.IGNORECASE)
if not query.strip().endswith(';'):
query += ";"
# Secondary duplicate LIMIT cleanup in case edits added a new one before semicolon
try:
query = re.sub(r"(LIMIT\s+\d+)(?:\s+LIMIT\s+\d+)+", r"\1", query, flags=re.IGNORECASE)
except Exception:
pass
print("[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][BEFORE]", query)
if "dde." in query:
print("[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][REPLACE] dde. -> de.")
query = query.replace("dde.", "de.")
for original, typo in [("de.","dee."),("de.","dp."),("dm.","ddm."),("dm.","dmm."),("e.","ee."),("e.","emp."),("d.","dd."),("d.","dep."),("s.","ss."),("s.","sal.")]:
if typo in query:
print(f"[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][REPLACE] {typo} -> {original}")
query = query.replace(typo, original)
for wrong, right in [("department_id","dept_no"),("employee_id","emp_no"),("manager_id","emp_no")]:
if wrong in query:
print(f"[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][REPLACE] {wrong} -> {right}")
query = query.replace(wrong, right)
print("[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][AFTER]", query)
# Step 7: JOIN/ON artifact cleanup
if " ON." in query:
query = re.sub(r'([a-z][a-z0-9_]*)\s+ON\s+([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)\s*=\s*ON\.([a-z][a-z0-9_]*)', r'\1 ON \2.\3 = \1.\4', query, flags=re.IGNORECASE)
if " ON." in query:
for alias in ["dm","de","e","d","s","t"]:
if f" {alias} ON" in query and f" ON.{alias}" not in query:
query = query.replace(" ON.", f" {alias}.")
if "JOIN." in query:
if "JOIN dept_manager dm ON JOIN.dept_no = dm.dept_no" in query:
query = query.replace("JOIN dept_manager dm ON JOIN.dept_no = dm.dept_no", "JOIN dept_manager dm ON d.dept_no = dm.dept_no")
if "department d\nJOIN\n dept_manager dm ON JOIN.dept_no = dm.dept_no" in query:
query = query.replace("department d\nJOIN\n dept_manager dm ON JOIN.dept_no = dm.dept_no", "department d\nJOIN\n dept_manager dm ON d.dept_no = dm.dept_no")
if "department d\nJOIN\n dept_manager dm ON JOIN." in query:
query = query.replace("department d\nJOIN\n dept_manager dm ON JOIN.", "department d\nJOIN\n dept_manager dm ON d.")
query = re.sub(r'([a-z][a-z0-9_]*)\s+ON\s+JOIN\.([a-z][a-z0-9_]*)\s*=\s*([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)', r'\1 ON \1.\2 = \3.\4', query, flags=re.IGNORECASE)
query = re.sub(r'([a-z][a-z0-9_]*)\s+ON\s+([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)\s*=\s*JOIN\.([a-z][a-z0-9_]*)', r'\1 ON \2.\3 = \1.\4', query, flags=re.IGNORECASE)
if "JOIN." in query:
tables_with_aliases = re.findall(r'([a-z][a-z0-9_]*)\s+([a-z][a-z0-9_]*)(?=\s+ON\s+JOIN\.)', query, flags=re.IGNORECASE)
if tables_with_aliases:
last_alias = tables_with_aliases[-1][1]
query = query.replace("JOIN.", f"{last_alias}.")
else:
for alias in ["dm","de","e","d","s","t"]:
if f"JOIN {alias}" in query:
query = query.replace("JOIN.", f"{alias}.")
if "JOIN." in query:
query = query.replace("JOIN.", "d.")
# REMOVED: Duplicate to_date correction logic (already handled in Step 5 above)
print("[DEBUG][SQL_FIXER][AFTER_TODATE_CORRECTION]", query)
# Add to_date conditions where appropriate based on the schema knowledge
# For any salary-related query that's missing the to_date condition
if "salary" in query.lower() and "s." in query and "s.to_date" not in query:
# If we're joining with employee table
if "employee e" in query or "e.emp_no" in query:
# Replace any incorrect e.to_date reference
if "e.to_date" in query:
query = query.replace("e.to_date = '9999-01-01'", "s.to_date = '9999-01-01'")
# Add missing to_date condition based on query structure
elif "WHERE" in query.upper():
query = re.sub(r'\bWHERE\b', "WHERE s.to_date = '9999-01-01' AND ", query, count=1, flags=re.IGNORECASE)
elif "LIMIT" in query.upper():
query = re.sub(r'\bLIMIT\b', "WHERE s.to_date = '9999-01-01' LIMIT", query, count=1, flags=re.IGNORECASE)
else:
query += " WHERE s.to_date = '9999-01-01'"
# Similarly for dept_emp table with 'de' alias
if "dept_emp" in query.lower() and "de." in query and "de.to_date" not in query:
if "WHERE" in query.upper():
query = re.sub(r'\bWHERE\b', "WHERE de.to_date = '9999-01-01' AND ", query, count=1, flags=re.IGNORECASE)
elif "LIMIT" in query.upper():
query = re.sub(r'\bLIMIT\b', "WHERE de.to_date = '9999-01-01' LIMIT", query, count=1, flags=re.IGNORECASE)
else:
query += " WHERE de.to_date = '9999-01-01'"
print("[DEBUG][SQL_FIXER][AFTER_DEPTEMP_TODATE]", query)
# Similarly for dept_manager table with 'dm' alias
if "dept_manager" in query.lower() and "dm." in query and "dm.to_date" not in query:
if "WHERE" in query.upper():
query = re.sub(r'\bWHERE\b', "WHERE dm.to_date = '9999-01-01' AND ", query, count=1, flags=re.IGNORECASE)
elif "LIMIT" in query.upper():
query = re.sub(r'\bLIMIT\b', "WHERE dm.to_date = '9999-01-01' LIMIT", query, count=1, flags=re.IGNORECASE)
else:
query += " WHERE dm.to_date = '9999-01-01'"
# Deduplicate repeated to_date conditions (e.g. AND s.to_date = '9999-01-01' AND s.to_date = '9999-01-01')
for alias in ['s', 'de', 'dm', 'e', 'd']:
dup_pattern = rf"(AND\s+{alias}\.to_date\s*=\s*'9999-01-01'\s*)(?:AND\s+{alias}\.to_date\s*=\s*'9999-01-01'\s*)+"
query = re.sub(dup_pattern, rf"AND {alias}.to_date = '9999-01-01' ", query, flags=re.IGNORECASE)
# Check for and fix unclosed quotes in date conditions
if "to_date=" in query:
# Look for unclosed date quotes with LIMIT inside the string
if "'9999-01-01 LIMIT" in query:
query = query.replace("'9999-01-01 LIMIT", "'9999-01-01' LIMIT")
# General check for other variations
for date_format in ["'9999-01-01", "'9999-01-01 AND", "'9999-01-01 WHERE", "'9999-01-01 GROUP", "'9999-01-01 ORDER"]:
if date_format in query and date_format + "'" not in query:
query = query.replace(date_format, date_format + "'")
# If there are multiple to_date conditions, ensure they all have closing quotes
if "to_date='9999-01-01 AND" in query:
query = query.replace("to_date='9999-01-01 AND", "to_date='9999-01-01' AND")
# Fix inconsistent table aliases (e.g., "dept_emp" used after "dept_emp de" was defined)
if "dept_emp de" in query and "dept_emp." in query:
query = query.replace("dept_emp.", "de.")
if "employee e" in query and "employee." in query:
query = query.replace("employee.", "e.")
if "department d" in query and "department." in query:
query = query.replace("department.", "d.")
if "salary s" in query and "salary." in query:
query = query.replace("salary.", "s.")
if "dept_manager dm" in query and "dept_manager." in query:
query = query.replace("dept_manager.", "dm.")
if "titles t" in query and "titles." in query:
query = query.replace("titles.", "t.")
# Self-join salary alias fix: only if detected, otherwise leave correct joins untouched
import re
salary_self_join_pattern = r'JOIN\s+salary\s+s\s+ON\s+s\.emp_no\s*=\s*s\.emp_no'
deptemp_self_join_pattern = r'JOIN\s+dept_emp\s+(?:AS\s+)?de\s+ON\s+de\.emp_no\s*=\s*de\.emp_no'
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_CHECK] Input query: {query[:200]}...')
# Only apply fix if self-join is present
if re.search(salary_self_join_pattern, query, re.IGNORECASE):
print('[DEBUG][SQL_FIXER][SELF_JOIN_SALARY] Found salary self-join pattern')
emp_alias_match = re.search(r'FROM\s+employee\s+(\w+)|JOIN\s+employee\s+(\w+)', query, re.IGNORECASE)
emp_alias = (emp_alias_match.group(1) or emp_alias_match.group(2)) if emp_alias_match else None
if emp_alias:
query = re.sub(salary_self_join_pattern, f'JOIN salary s ON s.emp_no = {emp_alias}.emp_no', query, flags=re.IGNORECASE)
print('[DEBUG][SQL_FIXER][SELF_JOIN_SALARY] Fixed self-join: replaced with join to employee alias')
else:
print('[DEBUG][SQL_FIXER][SELF_JOIN_SALARY] No salary self-join pattern found, skipping fix')
# Only fix if the join is exactly de.emp_no = de.emp_no and s.emp_no = de.emp_no is not already present
if re.search(deptemp_self_join_pattern, query, re.IGNORECASE):
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Found dept_emp self-join pattern')
# Check if a correct join already exists
correct_join_exists = re.search(r'JOIN\s+dept_emp\s+(?:AS\s+)?de\s+ON\s+s\.emp_no\s*=\s*de\.emp_no', query, re.IGNORECASE)
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Correct join exists: {bool(correct_join_exists)}')
if not correct_join_exists:
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] No correct join found, applying fix')
sal_alias_match = re.search(r'FROM\s+salary\s+(\w+)|JOIN\s+salary\s+(\w+)', query, re.IGNORECASE)
sal_alias = (sal_alias_match.group(1) or sal_alias_match.group(2)) if sal_alias_match else None
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Found salary alias: {sal_alias}')
if sal_alias:
old_query = query
query = re.sub(deptemp_self_join_pattern, f'JOIN dept_emp de ON {sal_alias}.emp_no = de.emp_no', query, flags=re.IGNORECASE)
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Fixed self-join: replaced with join to salary alias')
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] BEFORE: {old_query}')
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] AFTER: {query}')
else:
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Correct join already present, no fix applied')
else:
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] No self-join pattern found, skipping fix')
print(f'[DEBUG][SQL_FIXER][POST_SELF_JOIN] {query[:100]}...')
# If no self-join, do not touch correct joins
# Fix incorrect MIN/MAX alias syntax errors
if "MIN(" in query or "MAX(" in query:
# Fix syntax error with column = aggregate instead of AS alias
if "=" in query and ("MIN" in query or "MAX" in query):
import re
# Pattern: column_name = MIN(...)
query = re.sub(r'([a-zA-Z0-9_\.]+)\s*=\s*MIN\(([^)]+)\)', r'MIN(\2) AS \1_min', query)
query = re.sub(r'([a-zA-Z0-9_\.]+)\s*=\s*MAX\(([^)]+)\)', r'MAX(\2) AS \1_max', query)
# Pattern: table.column_name = MIN(...)
query = re.sub(r'([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*=\s*MIN\(([^)]+)\)', r'MIN(\3) AS \2_min', query)
query = re.sub(r'([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*=\s*MAX\(([^)]+)\)', r'MAX(\3) AS \2_max', query)
# Fix incorrect MIN/MAX alias syntax (column = aggregate_function instead of alias = aggregate_function)
if "MIN(" in query or "MAX(" in query:
# Look for pattern: table.column = MIN(...)
import re
query = re.sub(r'([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*=\s*(MIN|MAX)\(', r'\3(\1.\2) AS \2_\3', query)
# Also catch: column = MIN(...) without table prefix
query = re.sub(r'([a-zA-Z0-9_]+)\s*=\s*(MIN|MAX)\(', r'\2(\1) AS \1_\2', query)
# Fix GROUP BY queries that have COUNT in ORDER BY but not in SELECT
if "GROUP BY" in query.upper() and "ORDER BY" in query.upper() and "COUNT" in query.upper():
import re
# Check if COUNT is in ORDER BY but not in SELECT
if "ORDER BY COUNT" in query.upper() and "COUNT" not in query.upper().split("FROM")[0]:
# Extract the COUNT expression from ORDER BY
count_match = re.search(r'ORDER BY\s+COUNT\s*\(\s*([^)]+)\s*\)', query, re.IGNORECASE)
if count_match:
count_expr = count_match.group(1)
# Add the count to the SELECT clause
query = re.sub(
r'SELECT\s+(.+?)\s+FROM',
rf'SELECT \1, COUNT({count_expr}) AS employee_count FROM',
query,
flags=re.IGNORECASE
)
# Update the ORDER BY to use the alias
query = re.sub(
r'ORDER BY\s+COUNT\s*\([^)]+\)',
r'ORDER BY employee_count',
query,
flags=re.IGNORECASE
)
# Fix missing GROUP BY for aggregate queries (PostgreSQL strict mode)
if any(agg in query.upper() for agg in ['AVG(', 'SUM(', 'COUNT(', 'MAX(', 'MIN(']):
select_match = re.search(r'SELECT\s+(.*?)\s+FROM', query, re.IGNORECASE | re.DOTALL)
if select_match:
select_clause = select_match.group(1)
# Remove aggregate functions to find non-aggregated columns
remaining_select = re.sub(r'(COUNT|SUM|AVG|MAX|MIN)\s*\([^)]*\)', '', select_clause, flags=re.IGNORECASE)
remaining_select = re.sub(r'\s*AS\s+[a-z][a-z0-9_]*', '', remaining_select, flags=re.IGNORECASE)
# Find alias.column patterns first (e.g. d.dept_name, e.first_name)
dot_columns = re.findall(r'([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)', remaining_select, re.IGNORECASE)
# Also find bare column names (e.g. title, dept_name) for queries without alias prefix
_GBY_KW = {'where','group','order','limit','having','join','on','and','or','not',
'in','as','by','select','from','set','distinct','case','when','then',
'else','end','null','true','false','is','like'}
bare_columns = [t for t in re.findall(r'\b([a-z][a-z0-9_]*)\b', remaining_select, re.IGNORECASE)
if t.lower() not in _GBY_KW and not t.isdigit()]
columns = dot_columns if dot_columns else bare_columns
if columns and 'GROUP BY' not in query.upper():
# Deduplicate preserving order
seen = []
for c in columns:
if c.upper() not in [x.upper() for x in seen]:
seen.append(c)
group_cols = ', '.join(seen)
# Find insertion point: before ORDER BY, LIMIT, or semicolon
insertion_point = len(query)
for clause in [r'\bORDER\s+BY\b', r'\bLIMIT\b']:
m = re.search(clause, query, re.IGNORECASE)
if m and m.start() < insertion_point:
insertion_point = m.start()
semi = re.search(r'\s*;', query)
if semi and semi.start() < insertion_point:
insertion_point = semi.start()
query = query[:insertion_point] + f' GROUP BY {group_cols} ' + query[insertion_point:]
print(f"[DEBUG][SQL_FIXER][GROUP_BY_ADDED] Added GROUP BY {group_cols}")
# Fix incorrect date formatting in conditions
date_formats = [
(r"'(\d{4}/\d{2}/\d{2})'", lambda m: f"'{m.group(1).replace('/', '-')}'"), # YYYY/MM/DD to YYYY-MM-DD
(r"'(\d{2}/\d{2}/\d{4})'", lambda m: f"'{m.group(1)[6:]}-{m.group(1)[0:2]}-{m.group(1)[3:5]}'"), # MM/DD/YYYY to YYYY-MM-DD
]
for pattern, replacement in date_formats:
query = re.sub(pattern, lambda m: replacement(m), query)
# Extract all table alias definitions early to avoid UnboundLocalError
# Pattern matches table aliases only in FROM and JOIN clauses
alias_pattern = r'(?:FROM|JOIN)\s+([a-z][a-z0-9_]*)\s+(?:AS\s+)?([a-z][a-z0-9_]*)'
aliases = {}
for match in re.finditer(alias_pattern, query, re.IGNORECASE):
table_name, alias = match.groups()
if alias.lower() not in ['where', 'group', 'order', 'limit', 'on', 'join', 'from']:
aliases[table_name.lower()] = alias.lower()
# Extract all table alias definitions early to avoid UnboundLocalError
# Pattern matches table aliases only in FROM and JOIN clauses
alias_pattern = r'(?:FROM|JOIN)\s+([a-z][a-z0-9_]*)\s+(?:AS\s+)?([a-z][a-z0-9_]*)'
aliases = {}
for match in re.finditer(alias_pattern, query, re.IGNORECASE):
table_name, alias = match.groups()
if alias.lower() not in ['where', 'group', 'order', 'limit', 'on', 'join', 'from']:
aliases[table_name.lower()] = alias.lower()
# Fix wrong table aliases in JOIN conditions
if "JOIN" in query.upper():
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX_START] {query[:150]}...')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] Found aliases: {aliases}')
# Now check for wrong JOIN conditions
for table, alias in aliases.items():
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] Processing table={table}, alias={alias}')
# Pattern for incorrect join conditions like: ON table.column = other_alias.column
incorrect_join = rf'ON\s+{table}\.([a-z][a-z0-9_]*)\s*=\s*([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)'
old_query = query
query = re.sub(incorrect_join, f'ON {alias}.\\1 = \\2.\\3', query, flags=re.IGNORECASE)
if old_query != query:
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] Applied incorrect_join fix: {table} -> {alias}')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] BEFORE: {old_query[old_query.find("JOIN"):old_query.find("JOIN")+80]}...')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] AFTER: {query[query.find("JOIN"):query.find("JOIN")+80]}...')
# Pattern for the reverse direction
incorrect_join_reverse = rf'ON\s+([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)\s*=\s*{table}\.([a-z][a-z0-9_]*)'
old_query2 = query
query = re.sub(incorrect_join_reverse, f'ON \\1.\\2 = {alias}.\\3', query, flags=re.IGNORECASE)
if old_query2 != query:
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] Applied incorrect_join_reverse fix: {table} -> {alias}')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] BEFORE: {old_query2[old_query2.find("JOIN"):old_query2.find("JOIN")+80]}...')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] AFTER: {query[query.find("JOIN"):query.find("JOIN")+80]}...')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX_END] {query[:150]}...')
# Fix common table name/alias confusion in JOIN clauses (like "dept_manager.dept_no" instead of "dm.dept_no")
if "JOIN" in query and any(full_table in query for full_table in ["department.", "dept_manager.", "dept_emp.", "employee.", "salary.", "titles."]):
# Replace full table names with their aliases in JOIN conditions
# Use found aliases from aliases dict, falling back to defaults if not found
mapping = {
"department": aliases.get("department", "d"),
"dept_manager": aliases.get("dept_manager", "dm"),
"dept_emp": aliases.get("dept_emp", "de"),
"employee": aliases.get("employee", "e"),
"salary": aliases.get("salary", "s"),
"titles": aliases.get("titles", "t")
}
for table, alias in mapping.items():
if f"{table}." in query:
# Don't replace in the FROM or JOIN clauses where the alias is defined
parts = re.split(r'(FROM\s+|JOIN\s+)', query, flags=re.IGNORECASE)
result = []
for i, part in enumerate(parts):
if i > 0 and (parts[i-1].upper() == "FROM " or parts[i-1].upper() == "JOIN "):
# Check if this part contains the definition of an alias for this table
# e.g. "employee AS T1" or "employee T1"
alias_def = re.match(rf'^{table}\s+(?:AS\s+)?(\w+)', part, re.IGNORECASE)
if alias_def:
# This is where the alias is defined, don't replace here
result.append(part)
else:
result.append(part.replace(f"{table}.", f"{alias}."))
else:
result.append(part.replace(f"{table}.", f"{alias}."))
query = ''.join(result)
# Optional: allow disabling aggressive artifact heuristics (some regressions observed)
if os.environ.get("DISABLE_SANITIZER_ARTIFACT_HEURISTICS", "0") in ("1","true","True"):
# Skip the more invasive JOIN./ON. artifact rewrites
pass
else:
# Final cleanup - ensure no remaining JOIN. syntax errors with smarter mapping
if "JOIN." in query or " ON." in query:
try:
# Build alias preference ordering
alias_candidates = []
if re.search(r'\bdept_emp\s+de\b', query, re.IGNORECASE):
alias_candidates.append('de')
if re.search(r'\bdept_manager\s+dm\b', query, re.IGNORECASE):
alias_candidates.append('dm')
if re.search(r'\bemployee\s+e\b', query, re.IGNORECASE):
alias_candidates.append('e')
if re.search(r'\bdepartment\s+d\b', query, re.IGNORECASE):
alias_candidates.append('d')
if re.search(r'\bsalary\s+s\b', query, re.IGNORECASE):
alias_candidates.append('s')
# Fallback default alias if nothing else
if not alias_candidates:
alias_candidates = ['d','e','s','de','dm']
def _map_join_artifact(m):
col = m.group(1)
return f"{alias_candidates[0]}.{col}"
# Replace join.col or ON.col artifacts
query = re.sub(r'\bjoin\.([a-z][a-z0-9_]*)', lambda m: _map_join_artifact(m), query, flags=re.IGNORECASE)
query = re.sub(r'\bON\.([a-z][a-z0-9_]*)', lambda m: _map_join_artifact(m), query, flags=re.IGNORECASE)
# Fix pathological ON alias self-compare: ON join.emp_no = join.emp_no -> try map to distinct aliases
query = re.sub(r'ON\s+join\.([a-z0-9_]+)\s*=\s*join\.([a-z0-9_]+)', lambda m: f"ON {alias_candidates[0]}.{m.group(1)} = {alias_candidates[1] if len(alias_candidates)>1 else alias_candidates[0]}.{m.group(2)}", query, flags=re.IGNORECASE)
except Exception:
pass
# Smart alias injection: only if table is present WITHOUT ANY ALIAS
# Fix for the "de AS T2" bug: ensure we don't inject 'de' if 'T2' or any other alias is already there
for table, default_alias in [("salary", "s"), ("dept_emp", "de"), ("employee", "e"), ("department", "d")]:
if table in query.lower() and table.lower() not in aliases:
# If table is mentioned but not in our alias map, it might be unaliased
# Use a regex that checks if the table name is followed by something that looks like an alias
# We only want to inject if it's "FROM table" or "JOIN table" followed by a keyword or punctuation
pattern = rf'(FROM|JOIN)\s+{table}\b(?!\s+(?:AS\s+)?\w+)'
query = re.sub(pattern, rf'\1 {table} {default_alias}', query, flags=re.IGNORECASE)
# Fix the specific T1.salary / employee.salary hallucination
# If 'salary' is in SELECT but NOT in FROM/JOIN, we MUST join it.
if re.search(r'SELECT\b.*?\.salary\b', query, re.IGNORECASE | re.DOTALL) and 'salary' not in query.lower():
# Find the alias used for salary, e.g. T1.salary -> T1
sal_alias_match = re.search(r'([a-zA-Z0-9_]+)\.salary', query, re.IGNORECASE)
if sal_alias_match:
bad_alias = sal_alias_match.group(1)
# If this alias is actually mapped to 'employee', we need to fix it
if aliases.get('employee') == bad_alias or bad_alias.lower() == 'e':
# Find a good place to inject the join
if 'FROM employee' in query:
query = query.replace('FROM employee', f'FROM employee {bad_alias} JOIN salary s ON {bad_alias}.emp_no = s.emp_no')
elif f'FROM employee {bad_alias}' in query:
query = query.replace(f'FROM employee {bad_alias}', f'FROM employee {bad_alias} JOIN salary s ON {bad_alias}.emp_no = s.emp_no')
query = query.replace(f'{bad_alias}.salary', 's.salary')
print(f"[DEBUG][SQL_FIXER][SALARY_HALLUCINATION] Fixed {bad_alias}.salary by joining salary table")
# Fix the specific d.dept_name / department.dept_name hallucination
# If 'd.' or 'dept_name' is used but 'department' is NOT in FROM/JOIN, we MUST join it.
if re.search(r'\bd\.dept_name\b', query, re.IGNORECASE) and 'department' not in query.lower():
# If we already have a join to dept_emp, we can join department d
if 'dept_emp' in query.lower():
de_alias = aliases.get('dept_emp', 'de')
if f'JOIN department d' not in query:
query = query.replace(f'LIMIT', f'JOIN department d ON {de_alias}.dept_no = d.dept_no LIMIT')
print(f"[DEBUG][SQL_FIXER][DEPT_HALLUCINATION] Fixed d.dept_name by joining department table")
else:
# If no dept_emp, we need a cross join or similar?
# Better to just add a standard join sequence if it's a global query
pass
# Only repair salary.emp_no = dept_emp.emp_no if NOT inside an ON clause (avoid rewriting valid joins)
# This is a very conservative fix: only replace if the pattern is NOT inside an ON ... = ... clause
# Do NOT replace all salary.emp_no or dept_emp.emp_no globally!
# Instead, only fix the specific case of a direct, unaliased join condition outside ON clauses
# (This disables the destructive rewrite for all other cases)
# Simple extremes pattern: SELECT MIN(salary), MAX(salary) FROM salary -> ensure alias usage
if re.search(r'SELECT\s+MIN\(salary\)\s*,\s*MAX\(salary\)\s+FROM\s+salary(?!\s+(?:AS\s+)?s\b)', query, re.IGNORECASE):
query = re.sub(r'FROM\s+salary(?!\s+(?:AS\s+)?s\b)', 'FROM salary s', query, flags=re.IGNORECASE)
query = re.sub(r'MIN\(salary\)', 'MIN(s.salary)', query, flags=re.IGNORECASE)
query = re.sub(r'MAX\(salary\)', 'MAX(s.salary)', query, flags=re.IGNORECASE)
# Fix leftover join.emp_no occurrences after mapping (only if not part of a valid ON clause)
# This is now a no-op unless a clear artifact remains, so we keep it for legacy cases only
if re.search(r'\bjoin\.emp_no\b', query, re.IGNORECASE):
query = re.sub(r'\bjoin\.emp_no\b', 's.emp_no', query, flags=re.IGNORECASE)
# Department naming normalization & column correction
# Normalize capitalized Department references
query = re.sub(r'FROM\s+Department\b', 'FROM department', query, flags=re.IGNORECASE)
query = re.sub(r'JOIN\s+Department\b', 'JOIN department', query, flags=re.IGNORECASE)
# Standardize alias to d (if uppercase D used)
query = re.sub(r'from\s+department\s+D\b', 'FROM department d', query, flags=re.IGNORECASE)
query = re.sub(r'join\s+department\s+D\b', 'JOIN department d', query, flags=re.IGNORECASE)
# Replace d.name (nonexistent) with d.dept_name
query = re.sub(r'\b[dD]\.name\b', 'd.dept_name', query)
# Fix hallucinated column department.department_name
query = re.sub(r'department\.department_name', 'department.dept_name', query, flags=re.IGNORECASE)
query = re.sub(r'd\.department_name', 'd.dept_name', query, flags=re.IGNORECASE)
# Repair broken pattern 'JOIN dept ON d.dept_no = ON.dept_no'
if re.search(r'JOIN\s+dept\s+ON\s+d\.dept_no\s*=\s*ON\.dept_no', query, flags=re.IGNORECASE):
query = re.sub(r'JOIN\s+dept\s+ON\s+d\.dept_no\s*=\s*ON\.dept_no', '', query, flags=re.IGNORECASE)
# Replace stray ' ON.dept_no' with proper alias if department alias d exists
if ' ON.dept_no' in query and re.search(r'FROM\s+department\s+(?:AS\s+)?d\b', query, flags=re.IGNORECASE):
query = query.replace(' ON.dept_no', ' d.dept_no')
# General mapping: convert stray ON.<col> (e.g. ON.emp_no, ON.dept_no) to a likely alias.<col>
# This handles cases where the LLM accidentally emitted 'ON.col' instead of 'alias.col'
try:
def _map_on_col(m):
col = m.group(1).lower()
# Prefer department alias 'd' for dept_no
if col == 'dept_no' and re.search(r'FROM\s+department\s+(?:AS\s+)?d\b', query, flags=re.IGNORECASE):
return 'd.dept_no'
# Prefer dept_emp alias 'de' for dept_emp-related columns
if col in ('dept_no', 'emp_no') and re.search(r'\bdept_emp\s+(?:as\s+)?de\b', query, flags=re.IGNORECASE):
return f'de.{col}'
# dept_manager alias
if col in ('dept_no', 'emp_no') and re.search(r'\bdept_manager\s+(?:as\s+)?dm\b', query, flags=re.IGNORECASE):
return f'dm.{col}'
# salary/employee preferences for emp_no
if col == 'emp_no' and re.search(r'\bsalary\s+(?:as\s+)?s\b', query, flags=re.IGNORECASE):
return 's.emp_no'
if col == 'emp_no' and re.search(r'\bemployee\s+(?:as\s+)?e\b', query, flags=re.IGNORECASE):
return 'e.emp_no'
# If an explicit alias for the column exists anywhere, prefer that alias
for alias in ['d', 'de', 'dm', 'e', 's', 't']:
if re.search(rf'\b{alias}\.', query, flags=re.IGNORECASE) or re.search(rf'\b{alias}\b', query, flags=re.IGNORECASE):
return f"{alias}.{col}"
# Fallback to department alias if present, otherwise 'd'
if re.search(r'FROM\s+department', query, flags=re.IGNORECASE):
return f'd.{col}'
return f'd.{col}'
query = re.sub(r'\bON\.([a-z][a-z0-9_]*)\b', lambda m: _map_on_col(m), query, flags=re.IGNORECASE)
except Exception:
pass
# Fix common LLM alias typos like 'dept_e' or 'dept_emp' misuse and stray 'ON.dept_no'
# e.g. "JOIN department d ON dept_e.dept_no = ON.dept_no" -> "JOIN department d ON de.dept_no = d.dept_no"
try:
if re.search(r'join\s+department\s+d\s+on\s+[a-zA-Z0-9_]+\.dept_no\s*=\s*on\.dept_no', query, flags=re.IGNORECASE):
# Normalize left alias to 'de' (dept_emp) and right side to 'd.dept_no'
query = re.sub(r'join\s+department\s+d\s+on\s+[a-zA-Z0-9_]+\.dept_no\s*=\s*on\.dept_no',
'JOIN department d ON de.dept_no = d.dept_no',
query, flags=re.IGNORECASE)
# Replace 'dept_e.' (a common truncated alias) with 'de.'
if re.search(r'\bdept_e\.', query, flags=re.IGNORECASE):
query = re.sub(r'\bdept_e\.', 'de.', query, flags=re.IGNORECASE)
# Replace stray 'ON.dept_no' with 'd.dept_no' if department alias d is present
if re.search(r'\bON\.dept_no\b', query, flags=re.IGNORECASE) and re.search(r'from\s+department\s+(?:as\s+)?d\b', query, flags=re.IGNORECASE):
query = re.sub(r'\bON\.dept_no\b', 'd.dept_no', query, flags=re.IGNORECASE)
# Normalize 'dept_emp' full name followed by dot to 'de.' when alias de exists
if re.search(r'\bdept_emp\.', query, flags=re.IGNORECASE) and re.search(r'\bdept_emp\s+de\b', query, flags=re.IGNORECASE):
query = re.sub(r'\bdept_emp\.', 'de.', query, flags=re.IGNORECASE)
except Exception:
pass
# Final debug print to show the output after all fixer logic
try:
print("[DEBUG][SQL_FIXER][AFTER]", query)
except Exception:
pass
# Fix common mistake: stray semicolon before WHERE (e.g. "; WHERE ...")
# This often breaks SQL (syntax error at or near "WHERE"). Remove the stray semicolon.
if re.search(r';\s*WHERE', query, flags=re.IGNORECASE):
query = re.sub(r';\s*WHERE', ' WHERE', query, flags=re.IGNORECASE)
# Fix mistaken alias references where LLM used 'd' but salary table is aliased as 's'.
# Example: "salary s JOIN dept_emp de ON d.emp_no = de.emp_no" -> use s.emp_no
try:
if re.search(r'\bsalary\s+s\b', query, flags=re.IGNORECASE) and re.search(r'\bd\.emp_no\b', query, flags=re.IGNORECASE):
query = re.sub(r'\bd\.emp_no\b', 's.emp_no', query, flags=re.IGNORECASE)
except Exception:
pass
# Safe auto-insert: if the SQL references alias `e.` but there's no FROM/JOIN for employee
# inject a conservative join using dept_emp alias `de` when present. This addresses errors
# like "missing FROM-clause entry for table \"e\"" for queries such as employee counts per dept.
try:
has_e_alias_usage = re.search(r'\be\.', query, flags=re.IGNORECASE)
has_employee_join = re.search(r'\bjoin\s+employee\b|\bfrom\s+employee\b|\bemployee\s+e\b', query, flags=re.IGNORECASE)
has_de_alias = re.search(r'\b(dept_emp\s+de|\bde\.)', query, flags=re.IGNORECASE)
if has_e_alias_usage and not has_employee_join and has_de_alias:
# Insert join before WHERE/GROUP/ORDER/LIMIT/HAVING if present, otherwise append before semicolon/end
insert_point = None
m = re.search(r'\b(WHERE|GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b', query, flags=re.IGNORECASE)
join_clause = ' JOIN employee e ON de.emp_no = e.emp_no'
if m:
idx = m.start()
query = query[:idx] + join_clause + ' ' + query[idx:]
else:
# append safely before final semicolon if present
if query.strip().endswith(';'):
query = query.rstrip()[:-1] + join_clause + ';'
else:
query = query + join_clause
# Normalize spacing
query = re.sub(r'\s{2,}', ' ', query).strip()
except Exception:
pass
# Missing table join detection and auto-fix
# Fix missing department table join when d.dept_name is referenced
try:
# Fix for e.salary error: LLM trying to get salary from employee table
has_employee_salary = re.search(r'\be\.salary\b', query, re.IGNORECASE)
if has_employee_salary:
print("[DEBUG][SQL_FIXER][MISSING_JOIN] e.salary referenced - need salary table join")
# Add salary table join
if not re.search(r'salary\s+s\b', query, re.IGNORECASE):
# Add salary join after employee table
employee_join_pattern = r'(FROM\s+employee\s+e)'
if re.search(employee_join_pattern, query, re.IGNORECASE):
query = re.sub(employee_join_pattern, r'\1\nJOIN salary s ON e.emp_no = s.emp_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added salary join")
# Fix the column references: e.salary -> s.salary
query = re.sub(r'\be\.salary\b', 's.salary', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Fixed column references from e.salary to s.salary")
# Add salary table to WHERE clause for current records
if 's.to_date' not in query.lower():
where_match = re.search(r'(\bWHERE\b)', query, re.IGNORECASE)
if where_match:
query = re.sub(r'(\bWHERE\b)', r'\1 s.to_date = \'9999-01-01\' AND', query, flags=re.IGNORECASE)
else:
# Add WHERE clause before GROUP BY
group_match = re.search(r'(\bGROUP BY\b)', query, re.IGNORECASE)
if group_match:
query = re.sub(r'(\bGROUP BY\b)', r'WHERE s.to_date = \'9999-01-01\'\n\1', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added salary to_date filter")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
# Fix for missing e.gender in SELECT: LLM puts e.gender in GROUP BY but forgets to SELECT it
# This produces 2-column results instead of 3-column (dept, gender, count)
# Use SELECT-anchored search to avoid matching schema text or LLM prompt preamble
_ql = query.lower()
_select_pos = _ql.rfind('select') # rfind: use LAST 'select' (closest to actual SQL)
_group_by_pos = _ql.rfind('group by') # rfind: use LAST 'group by'
_gender_in_groupby = False
_gender_in_select = True # default safe: don't inject
if _select_pos > -1 and _group_by_pos > -1 and _group_by_pos > _select_pos:
_group_by_snippet = query[_group_by_pos:]
_gender_in_groupby = 'e.gender' in _group_by_snippet.lower()
# Find FROM between SELECT and GROUP BY using ' from ' (spaces avoid matching 'from_date')
_from_pos = _ql.find(' from ', _select_pos)
if _from_pos > -1:
_select_snippet = query[_select_pos:_from_pos]
_gender_in_select = 'e.gender' in _select_snippet.lower()
if _gender_in_groupby and not _gender_in_select:
print("[DEBUG][SQL_FIXER][GENDER_SELECT] e.gender in GROUP BY but missing from SELECT — injecting")
# Find the first comma AFTER the SELECT keyword (safe: skips any schema text before SELECT)
_actual_select_pos = _ql.rfind('select')
first_comma = query.find(',', _actual_select_pos)
if first_comma > -1:
query = query[:first_comma + 1] + '\ne.gender,' + query[first_comma + 1:]
print(f"[DEBUG][SQL_FIXER][GENDER_SELECT_AFTER] {query[_actual_select_pos:_actual_select_pos+250]}")
# Fix for e.dept_name error: LLM incorrectly puts dept_name on employee table
# dept_name lives on the department table, requires dept_emp + department JOINs
has_e_dept_name = re.search(r'\be\.dept_name\b', query, re.IGNORECASE)
if has_e_dept_name:
print("[DEBUG][SQL_FIXER][MISSING_JOIN] e.dept_name referenced — dept_name is not on employee table, adding dept_emp + department JOINs")
emp_alias = re.search(r'FROM\s+employee\s+(\w+)', query, re.IGNORECASE)
e_alias = emp_alias.group(1) if emp_alias else 'e'
# Add dept_emp join if missing
if not re.search(r'dept_emp\s+de\b', query, re.IGNORECASE):
emp_table_pattern = rf'(FROM\s+employee\s+{e_alias})'
if re.search(emp_table_pattern, query, re.IGNORECASE):
query = re.sub(emp_table_pattern, rf'\1\nJOIN dept_emp de ON {e_alias}.emp_no = de.emp_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added dept_emp join")
# Add department join if missing
if not re.search(r'JOIN\s+department\b', query, re.IGNORECASE):
dept_emp_pattern = r'(JOIN\s+dept_emp\s+de\s+ON\s+[^\n]+)'
if re.search(dept_emp_pattern, query, re.IGNORECASE):
query = re.sub(dept_emp_pattern, r'\1\nJOIN department d ON de.dept_no = d.dept_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added department join")
# Fix to_date filter on dept_emp
if 'de.to_date' not in query.lower():
where_match = re.search(r'(\bWHERE\b)', query, re.IGNORECASE)
if where_match:
query = re.sub(r'\bWHERE\b', lambda m: "WHERE de.to_date = '9999-01-01' AND", query, flags=re.IGNORECASE, count=1)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added de.to_date filter")
# Replace e.dept_name → d.dept_name everywhere
query = re.sub(r'\be\.dept_name\b', 'd.dept_name', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Fixed e.dept_name → d.dept_name")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
# Fix: title table has no dept_no column — LLM hallucinates direct title→department join
# Correct path: title → dept_emp (via emp_no) → department (via dept_no)
if re.search(r'\bt\.dept_no\b', query, re.IGNORECASE):
print("[DEBUG][SQL_FIXER][TITLE_DEPT_NO] t.dept_no doesn't exist — fixing title→dept_emp→department join")
# Replace: JOIN department X ON t.dept_no = X.dept_no
# With: JOIN dept_emp de ON t.emp_no = de.emp_no \n JOIN department X ON de.dept_no = X.dept_no
fixed = re.sub(
r'JOIN\s+department\s+(?:AS\s+)?(\w+)\s+ON\s+t\.dept_no\s*=\s*\1\.dept_no',
r'JOIN dept_emp de ON t.emp_no = de.emp_no\nJOIN department \1 ON de.dept_no = \1.dept_no',
query, flags=re.IGNORECASE
)
if fixed != query:
query = fixed
# Add de.to_date filter if missing
if 'de.to_date' not in query.lower():
query = re.sub(r'\bWHERE\b', "WHERE de.to_date = '9999-01-01' AND", query, flags=re.IGNORECASE, count=1)
print(f"[DEBUG][SQL_FIXER][TITLE_DEPT_NO_AFTER] {query[:300]}")
has_department_join = re.search(r'\bjoin\s+department\b|\bfrom\s+department\b', query, re.IGNORECASE | re.DOTALL)
# Updated pattern to handle multiline formatting and whitespace - simplified
has_dept_emp_join = re.search(r'dept_emp\s+de\b', query, re.IGNORECASE | re.DOTALL)
# Fix for incorrect alias usage: T2.dept_no/T2.dept_name when T2 is salary table
has_salary_alias_dept_cols = re.search(r'\bT2\.(dept_no|dept_name)\b', query, re.IGNORECASE)
has_salary_table_as_t2 = re.search(r'\bsalary\s+AS\s+T2\b', query, re.IGNORECASE)
if has_salary_alias_dept_cols and has_salary_table_as_t2:
print("[DEBUG][SQL_FIXER][MISSING_JOIN] T2.dept_no/dept_name referenced but T2 is salary table - need dept_emp and department joins")
# This is a fundamental schema error - the LLM is trying to get dept info from salary table
# We need to add dept_emp and department joins and fix the column references
# Check if we already have dept_emp join
if not re.search(r'dept_emp', query, re.IGNORECASE):
# Add dept_emp join after employee table
employee_join_pattern = r'(FROM\s+employee\s+AS\s+T1)'
if re.search(employee_join_pattern, query, re.IGNORECASE):
query = re.sub(employee_join_pattern, r'\1\nJOIN dept_emp de ON T1.emp_no = de.emp_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added dept_emp join")
# Check if we need department join
if not re.search(r'department', query, re.IGNORECASE):
# Add department join after dept_emp
query = re.sub(r'(JOIN dept_emp de ON T1\.emp_no = de\.emp_no)', r'\1\nJOIN department d ON de.dept_no = d.dept_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added department join")
# Fix the column references: T2.dept_no -> d.dept_no, T2.dept_name -> d.dept_name
query = re.sub(r'\bT2\.dept_no\b', 'd.dept_no', query, flags=re.IGNORECASE)
query = re.sub(r'\bT2\.dept_name\b', 'd.dept_name', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Fixed column references from T2 to d")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
elif has_d_dept_name and not has_department_join and has_dept_emp_join:
print("[DEBUG][SQL_FIXER][MISSING_JOIN] d.dept_name referenced but no department table joined")
# Insert department join after the first JOIN clause (simplified approach)
first_join_match = re.search(r'(JOIN\s+\w+\s+\w+\s+ON\s+[^\n]+)', query, re.IGNORECASE)
if first_join_match:
join_clause = first_join_match.group(1)
new_join = join_clause + '\nJOIN department d ON de.dept_no = d.dept_no'
query = query.replace(join_clause, new_join)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added department table join after first JOIN")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
else:
# Fallback: insert department join before WHERE clause
where_match = re.search(r'(\bWHERE\b)', query, re.IGNORECASE)
if where_match:
insert_pos = where_match.start()
new_join = '\nJOIN department d ON de.dept_no = d.dept_no\n'
query = query[:insert_pos] + new_join + query[insert_pos:]
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added department table join (fallback method)")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
except Exception:
pass
# Final normalization: remove duplicate LIMITs again, trim extra semicolons & spaces
try:
query = re.sub(r"(LIMIT\s+\d+)(?:\s+LIMIT\s+\d+)+", r"\1", query, flags=re.IGNORECASE)
query = re.sub(r";{2,}$", ";", query.strip())
query = re.sub(r"\s+;", ";", query)
query = re.sub(r"[ \t]{2,}", " ", query)
except Exception:
pass
# Final: if we identified a simple extremes pattern but sanitizer introduced alias mismatch, normalize
if simple_extremes:
# Ensure aliasing consistency
if re.search(r'FROM\s+salary\s+s', query, re.IGNORECASE):
# Convert MIN(salary) to MIN(s.salary) if missing
query = re.sub(r'MIN\(salary\)', 'MIN(s.salary)', query, flags=re.IGNORECASE)
query = re.sub(r'MAX\(salary\)', 'MAX(s.salary)', query, flags=re.IGNORECASE)
# Remove trailing LIMIT if present (not needed for two-row aggregate)
if re.search(r'LIMIT\s+100;?$', query, re.IGNORECASE):
query = re.sub(r'LIMIT\s+100;?$', ';', query, flags=re.IGNORECASE)
# Fix malformed subquery aliases like "FROM salary s s2" -> "FROM salary s2"
# Common LLM error: generates "FROM table alias1 alias2" instead of proper aliases
try:
# Pattern: FROM table_name alias1 alias2 (followed by space/newline, not JOIN)
# Must have exactly 3 words after FROM and be followed by whitespace or end
malformed_alias_pattern = r'\bFROM\s+(\w+)\s+(\w+)\s+(\w+)(?=\s|$)'
matches = re.findall(malformed_alias_pattern, query, flags=re.IGNORECASE | re.MULTILINE)
if matches:
# Only fix if we have exactly the malformed pattern "FROM table alias1 alias2"
# and not something like "FROM table alias JOIN"
for match in matches:
table_name, alias1, alias2 = match
# Check if this is actually malformed (neither alias1 nor alias2 should be SQL keywords)
KEYWORDS = ['SELECT', 'FROM', 'JOIN', 'WHERE', 'GROUP', 'ORDER', 'LIMIT', 'HAVING', 'ON', 'AND', 'OR', 'BY', 'SET', 'IN', 'AS', 'DISTINCT']
if alias1.upper() not in KEYWORDS and alias2.upper() not in KEYWORDS:
# Additional safeguard: Don't fix if the old_pattern appears in a subquery context
# Look for patterns like "SELECT...FROM table WHERE" which indicate a subquery
old_pattern = f"FROM {table_name} {alias1} {alias2}"
# Check if this pattern appears near SELECT and WHERE keywords (subquery indicator)
if old_pattern in query:
# Find the position of this pattern
pos = query.find(old_pattern)
# Look before and after for SELECT/WHERE to detect subqueries
before_context = query[max(0, pos-100):pos].upper()
after_context = query[pos:pos+100].upper()
# Skip if this looks like a subquery (has SELECT before and WHERE after)
if 'SELECT' in before_context and 'WHERE' in after_context:
print(f"[DEBUG][SQL_FIXER][MALFORMED_ALIAS] Skipping subquery pattern: {old_pattern}")
continue
# Safe to fix
new_pattern = f"FROM {table_name} {alias2}"
query = query.replace(old_pattern, new_pattern)
print(f"[DEBUG][SQL_FIXER][MALFORMED_ALIAS] Fixed malformed table alias: {old_pattern} -> {new_pattern}")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][MALFORMED_ALIAS] Error: {e}")
pass
# Fix alias reference in SELECT clause (PostgreSQL doesn't allow referencing column aliases in same SELECT)
# Common pattern: (avg_male_salary - avg_female_salary) AS salary_gap
# Fix: Replace with the actual expressions
# TEMPORARILY DISABLED - causing SQL corruption
try:
# Skip alias reference fix for now to avoid SQL corruption
pass
# alias_ref_pattern = r'\(\s*avg_male_salary\s*-\s*avg_female_salary\s*\)'
# if re.search(alias_ref_pattern, query, flags=re.IGNORECASE):
# # Find the SELECT clause and extract alias definitions
# select_part = re.search(r'SELECT\s+(.*?)\s+FROM', query, flags=re.IGNORECASE | re.DOTALL)
# if select_part:
# select_content = select_part.group(1)
# # Look for avg_male_salary and avg_female_salary definitions with proper word boundaries
# male_salary_match = re.search(r'(AVG\s*\(\s*CASE\s+WHEN\s+e\.gender\s*=\s*[\'"]M[\'"].*?END\s*\))\s+AS\s+avg_male_salary', select_content, flags=re.IGNORECASE | re.DOTALL)
# female_salary_match = re.search(r'(AVG\s*\(\s*CASE\s+WHEN\s+e\.gender\s*=\s*[\'"]F[\'"].*?END\s*\))\s+AS\s+avg_female_salary', select_content, flags=re.IGNORECASE | re.DOTALL)
#
# if male_salary_match and female_salary_match:
# male_expr = male_salary_match.group(1).strip()
# female_expr = female_salary_match.group(1).strip()
# # Replace the alias references with actual expressions, being more careful about boundaries
# replacement = f'({male_expr} - {female_expr})'
# query = re.sub(r'\(\s*avg_male_salary\s*-\s*avg_female_salary\s*\)', replacement, query, flags=re.IGNORECASE)
# print(f"[DEBUG][SQL_FIXER][ALIAS_REF_FIX] Replaced alias references with expressions")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][ALIAS_REF_FIX] Error: {e}")
pass
# Fix missing employee alias in JOIN
try:
if re.search(r'FROM\s+employee\s+JOIN', query, flags=re.IGNORECASE):
query = re.sub(r'FROM\s+employee\s+JOIN', 'FROM employee e JOIN', query, flags=re.IGNORECASE)
print(f"[DEBUG][SQL_FIXER][MISSING_ALIAS] Added missing employee alias")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][MISSING_ALIAS] Error: {e}")
pass
# Fix invalid ds.to_date references
try:
if 'ds.to_date' in query:
query = query.replace('ds.to_date', 's.to_date')
print(f"[DEBUG][SQL_FIXER][INVALID_COLUMN] Fixed ds.to_date to s.to_date")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][INVALID_COLUMN] Error: {e}")
pass
# Fix unnecessary GROUP BY that matches COUNT DISTINCT field
# Pattern: SELECT COUNT(DISTINCT alias.column) ... GROUP BY alias.column
# This is always wrong - returns one row per value instead of total count
try:
# Find COUNT(DISTINCT alias.column) in SELECT
count_distinct_pattern = r'COUNT\s*\(\s*DISTINCT\s+(\w+)\.(\w+)\s*\)'
count_match = re.search(count_distinct_pattern, query, re.IGNORECASE)
if count_match:
alias = count_match.group(1)
column = count_match.group(2)
# Check if there's a GROUP BY with the same alias.column
group_by_pattern = rf'GROUP\s+BY\s+{re.escape(alias)}\.{re.escape(column)}\b'
if re.search(group_by_pattern, query, re.IGNORECASE):
# Remove the GROUP BY clause entirely
query = re.sub(group_by_pattern, '', query, flags=re.IGNORECASE)
# Clean up any trailing commas or extra whitespace
query = re.sub(r'\s+', ' ', query).strip()
print(f"[DEBUG][SQL_FIXER][GROUP_BY_FIX] Removed unnecessary GROUP BY {alias}.{column}")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][GROUP_BY_FIX] Error: {e}")
pass
query = query.strip()
# Debug: print SQL after fixing
try:
print("[DEBUG][SQL_FIXER][AFTER]", query)
except Exception:
pass
return query
# ---------------- Hybrid micro-generation verification helper -----------------
def verify_micro_sql(candidate_sql: Optional[str], question: str, tag: str) -> Optional[str]:
"""Verify & (optionally) adjust a micro-generated candidate via MCP/LLM.
Returns validated SQL or None to signal fallback to full LLM generation.
"""
if not candidate_sql or not isinstance(candidate_sql, str):
return None
if os.environ.get("SKIP_MICRO_VERIFICATION", "0") in ("1","true","True"):
return candidate_sql
try:
base_val = validate_sql_query(candidate_sql)
eh_val = enhanced_validate_sql(candidate_sql)
if base_val.get('valid') and eh_val.get('valid'):
# Already valid, skip fixer
return candidate_sql
prompt = (
f"{_dynamic_schema_block('')}\n\n"
"You are an SQL verifier. A candidate SQL statement was programmatically assembled for the question below. "
"If it's already valid, you may return it unchanged. If SMALL fixes (aliases, correct column name existing in schema, missing LIMIT) are needed, correct them. "
"DO NOT invent new tables or columns. Always end with a semicolon; add 'LIMIT 100' if a plain SELECT without limiting and not an aggregate returning <= 1 row.\n"
f"Tag: {tag}\n"
f"Question: {question}\n"
"Candidate SQL:\n" + candidate_sql + "\n\nReturn ONLY the final SQL (no explanation)."
)
verified_text = None
# Try MCP first
try:
from .mcp_client import mcp_complete
verified_text = mcp_complete(prompt, timeout=MICRO_VERIFY_MCP_TIMEOUT)
except Exception:
try:
llm_local = Settings.llm
resp = llm_local.complete(prompt=prompt, timeout=MICRO_VERIFY_LLM_TIMEOUT)
verified_text = getattr(resp, 'text', str(resp))
except Exception as e2:
print(f"[MICRO-VERIFY][WARN] Verification LLM failed: {e2}; using original candidate if valid.")
if base_val.get('valid'):
return candidate_sql
return None
if not verified_text:
return candidate_sql if base_val.get('valid') else None
# Extract SQL
if '```' in verified_text:
parts = re.split(r"```(?:sql)?", verified_text, flags=re.IGNORECASE)
verified_text = '\n'.join(p for p in parts if 'select' in p.lower() or 'with' in p.lower())
if verified_text and verified_text.lower().startswith('sql'):
verified_text = verified_text[3:].strip()
# Only fix if not valid
v1 = validate_sql_query(verified_text)
v2 = enhanced_validate_sql(verified_text)
if v1.get('valid') and v2.get('valid'):
print(f"[MICRO-VERIFY] Accepted verified SQL for tag={tag}")
return verified_text
else:
print(f"[MICRO-VERIFY][WARN] Verified SQL invalid (base={v1}, issues={v2.get('issues')}); applying fixer.")
# Guard: only pass actual SQL to the fixer, not the full LLM prompt
_vt_stripped = verified_text.strip().lstrip('\n') if verified_text else ''
_vt_lower = _vt_stripped.lower()
if not (_vt_lower.startswith('select') or _vt_lower.startswith('with ') or _vt_lower.startswith('insert') or _vt_lower.startswith('update')):
# LLM returned the full prompt instead of SQL — extract the last SELECT...; block
_sql_match = re.search(r'(SELECT\s.+?;)\s*$', _vt_stripped, re.IGNORECASE | re.DOTALL)
if _sql_match:
_vt_stripped = _sql_match.group(1).strip()
print(f"[MICRO-VERIFY][WARN] Extracted SQL from prompt echo: {_vt_stripped[:120]}")
elif base_val.get('valid'):
print("[MICRO-VERIFY][WARN] Could not extract SQL; falling back to original candidate")
return candidate_sql
else:
return None
fixed = fix_common_sql_errors(_vt_stripped)
v1f = validate_sql_query(fixed)
v2f = enhanced_validate_sql(fixed)
if v1f.get('valid') and v2f.get('valid'):
print(f"[MICRO-VERIFY] Fixed SQL accepted for tag={tag}")
return fixed
# fallback to original if it was valid
if base_val.get('valid') and eh_val.get('valid'):
return candidate_sql
return None
except Exception as e:
print(f"[MICRO-VERIFY][ERROR] Unexpected error: {e}; falling back to full generation")
return None
def schema_aware_column_mapper(query: str) -> str:
"""Map alias.column references to the correct alias based on the inspected schema.
If an alias references a column not present in its table, search other tables
present in the FROM/JOIN clauses for that column and rewrite the alias to
the correct one. This fixes cases like `de.dept_name` -> `d.dept_name`.
"""
try:
# Build alias -> table mapping from FROM and JOIN clauses
aliases: Dict[str, str] = {}
for m in re.finditer(r"\bFROM\s+([a-zA-Z_][\w]*)\s+(?:AS\s+)?([a-zA-Z_][\w]*)", query, flags=re.IGNORECASE):
table, alias = m.group(1).lower(), m.group(2).lower()
aliases[alias] = table
for m in re.finditer(r"\bJOIN\s+([a-zA-Z_][\w]*)\s+(?:AS\s+)?([a-zA-Z_][\w]*)", query, flags=re.IGNORECASE):
table, alias = m.group(1).lower(), m.group(2).lower()
aliases[alias] = table
# Cache columns for each alias (table)
alias_cols: Dict[str, List[str]] = {}
for alias, table in aliases.items():
try:
cols = [c['name'].lower() for c in inspector.get_columns(table_name=table)]
except Exception:
cols = []
alias_cols[alias] = cols
# Replacement: for each occurrence alias.col, if col not in alias table, try to find other alias that has it
def _replace_alias_col(m: re.Match) -> str:
a = m.group(1)
col = m.group(2)
a_l = a.lower()
col_l = col.lower()
# If alias known and column exists there, keep
if a_l in alias_cols and col_l in alias_cols[a_l]:
return f"{a}.{col}"
# Search other aliases
for other_alias, cols in alias_cols.items():
if col_l in cols:
return f"{other_alias}.{col}"
# No alternative found; return original
return f"{a}.{col}"
new_query = re.sub(r"\b([a-zA-Z_][\w]*)\.([a-zA-Z_][\w]*)\b", _replace_alias_col, query)
return new_query
except Exception as e:
print(f"schema_aware_column_mapper failed: {e}")
return query
# --- Manager query micro-generation helpers ---
def generate_manager_list_sql(question: str) -> Optional[str]:
"""Micro LLM generation for manager listing without static template."""
try:
mini_llm = initialize_llm(progressive_reduction=1)
prompt = (
"Generate ONLY a PostgreSQL SQL query listing current department managers.\n"
"Use tables: department d, dept_manager dm, employee e.\n"
"Joins: d.dept_no = dm.dept_no AND e.emp_no = dm.emp_no.\n"
"Current managers: dm.to_date='9999-01-01'.\n"
"Select: d.dept_name AS department_name, e.first_name, e.last_name.\n"
"Order by d.dept_name. End with LIMIT 100; Include semicolon.\n"
f"Question: {question}\nReturn ONLY SQL." )
resp = TimeoutManager.run_with_timeout(
func=mini_llm.complete,
timeout=10.0,
prompt=prompt
)
sql_txt = resp.text.strip()
if '```' in sql_txt:
parts = re.split(r"```(?:sql)?", sql_txt, flags=re.IGNORECASE)
sql_txt = ''.join(p for p in parts if 'select' in p.lower()).strip()
sql_txt = fix_common_sql_errors(sql_txt)
needed = ['from department', 'join dept_manager', 'join employee', 'dept_name', 'limit']
if all(n in sql_txt.lower() for n in needed) and sql_txt.lower().startswith('select'):
if not sql_txt.strip().endswith(';'):
sql_txt += ';'
return sql_txt
return None
except Exception as e:
print(f"Manager micro-generation failed: {e}")
return None
def adjust_manager_query(sql_query: str, original_question: str) -> str:
ql = original_question.lower()
if 'manager' not in ql:
return sql_query
missing_dm = not re.search(r'join\s+dept_manager', sql_query, re.IGNORECASE)
missing_e = not re.search(r'join\s+employee', sql_query, re.IGNORECASE)
if missing_dm or missing_e:
regen = generate_manager_list_sql(original_question)
if regen:
return regen
# If only dept name selected, regenerate for full manager listing
sel = sql_query.split('FROM')[0].lower() if 'from' in sql_query.lower() else ''
if 'dept_name' in sel and 'first_name' not in sel and 'last_name' not in sel:
regen = generate_manager_list_sql(original_question)
if regen:
return regen
return sql_query
# --- Department listing micro-generation & adjuster ---
def generate_department_list_sql(question: str) -> Optional[str]:
"""Micro LLM generation for department name listing (pure LLM path)."""
try:
mini_llm = initialize_llm(progressive_reduction=1)
prompt = (
"Generate ONLY a concise PostgreSQL SQL query that lists all department names.\n"
"Use table: department d. Select: d.dept_name AS department_name.\n"
"Order alphabetically by department_name. End with LIMIT 100; Include semicolon.\n"
f"Question: {question}\nReturn ONLY SQL." )
resp = TimeoutManager.run_with_timeout(
func=mini_llm.complete,
timeout=6.0,
prompt=prompt
)
sql_txt = resp.text.strip()
if '```' in sql_txt:
parts = re.split(r"```(?:sql)?", sql_txt, flags=re.IGNORECASE)
sql_txt = ''.join(p for p in parts if 'select' in p.lower()).strip()
sql_txt = fix_common_sql_errors(sql_txt)
needed = ['select', 'from department', 'dept_name', 'limit']
if all(n in sql_txt.lower() for n in needed) and sql_txt.lower().startswith('select'):
if not sql_txt.strip().endswith(';'):
sql_txt += ';'
return sql_txt
return None
except Exception as e:
print(f"Department micro-generation failed: {e}")
return None
def adjust_department_query(sql_query: str, original_question: str) -> str:
ql = original_question.lower()
if not any(k in ql for k in ['department name', 'department names', 'list departments', 'departments list']):
return sql_query
if re.search(r'department\.department_name', sql_query, re.IGNORECASE) or re.search(r'JOIN\s+dept\s+ON', sql_query, re.IGNORECASE):
regen = generate_department_list_sql(original_question)
if regen:
return regen
if ' ON.' in sql_query and 'dept_name' in sql_query.lower():
regen = generate_department_list_sql(original_question)
if regen:
return regen
return sql_query
# --- Average salary per department micro-generation & adjuster ---
def generate_avg_salary_by_department_sql(question: str) -> Optional[str]:
"""Micro LLM generation for average salary per department (pure LLM)."""
try:
mini_llm = initialize_llm(progressive_reduction=1)
prompt = (
"Generate ONLY a PostgreSQL SQL query returning average current salary per department.\n"
"Use tables/aliases: salary s, employee e, dept_emp de, department d.\n"
"Joins: s.emp_no = e.emp_no; e.emp_no = de.emp_no; de.dept_no = d.dept_no.\n"
"Current rows: s.to_date='9999-01-01' AND de.to_date='9999-01-01'.\n"
"Select: d.dept_name AS department_name, AVG(s.salary) AS average_salary.\n"
"Group by d.dept_name. Order by average_salary DESC. End with LIMIT 100; Include semicolon.\n"
f"Question: {question}\nReturn ONLY SQL." )
resp = TimeoutManager.run_with_timeout(func=mini_llm.complete, timeout=8.0, prompt=prompt)
sql_txt = resp.text.strip()
if '```' in sql_txt:
parts = re.split(r"```(?:sql)?", sql_txt, flags=re.IGNORECASE)
sql_txt = ''.join(p for p in parts if 'select' in p.lower()).strip()
sql_txt = fix_common_sql_errors(sql_txt)
required = ['avg', 'from salary', 'join', 'dept_name', 'group by', 'limit']
if all(r in sql_txt.lower() for r in required) and sql_txt.lower().startswith('select'):
if not sql_txt.strip().endswith(';'):
sql_txt += ';'
return sql_txt
return None
except Exception as e:
print(f"Avg salary micro-generation failed: {e}")
return None
def adjust_avg_salary_department_query(sql_query: str, original_question: str) -> str:
ql = original_question.lower()
if 'average salary' not in ql or 'department' not in ql:
return sql_query
# If question asks for an additional grouping dimension (title, job title, gender, etc.)
# AND the SQL already joins that table, do not regenerate — the LLM handled the
# multi-dimension correctly and regenerating would drop that dimension.
_extra_dims = ['title', 'job title', 'gender', 'hire_date', 'position']
if any(dim in ql for dim in _extra_dims):
if re.search(r'\b(from|join)\s+title\b', sql_query, re.IGNORECASE):
print(f"[ADJUSTER][AVG_SALARY] Skipping regen — SQL already has title join for multi-dimension query")
return sql_query
# Conditions that signal regeneration
invalid_bare_department = re.search(r'\bselect\s+department\b', sql_query, re.IGNORECASE)
missing_dept_join = 'join department' not in sql_query.lower() and ' department d' not in sql_query.lower()
missing_avg = 'avg(' not in sql_query.lower()
missing_filters = 'to_date' not in sql_query.lower()
if invalid_bare_department or missing_dept_join or missing_avg or missing_filters:
regen = generate_avg_salary_by_department_sql(original_question)
if regen:
return regen
return sql_query
# --- Title query semantic adjuster (post-generation heuristic, no hardcoded full query) ---
def adjust_title_query(sql_query: str, original_question: str, normalized_title: Optional[str]) -> str:
"""Heuristically adjust title/engineer count queries to valid schema usage.
Rules (non-destructive, only if clearly a title count intent):
- Ensure alias t for title table
- Replace misuse of 'department =' with correct t.title comparison
- Ensure COUNT(DISTINCT t.emp_no) and current record filter t.to_date='9999-01-01'
"""
ql = original_question.lower()
if not any(k in ql for k in ["engineer", "title", "developer", "analyst", "staff"]):
return sql_query
# Guard: if user is asking for count of DISTINCT title types (not employees with a title),
# the correct SQL is COUNT(DISTINCT title) with no emp_no or engineer filter — do not corrupt it.
is_distinct_title_count = (
any(k in ql for k in ["how many", "count", "number of", "different", "distinct", "types of"]) and
any(k in ql for k in ["title", "titles", "job title", "job titles", "positions", "roles"]) and
not any(k in ql for k in ["engineer", "engineers", "analyst", "developer", "staff"])
)
if is_distinct_title_count:
print(f"[ADJUSTER][TITLE] Skipping adjust_title_query — question is asking for count of distinct title types, not employee count")
return sql_query
intent_is_count = any(k in ql for k in ["how many", "count"]) and any(k in ql for k in ["engineer", "engineers"])
# If LLM failed to reference title table at all for an engineer count intent, synthesize minimal correct pattern.
# This is a heuristic repair (constructed dynamically) – not a stored fallback template list.
if 'from title' not in sql_query.lower() and intent_is_count:
target = normalized_title
if not target:
for pat, canon in [
(r"senior engineers?", "Senior Engineer"),
(r"staff engineers?", "Staff Engineer"),
(r"assistant engineers?", "Assistant Engineer"),
(r"engineers?", "Engineer"),
]:
if re.search(pat, ql):
target = canon
break
target = target or "Engineer"
try:
generated = generate_title_count_sql(original_question, target)
if generated:
return generated
except Exception as gen_err:
print(f"Title micro-generation failed: {gen_err}")
# Fall through to incremental adjustments below without introducing a static template
pass
# Continue adjustments for queries that already reference title
target = normalized_title
# Attempt to infer target from question if not provided
if not target:
for pat, canon in [
(r"senior engineers?", "Senior Engineer"),
(r"staff engineers?", "Staff Engineer"),
(r"assistant engineers?", "Assistant Engineer"),
(r"engineers?", "Engineer"),
]:
if re.search(pat, ql):
target = canon
break
# Minimal non-template adjustments only (structural hygiene, no full pattern injection)
# 1. Ensure alias t if title table already present and alias missing (pure structural, not full query template)
if re.search(r"from\s+title\b", sql_query, re.IGNORECASE) and not re.search(r"from\s+title\s+(?:as\s+)?t\b", sql_query, re.IGNORECASE):
sql_query = re.sub(r"from\s+title\b", "FROM title t", sql_query, flags=re.IGNORECASE)
# 2. Replace misuse of department = 'X' (a semantic correction, not a canned query)
if re.search(r"department\s*=\s*'([^']+)'", sql_query, re.IGNORECASE):
sql_query = re.sub(r"department\s*=\s*'([^']+)'", lambda m: f"t.title = '{(target or m.group(1)).rstrip('s')}'", sql_query, flags=re.IGNORECASE)
# 3. If we see COUNT(*) with only title table referenced, prefer regenerating via micro LLM instead of hard substituting a fragment
if re.search(r"select\s+count\(\*\)", sql_query, re.IGNORECASE) and re.search(r"from\s+title", sql_query, re.IGNORECASE):
regen = None
try:
regen = generate_title_count_sql(original_question, target or 'Engineer')
except Exception:
regen = None
if regen:
return regen
# 4. If essential current-row filter missing, attempt micro-generation instead of injecting static predicate
# Only trigger current-filter regen if this is TRULY an employee-with-title count query
# (not a generic 'count distinct titles' query which doesn't need to_date)
is_employee_title_count = intent_is_count or (target is not None)
needs_current_filter = (
re.search(r"from\s+title", sql_query, re.IGNORECASE) and
't.to_date' not in sql_query.lower() and
is_employee_title_count
)
if needs_current_filter:
regen = None
try:
regen = generate_title_count_sql(original_question, target or 'Engineer')
except Exception:
regen = None
if regen:
return regen
# 5. Do NOT inject LIMIT / semicolon here; global sanitizers handle generic formatting consistently
return sql_query
# Lightweight micro-generation (still LLM based) to avoid static hardcoded pattern for title count
def generate_title_count_sql(question: str, canonical_title: str) -> Optional[str]:
"""Use a minimal LLM prompt to produce the title count SQL instead of embedding a static query.
Returns cleaned SQL or None on failure. Keeps pure LLM path philosophy."""
# Deterministic schema-driven builder first (no LLM)
try:
if 'title' in available_tables:
try:
cols = [c['name'].lower() for c in inspector.get_columns(table_name='title')]
except Exception:
cols = []
# Required columns for deterministic build
if 'emp_no' in cols and 'title' in cols and 'to_date' in cols:
title_literal = canonical_title.strip()
# Assemble SQL programmatically via build_sql helper (avoid literal concatenation)
select_cols = ["COUNT(DISTINCT t.emp_no) AS title_count"]
base_table = "title t"
where = [f"LOWER(t.title) = '{title_literal.lower()}'", "t.to_date = '9999-01-01'"]
sql = build_sql(select_cols=select_cols, base_table=base_table, where=where, limit=100)
return sql
except Exception as e:
print(f"Deterministic title count builder error: {e}")
# Fallback: small LLM-based micro-generator (existing behavior)
try:
mini_llm = initialize_llm(progressive_reduction=2)
memory_manager.register_llm_instance(mini_llm)
prompt = (
"Generate ONLY a concise PostgreSQL SQL query counting distinct employees with a specific title.\n"
"Constraints:\n"
"- Use title table only with alias t\n"
"- Current rows: t.to_date='9999-01-01'\n"
f"- Exact title match: '{canonical_title}' (use singular)\n"
"- COUNT(DISTINCT t.emp_no) AS TitleCount\n"
"- End with LIMIT 100;\n"
"Question: " + question + "\n"
"Return ONLY SQL; no prose."
)
resp = TimeoutManager.run_with_timeout(
func=mini_llm.complete,
timeout=8.0,
prompt=prompt
)
memory_manager.unregister_llm_instance(mini_llm)
sql_txt = resp.text.strip()
if "```" in sql_txt:
parts = re.split(r"```(?:sql)?", sql_txt, flags=re.IGNORECASE)
sql_txt = ''.join(p for p in parts if 'select' in p.lower()).strip()
sql_txt = fix_common_sql_errors(sql_txt)
# Enforce essential fragments; if missing treat as failure so caller may fallback to adjustments
required = ["count", "from title", "t.to_date", "t.title", "limit"]
if all(r in sql_txt.lower() for r in required):
if not sql_txt.lower().startswith("select"):
return None
if not sql_txt.strip().endswith(';'):
sql_txt += ';'
return sql_txt
return None
except Exception as e:
try:
memory_manager.unregister_llm_instance(mini_llm)
except Exception:
pass
print(f"Title count micro-generation failed: {e}")
return None
# --- Employee count by department micro-generation & adjuster ---
def generate_employee_count_by_department_sql(question: str) -> Optional[str]:
"""Deterministic, schema-driven SQL builder for employees-per-department.
This avoids relying on a small LLM call which has been producing
malformed WHERE/alias combinations under low-memory/degraded-model
conditions. The builder inspects available tables/columns and returns
a safe SQL string or None if required tables/columns are missing.
"""
try:
ql = question.lower()
superlative = any(w in ql for w in ["most", "largest", "highest"])
least = any(w in ql for w in ["fewest", "least"]) # prefer ASC
order_asc = least
# Required tables
if 'department' not in available_tables or 'dept_emp' not in available_tables:
return None
# Discover canonical column names
try:
dept_cols = [c['name'].lower() for c in inspector.get_columns(table_name='department')]
except Exception:
dept_cols = []
try:
de_cols = [c['name'].lower() for c in inspector.get_columns(table_name='dept_emp')]
except Exception:
de_cols = []
# Choose dept name column (dept_name preferred)
dept_name_col = 'dept_name' if 'dept_name' in dept_cols else (dept_cols[0] if dept_cols else 'dept_name')
# dept_no column name
dept_no_col = 'dept_no' if 'dept_no' in dept_cols or 'dept_no' in de_cols else (dept_cols[0] if dept_cols else 'dept_no')
# emp_no column
emp_no_col = 'emp_no' if 'emp_no' in de_cols else (de_cols[0] if de_cols else 'emp_no')
# Build SQL programmatically
order_dir = 'ASC' if order_asc else 'DESC'
limit_clause = 'LIMIT 1' if superlative or least else 'LIMIT 100'
select_cols = [f"d.{dept_name_col} AS department_name", f"COUNT(DISTINCT de.{emp_no_col}) AS employee_count"]
base_table = f"department d"
joins = [f"JOIN dept_emp de ON d.{dept_no_col} = de.{dept_no_col}"]
where_clauses = []
# Prefer standard current-row sentinel if available
if 'to_date' in de_cols:
where_clauses.append("de.to_date = '9999-01-01'")
parts = []
parts.append("SELECT " + ", ".join(select_cols))
parts.append("FROM " + base_table)
parts.extend(joins)
if where_clauses:
parts.append("WHERE " + " AND ".join(where_clauses))
parts.append("GROUP BY d." + dept_name_col)
parts.append(f"ORDER BY employee_count {order_dir}")
parts.append(limit_clause + ';')
sql = " ".join(parts)
return sql
except Exception as e:
print(f"Employee count builder failed: {e}")
return None
def adjust_employee_count_by_department_query(sql_query: str, original_question: str) -> str:
ql = original_question.lower()
if not ('department' in ql and any(k in ql for k in ['count', 'how many', 'number of', 'most', 'largest', 'highest', 'fewest', 'least']) and any(k in ql for k in ['employee', 'employees'])):
return sql_query
# If missing essential join or aggregation/grouping, regenerate
missing_join = 'join dept_emp' not in sql_query.lower()
missing_group = 'group by' not in sql_query.lower()
missing_count = 'count(' not in sql_query.lower()
if missing_join or missing_group or missing_count:
regen = generate_employee_count_by_department_sql(original_question)
if regen:
return regen
# Superlative queries should generally have LIMIT 1
if any(w in ql for w in ['most','largest','highest','fewest','least']) and 'limit 1' not in sql_query.lower():
regen = generate_employee_count_by_department_sql(original_question)
if regen:
return regen
return sql_query
async def present_results(question: str, sql_query: str) -> str:
"""Execute the query and present results with intelligent formatting and insights."""
print("[PRESENTER] Starting intelligent result presentation...")
try:
# Execute the query with diagnostic verification
print("[PRESENTER] Executing query...")
results = await execute_query(sql_query)
# Perform result verification for suspicious patterns
if results and isinstance(results, list) and len(results) <= 5:
try:
flat_vals = []
for r in results:
for v in r.values():
if isinstance(v, (int, float)):
flat_vals.append(v)
if flat_vals and all(v == flat_vals[0] for v in flat_vals):
if flat_vals[0] in (1103, 1000, 999):
print(f"[PRESENTER] Detected uniform result pattern {flat_vals} - verifying accuracy...")
lowered_q = question.lower()
is_simple_total = re.search(r"total number of employees|total employees|count of employees|how many employees", lowered_q) and 'department' not in lowered_q and 'each' not in lowered_q
simple_sql_shape = re.match(r"^\s*select\s+count\s*\(\s*\*\s*\)\s+as\s+total_employees\s+from\s+employee\s*;?\s*$", sql_query, flags=re.IGNORECASE)
if is_simple_total or simple_sql_shape:
try:
print("[PRESENTER] Cross-verifying employee count...")
verification_results = []
verify_queries = [
build_sql(select_cols=["COUNT(*) AS total_emp_check"], base_table="employee", limit=None),
build_sql(select_cols=["COUNT(DISTINCT emp_no) AS total_emp_distinct"], base_table="employee", limit=None)
]
for vq in verify_queries:
try:
vr = await execute_query(vq)
verification_results.append((vq, vr))
except Exception as verr:
verification_results.append((vq, str(verr)))
counts = []
for _, vr in verification_results:
if isinstance(vr, list) and vr and isinstance(vr[0], dict):
for val in vr[0].values():
if isinstance(val, (int, float)):
counts.append(val)
if len(counts) >= 2 and counts[0] != counts[1]:
print(f"[PRESENTER] Count verification mismatch detected: {counts}")
else:
print(f"[PRESENTER] Count verification successful: {counts[0] if counts else 'N/A'}")
except Exception as ver_err:
print(f"[PRESENTER] Verification failed: {ver_err}")
except Exception:
pass
# Apply intelligent formatting and context
print("[PRESENTER] Applying intelligent formatting...")
formatted_response = format_results(results, question)
# Chart-aware presentation (Phase 3): attach a chart spec when the
# result rows visualize well. The marker is stripped by the worker;
# CLI/terminal runs just see the plain text answer.
try:
from .chart_contract import detect_chart_spec, embed_chart_marker
chart_spec = detect_chart_spec(results, question)
if chart_spec:
formatted_response = embed_chart_marker(formatted_response, chart_spec)
print(f"[PRESENTER] Chart attached: type={chart_spec['type']} "
f"points={len(chart_spec['x'])} series={len(chart_spec['series'])}")
except Exception as chart_err:
print(f"[PRESENTER] chart detection skipped: {chart_err}")
# Add presentation insights
if results:
result_count = len(results)
print(f"[PRESENTER] Presenting {result_count} result(s) with contextual insights")
else:
print("[PRESENTER] No results found - providing explanatory response")
print("[PRESENTER] Result presentation completed successfully")
return formatted_response
except Exception as e:
error_msg = f"[PRESENTER] Error during result presentation: {str(e)}"
print(error_msg)
return f"I encountered an error while presenting the results: {str(e)}"
async def agentic_query_process(question: str) -> str:
"""Process a query through an agentic workflow."""
print(f"[DEBUG][INCOMPLETE_FUNCTION] Called with question: '{question}'")
# Generate SQL query from LLM
sql_query = query_craft(question)
# Validate the query before fixing
validation = enhanced_validate_sql(sql_query)
if validation["valid"]:
print("[DEBUG][AGENTIC] Query is valid, skipping fixer.")
fixed_query = sql_query
else:
print(f"[DEBUG][AGENTIC] Query has issues: {validation['issues']}. Applying fixer.")
fixed_query = fix_common_sql_errors(sql_query)
# Continue with the rest of the workflow using fixed_query
# (The rest of the function remains unchanged)
async def get_doc_text(doc_id: str) -> Optional[str]:
"""Return stored OCR text for a given doc_id if present in chat_store/docs."""
try:
docs_dir = Path('chat_store') / 'docs'
fpath = docs_dir / f"{doc_id}.txt"
if fpath.exists():
return fpath.read_text(encoding='utf-8')
except Exception:
pass
return None
async def agentic_query_process(question: str, doc_id: Optional[str] = None) -> str:
"""Process a query through an agentic workflow. Optionally include OCR text from doc_id."""
print(f"[ENTRY_POINT_DEBUG] agentic_query_process called with question: '{question}'")
print(f"[ENTRY_POINT_DEBUG] doc_id: {doc_id}")
# Initialize database if not already done
global async_session, available_tables
if not _DB_CONNECTED or not available_tables:
print("--- Database Initialization ---")
db_metadata = await initialize_database(uri=DB_CONNECTION_URL)
if not db_metadata["tables"]:
return (
"No database is connected yet. Open the **Database** panel on the right, "
"then connect one via connection URL or upload a .sqlite/.csv file — "
"or set DB_CONNECTION_URL in Agent/.env and restart the worker."
)
print(f"Database initialized with tables: {available_tables}")
# If a doc_id is provided, fetch its OCR text and append to the question for context
if doc_id:
try:
doc_text = await get_doc_text(doc_id)
if doc_text:
try:
from os import getenv as _oget
snippet_limit = int(_oget("OCR_SNIPPET_LIMIT", "2000"))
except Exception:
snippet_limit = 2000
if snippet_limit <= 0:
snippet_limit = 2000
snippet = doc_text[:snippet_limit]
question = (
question
+ f"\n\n[Document {doc_id} excerpt start]\n{snippet}\n[Document excerpt end]"
)
except Exception as e:
print(f"Warning: failed to include doc_text for {doc_id}: {e}")
# First clean up the question to handle common typos
print(f"[TYPO_DEBUG] Question before typo correction: '{question}'")
corrected_question = question
common_typos = {
"deparment": "department",
"deparments": "departments",
"avarage": "average",
# Removed "employe": "employee" and "employes": "employees" to prevent over-correction
}
for typo, correction in common_typos.items():
corrected_question = corrected_question.replace(typo, correction)
if corrected_question != question:
print(f"Corrected query: '{corrected_question}' (was: '{question}')")
question = corrected_question
# Initial memory cleanup before processing
print(f"\n--- Initial Memory Cleanup for question: {question} ---")
gc.collect()
stats_before = memory_manager.get_memory_stats()
memory_manager.force_collect_garbage()
stats_after = memory_manager.get_memory_stats()
print(f"Memory before cleanup: {stats_before['available_mb']:.2f}MB")
print(f"Memory after cleanup: {stats_after['available_mb']:.2f}MB")
print(f"Memory reclaimed: {stats_after['available_mb'] - stats_before['available_mb']:.2f}MB")
# Check if there's enough memory to safely process the request
if stats_after['available_mb'] < memory_manager.critical_mb:
print(f"System is critically low on memory: {stats_after['available_mb']:.2f}MB available")
print("LLM-based approach not viable due to critical memory constraints")
return "I'm sorry, but your system is critically low on available memory. Please close some applications and try again, or try a simpler query."
print("\n=== MEMORY AGENT ===")
# Handle re-execute requests by cleaning the question first
original_question = question
clean_question = question
for keyword in ["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"]:
clean_question = clean_question.replace(keyword, "").strip()
# Check if this is a re-execute request
is_reexecute = any(k in original_question.lower() for k in ["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"])
if is_reexecute and clean_question and len(clean_question.strip()) > 0:
print(f"User requested to re-execute. Using question: '{clean_question}'")
question = clean_question # Use the cleaned question for processing
print("Bypassing memory check for re-execute request.")
else:
# Normal memory check
memory_result = memory_check(question)
if memory_result.startswith("FOUND_IN_MEMORY:"):
print("Found previous answer in memory")
return memory_result.replace("FOUND_IN_MEMORY: ", "")
elif memory_result.startswith("FOUND_IN_MEMORY_WITH_ERROR:"):
print("Found previous answer with error. Suggesting to retry.")
error_message = memory_result.replace("FOUND_IN_MEMORY_WITH_ERROR: ", "")
return f"{error_message}\n\nIt looks like there was an error with this query. You can try asking again with 're-execute' to regenerate the query."
elif memory_result.startswith("RE_EXECUTE_ORIGINAL:"):
print("User requested to re-execute. Retrieving original question.")
# Only use the fallback question if user didn't specify a question after "re-execute"
if clean_question and len(clean_question.strip()) > 0:
print(f"User provided specific question for re-execute: '{clean_question}' - using that instead of fallback")
question = clean_question
else:
print("User didn't specify question for re-execute, using fallback from memory")
question = memory_result.replace("RE_EXECUTE_ORIGINAL: ", "")
print(f"Final question for re-execution: {question}")
elif "Bypassing memory check" in memory_result:
print("User requested to re-execute. Bypassing memory check.")
# Continue with query generation
else:
print("No previous answer found in memory")
# Memory check again before heavy processing
print("\n--- Pre-Generation Memory Check ---")
stats = memory_manager.get_memory_stats()
if stats['available_mb'] < memory_manager.threshold_mb:
print(f"⚠️ Memory low before query generation: {stats['available_mb']:.2f}MB available")
memory_manager.force_collect_garbage()
stats = memory_manager.get_memory_stats()
if stats['available_mb'] < memory_manager.critical_mb:
return "I'm sorry, but your system is currently low on available memory. Please close some applications and try again, or try a simpler query."
print("\n=== QUERY CRAFT AGENT ===")
try:
gc.collect()
try:
sql_query = query_craft(question)
except ValueError as gen_fail:
ql = question.lower()
if ACTIVE_SCHEMA_PROFILE.is_employee_db and ('manager' in ql and ('male' in ql or 'female' in ql or 'gender' in ql)):
print("[FALLBACK] Building manager gender count query programmatically after LLM failure")
select_cols = ["e.gender", "COUNT(*) AS manager_count"]
joins = ["JOIN dept_manager dm ON dm.emp_no = e.emp_no"]
sql_query = build_sql(select_cols=select_cols, base_table="employee e", joins=joins, group_by=["e.gender"], order_by="manager_count DESC", limit=None)
else:
raise
try:
global LAST_GENERATED_SQL
LAST_GENERATED_SQL = sql_query
except Exception:
pass
try:
print("<<<SQL_START>>>")
print(sql_query.strip())
print("<<<SQL_END>>>")
except Exception:
print(f"Generated SQL query: {sql_query}")
# Invariant enforcement (classification + constraint-driven regeneration).
# NOTE: invariants are tuned for the built-in employee demo schema. For
# user-uploaded schemas only generic structural validation runs.
if ENABLE_INVARIANTS and ACTIVE_SCHEMA_PROFILE.is_employee_db:
# Quick validation check BEFORE running expensive invariants.
# If the SQL already passes validation, invariants will only produce false positives
# and waste 90-160s. Use a flag to gate the entire enforce_invariants block.
_skip_invariants = False
try:
_pre_inv_valid = enhanced_validate_sql(sql_query)
_pre_inv_base = validate_sql_query(sql_query)
if _pre_inv_valid.get('valid') and _pre_inv_base.get('valid'):
print("[INVARIANTS] SQL passed pre-check — skipping enforce_invariants (already valid)")
print("<<<SQL_REVISED_START>>>")
print(sql_query.strip())
print("<<<SQL_REVISED_END>>>")
LAST_GENERATED_SQL = sql_query
_skip_invariants = True
except Exception:
pass # If pre-check fails, fall through to normal invariants
if not _skip_invariants:
try:
try:
cat = classify_question(question)
except Exception:
cat = None
_sql_normalized = re.sub(r'\s+', ' ', sql_query).lower()
_has_dept_emp = 'dept_emp' in _sql_normalized
_has_gender = 'gender' in _sql_normalized or 'e.gender' in _sql_normalized
if cat == 'EMP_COUNT_BY_DEPT' and not _has_dept_emp and not _has_gender:
rescue = generate_employee_count_by_department_sql(question)
if rescue:
print("[RESCUE] Replaced incomplete employee count query with micro-generated version before invariants")
sql_query = rescue
classification_question = re.sub(r'^re-execute\s+', '', question, flags=re.IGNORECASE)
inv_result = enforce_invariants(classification_question, sql_query, Settings.llm, run_llm_with_timeout)
if inv_result.get('regenerations'):
for regen in inv_result['regenerations']:
print(f"[INVARIANTS] Regen {regen['attempt']} fixed missing {regen['missing_before']}")
if 'remaining_missing' in inv_result and inv_result['remaining_missing']:
print(f"[INVARIANTS] Remaining unmet invariants: {inv_result['remaining_missing']}")
try:
micro_map = {
'count_depts': generate_department_list_sql,
'emp_count_by_dept': generate_employee_count_by_department_sql,
'count_employees_by_department': generate_employee_count_by_department_sql,
'EMP_COUNT_BY_DEPT': generate_employee_count_by_department_sql,
}
for miss in inv_result['remaining_missing']:
key = miss.lower()
gen_fn = micro_map.get(key)
if gen_fn:
try:
print(f"[INVARIANTS] Attempting micro-generator for unmet invariant '{miss}' using {gen_fn.__name__}")
micro_sql = gen_fn(question)
if micro_sql:
print(f"[INVARIANTS] Micro-generator produced SQL for '{miss}': {micro_sql}")
sql_query = micro_sql
break
except Exception as micro_err:
print(f"[INVARIANTS] Micro-generator {gen_fn.__name__} failed: {micro_err}")
except Exception as micro_map_err:
print(f"[INVARIANTS] Micro-generator mapping failed: {micro_map_err}")
if not sql_query or (isinstance(sql_query, str) and sql_query.strip() == ''):
sql_query = inv_result.get('final_sql', sql_query)
else:
try:
print("<<<SQL_REVISED_START>>>")
print(sql_query.strip())
print("<<<SQL_REVISED_END>>>")
LAST_GENERATED_SQL = sql_query
except Exception:
print(f"[INVARIANTS] Final SQL after enforcement/micro-gen: {sql_query}")
if not sql_query:
sql_query = inv_result.get('final_sql', sql_query)
except Exception as inv_err:
print(f"[INVARIANTS] Enforcement skipped due to error: {inv_err}")
memory_manager.force_collect_garbage()
# --- MAIN PATCH: Only fix if invalid, and always use the validated/fixed query ---
validation = enhanced_validate_sql(sql_query)
if validation["valid"]:
fixed_query = sql_query
print("[DEBUG][AGENTIC] Query is valid, skipping fixer.")
else:
print(f"[DEBUG][AGENTIC] Query has issues: {validation['issues']}. Applying fixer.")
fixed_query = fix_common_sql_errors(sql_query)
# All subsequent processing uses fixed_query only
sql_query = fixed_query
# --- Unconditional semantic fixes (run even when fixer was skipped) ---
# Fix 1: dept_no = 'DeptName' → dept_no IN (SELECT dept_no FROM department WHERE dept_name = 'DeptName')
# This pattern is syntactically valid but semantically wrong (dept_no is a code, not a name)
try:
_dn_match = re.search(r"\bdept_no\s*=\s*'([A-Za-z][A-Za-z\s&]*)'", sql_query, re.IGNORECASE)
if _dn_match and ACTIVE_SCHEMA_PROFILE.is_employee_db:
_dn_val = _dn_match.group(1)
if not re.match(r'^d\d+$', _dn_val.strip(), re.IGNORECASE):
_dn_fix = f"dept_no IN (SELECT dept_no FROM department WHERE dept_name = '{_dn_val}')"
sql_query = re.sub(
r"\bdept_no\s*=\s*'" + re.escape(_dn_val) + r"'",
_dn_fix, sql_query, flags=re.IGNORECASE
)
print(f"[POST-FIX] dept_no = '{_dn_val}' replaced with subquery lookup")
except Exception:
pass
# Fix 2: department.dept_name = 'X' used in WHERE but department table not in FROM/JOIN
# → add the missing dept_emp + department joins
try:
_has_dept_filter = re.search(r"\bdepartment\.dept_name\s*=\s*'([^']+)'", sql_query, re.IGNORECASE)
_has_dept_join = re.search(r'\bjoin\s+department\b|\bfrom\s+department\b', sql_query, re.IGNORECASE)
_has_dept_emp_join = re.search(r'\bjoin\s+dept_emp\b|\bfrom\s+dept_emp\b', sql_query, re.IGNORECASE)
if _has_dept_filter and not _has_dept_join and ACTIVE_SCHEMA_PROFILE.is_employee_db:
_dept_filter_val = _has_dept_filter.group(1)
# Find the first FROM table to determine the join anchor
_from_match = re.search(r'\bFROM\s+(\w+)', sql_query, re.IGNORECASE)
_anchor = _from_match.group(1) if _from_match else 'salary'
# Build the missing joins - anchor through employee if present
_has_emp = re.search(r'\bjoin\s+employee\b|\bfrom\s+employee\b', sql_query, re.IGNORECASE)
if _has_emp and not _has_dept_emp_join:
_join_to_add = (
"JOIN dept_emp de ON e.emp_no = de.emp_no "
"JOIN department d ON de.dept_no = d.dept_no"
)
elif not _has_dept_emp_join:
_join_to_add = (
f"JOIN dept_emp de ON {_anchor}.emp_no = de.emp_no "
"JOIN department d ON de.dept_no = d.dept_no"
)
else:
_join_to_add = "JOIN department d ON de.dept_no = d.dept_no"
# Insert join before WHERE
sql_query = re.sub(r'\bWHERE\b', _join_to_add + ' WHERE', sql_query, count=1, flags=re.IGNORECASE)
# Replace department.dept_name with d.dept_name (alias form)
sql_query = re.sub(r'\bdepartment\.dept_name\b', 'd.dept_name', sql_query, flags=re.IGNORECASE)
print(f"[POST-FIX] Added missing department JOIN for filter dept_name = '{_dept_filter_val}'")
except Exception:
pass
# Fix 3: "Which X has the highest/most/lowest/fewest..." → singular superlative → LIMIT 1
# These questions expect a single top result, not a ranked list of all rows.
try:
_singular_superlative = re.search(
r'\bwhich\b.{1,40}\b(highest|most|lowest|fewest|best|worst|largest|smallest|greatest)\b',
question.lower()
)
if _singular_superlative:
# Only change LIMIT 100 (the default) — never touch intentional LIMIT N values
if re.search(r'\bLIMIT\s+100\b', sql_query, re.IGNORECASE):
sql_query = re.sub(r'\bLIMIT\s+100\b', 'LIMIT 1', sql_query, flags=re.IGNORECASE)
print("[POST-FIX] Changed LIMIT 100 -> LIMIT 1 for singular superlative query")
except Exception:
pass
# Fix 4: department alias used with columns it doesn't have (emp_no, from_date, to_date)
# These columns belong to dept_manager. When both 'd' (department) and 'dm' (dept_manager)
# are present, redirect d.emp_no -> dm.emp_no, d.from_date -> dm.from_date, d.to_date -> dm.to_date
# and fix wrong JOIN conditions like "ON d.emp_no = e.emp_no" -> "ON dm.emp_no = e.emp_no"
try:
_dept_alias = re.search(r'\bFROM\s+department\s+(\w+)\b', sql_query, re.IGNORECASE)
_dm_alias = re.search(r'\b(?:FROM|JOIN)\s+dept_manager\s+(\w+)\b', sql_query, re.IGNORECASE)
if _dept_alias and _dm_alias and ACTIVE_SCHEMA_PROFILE.is_employee_db:
_da = _dept_alias.group(1) # e.g. 'd'
_dma = _dm_alias.group(1) # e.g. 'dm'
_cols_department_lacks = ['emp_no', 'from_date', 'to_date']
_fixed = False
for _col in _cols_department_lacks:
_bad = rf'\b{re.escape(_da)}\.{_col}\b'
if re.search(_bad, sql_query, re.IGNORECASE):
sql_query = re.sub(_bad, f'{_dma}.{_col}', sql_query, flags=re.IGNORECASE)
_fixed = True
if _fixed:
print(f"[POST-FIX] Redirected {_da}.{{emp_no|from_date|to_date}} -> {_dma}.* (department lacks these columns)")
except Exception:
pass
# Fix 5: The employee table does not have a 'to_date' column.
# If employee is the ONLY table (no JOINs) but the query has "to_date = '9999-01-01'", remove it.
# This happens because the system prompt universally instructs filtering current rows with to_date.
try:
if (re.search(r'\bFROM\s+employee\b', sql_query, re.IGNORECASE)
and not re.search(r'\bJOIN\b', sql_query, re.IGNORECASE)
and ACTIVE_SCHEMA_PROFILE.is_employee_db):
if re.search(r'\bto_date\s*=\s*\'9999-01-01\'', sql_query, re.IGNORECASE):
# Remove if it's the first/only condition
sql_query = re.sub(r'\bWHERE\s+(?:e\.)?to_date\s*=\s*\'9999-01-01\'\s*(?:AND)?\s*', 'WHERE ', sql_query, flags=re.IGNORECASE)
# Remove if it's a subsequent condition
sql_query = re.sub(r'\bAND\s+(?:e\.)?to_date\s*=\s*\'9999-01-01\'\s*', '', sql_query, flags=re.IGNORECASE)
# Cleanup trailing empty WHERE clauses
sql_query = re.sub(r'\bWHERE\s+(GROUP\s+BY|ORDER\s+BY|LIMIT|;|$)', r'\1', sql_query, flags=re.IGNORECASE)
sql_query = re.sub(r'\bWHERE\s*$', '', sql_query, flags=re.IGNORECASE)
print("[POST-FIX] Removed invalid 'to_date' filter (employee table has no to_date column)")
except Exception:
pass
# Fix 6: hire_date only exists in the employee table. If it is prefixed by an alias belonging to another table,
# redirect it to the employee table's alias (or 'employee' if no alias).
try:
if re.search(r'\bhire_date\b', sql_query, re.IGNORECASE) and ACTIVE_SCHEMA_PROFILE.is_employee_db:
_emp_alias_match = re.search(r'\b(?:FROM|JOIN)\s+employee\s+(?:AS\s+)?(\w+)\b', sql_query, re.IGNORECASE)
_emp_alias = _emp_alias_match.group(1) if _emp_alias_match else 'employee'
def _fix_hire_date_alias(match):
_prefix = match.group(1)
if _prefix.lower() not in [_emp_alias.lower(), 'employee']:
return f"{_emp_alias}.hire_date"
return match.group(0)
_sql_query_new = re.sub(r'\b(\w+)\.hire_date\b', _fix_hire_date_alias, sql_query, flags=re.IGNORECASE)
if _sql_query_new != sql_query:
print(f"[POST-FIX] Redirected wrong alias for hire_date to {_emp_alias}.hire_date")
sql_query = _sql_query_new
except Exception:
pass
# Continue with the rest of the workflow as before, but always use sql_query (now validated/fixed)
# Pre-execution semantic/alias guard for title queries
if ACTIVE_SCHEMA_PROFILE.is_employee_db and re.search(r"from\s+title\b", sql_query, re.IGNORECASE):
if re.search(r"t\.(?:emp_no|title|to_date)", sql_query, re.IGNORECASE) and not re.search(r"from\s+title\s+(?:as\s+)?t\b", sql_query, re.IGNORECASE):
print("Injecting missing alias 't' for title before execution")
sql_query = re.sub(r"from\s+title\b", "FROM title t", sql_query, flags=re.IGNORECASE)
if re.search(r"where\s+.*department\s*=", sql_query, re.IGNORECASE) and not re.search(r"join\s+department", sql_query, re.IGNORECASE):
print("Adjusting misuse of department in title-only query before execution")
sql_query = adjust_title_query(sql_query, question, None)
# Sanitize: move misplaced WHERE clauses that appear after GROUP BY/ORDER BY back to before them
try:
upper_sql = sql_query.upper()
where_m = re.search(r'\bWHERE\b', upper_sql)
if where_m:
where_idx = where_m.start()
remainder = upper_sql[where_idx + 5:]
next_clause_m = re.search(r'\b(GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b', remainder)
if next_clause_m:
next_rel = where_idx + 5 + next_clause_m.start()
where_clause = sql_query[where_idx:next_rel]
sql_query = sql_query[:where_idx] + sql_query[next_rel:]
grp_m = re.search(r'\bGROUP\s+BY\b', sql_query, re.IGNORECASE)
ord_m = re.search(r'\bORDER\s+BY\b', sql_query, re.IGNORECASE)
lim_m = re.search(r'\bLIMIT\b', sql_query, re.IGNORECASE)
if grp_m:
insert_at = grp_m.start()
elif ord_m:
insert_at = ord_m.start()
elif lim_m:
insert_at = lim_m.start()
else:
insert_at = len(sql_query)
sql_query = sql_query[:insert_at] + where_clause + ' ' + sql_query[insert_at:]
print("[AUTO-FIX] Repositioned WHERE clause before GROUP/ORDER/LIMIT.")
except Exception:
pass
try:
# DISABLED: Schema mapper has a bug where it replaces table aliases with SQL keywords
# See GitHub issue: regex captures "ON" and "JOIN" as aliases, causing syntax errors
# The 3-level architecture generates correct SQL, so this post-processing is unnecessary
skip_schema_mapper = True # FORCE DISABLED to prevent corruption
if not skip_schema_mapper:
normalized_sql = schema_aware_column_mapper(sql_query)
if normalized_sql.strip() != sql_query.strip():
print("[SCHEMA-MAPPER] Rewrote SQL to match inspected schema.")
print("Before:", sql_query)
print("After:", normalized_sql)
sql_query = normalized_sql
else:
print("[SCHEMA-MAPPER] Disabled to prevent corruption of valid SQL")
except Exception as _map_err:
print(f"Schema mapper error (continuing with original SQL): {_map_err}")
print(f"Executing query: {sql_query}")
if re.search(r"count\s*\(", sql_query, re.IGNORECASE):
print("[DIAGNOSTIC] Detected COUNT query. Full SQL (sanitized):")
print(sql_query)
# Fix stray semicolons before LIMIT — LLM sometimes puts ';' after ORDER BY clause
# Pattern 1: explicit ;\s*LIMIT (original)
if re.search(r";\s*LIMIT\s+\d+", sql_query, re.IGNORECASE):
sql_query = re.sub(r";\s*(LIMIT\s+\d+)", r" \1", sql_query, flags=re.IGNORECASE)
print("[AUTO-FIX] Removed stray semicolon before LIMIT")
# Pattern 2: semicolon after ORDER BY / GROUP BY / HAVING clause, before LIMIT on next line
sql_query = re.sub(r"(ORDER\s+BY\s+[^;]+);(\s*LIMIT)", r"\1\2", sql_query, flags=re.IGNORECASE)
sql_query = re.sub(r"(GROUP\s+BY\s+[^;]+);(\s*LIMIT)", r"\1\2", sql_query, flags=re.IGNORECASE)
sql_query = re.sub(r";\s*;+$", ";", sql_query)
try:
if re.search(r'\b(count|sum|avg|min|max)\s*\(', sql_query, flags=re.IGNORECASE) and 'group by' not in sql_query.lower():
sql_query = re.sub(r"\s+LIMIT\s+\d+\s*;?\s*$", ";" if sql_query.strip().endswith(';') else '', sql_query, flags=re.IGNORECASE)
sql_query = re.sub(r"\s+LIMIT\s+\d+\b", '', sql_query, flags=re.IGNORECASE)
except Exception:
pass
if re.search(r"\bdde\.", sql_query):
print("[AUTO-FIX] Correcting alias typo 'dde.' to 'de.'")
sql_query = re.sub(r"\bdde\.", "de.", sql_query)
if re.search(r"\bdee\.", sql_query):
print("[AUTO-FIX] Correcting alias typo 'dee.' to 'de.'")
sql_query = re.sub(r"\bdee\.", "de.", sql_query)
if (ACTIVE_SCHEMA_PROFILE.is_employee_db
and re.search(r"avg\s*\(\s*s\.salary", sql_query, re.IGNORECASE)
and 'group by' in sql_query.lower()):
# Only regenerate if SQL has actual issues — skip if already valid
_regen_pre_check = enhanced_validate_sql(sql_query)
if not _regen_pre_check.get('valid'):
print("[REGEN] Regenerating AVG salary per department query for correct structure via LLM.")
from .invariants import build_invariants, evaluate_invariants, regenerate_with_constraints, CATEGORY_AVG_SALARY
cat = classify_question(question)
if cat == CATEGORY_AVG_SALARY:
invs = build_invariants(cat, question)
missing = evaluate_invariants(sql_query, invs)
if missing:
regen_sql = regenerate_with_constraints(question, sql_query, missing, Settings.llm, run_llm_with_timeout, attempt=0)
if regen_sql:
sql_query = regen_sql
else:
print("[REGEN] AVG salary REGEN skipped — SQL already valid")
if (ACTIVE_SCHEMA_PROFILE.is_employee_db
and re.match(r"\s*select\s+count\s*\(\s*\*\s*\)\s+from\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+limit", sql_query, flags=re.IGNORECASE)
and 'group by' not in sql_query.lower()):
print("[REGEN] Regenerating simple total count query for correct structure via LLM.")
from .invariants import build_invariants, evaluate_invariants, regenerate_with_constraints, CATEGORY_SIMPLE_COUNT_EMP
cat = classify_question(question)
if cat == CATEGORY_SIMPLE_COUNT_EMP:
invs = build_invariants(cat, question)
missing = evaluate_invariants(sql_query, invs)
if missing:
regen_sql = regenerate_with_constraints(question, sql_query, missing, Settings.llm, run_llm_with_timeout, attempt=0)
if regen_sql:
sql_query = regen_sql
if (ACTIVE_SCHEMA_PROFILE.is_employee_db
and re.search(r"group\s+by\s+.*dept_name", sql_query, re.IGNORECASE)
and re.search(r"count\s*\(\s*\*\s*\)", sql_query, re.IGNORECASE)
and 'employee_count' not in sql_query.lower()):
try:
select_part, rest = sql_query.split('FROM', 1)
select_part_fixed = re.sub(r"count\s*\(\s*\*\s*\)\s*(?!as)", "COUNT(*) AS employee_count ", select_part, count=1, flags=re.IGNORECASE)
sql_query = select_part_fixed + 'FROM' + rest
except Exception:
pass
if ('dept_name' in sql_query.lower() and 'count(' in sql_query.lower() and 'dept_emp' not in sql_query.lower() and re.search(r'from\s+department', sql_query, re.IGNORECASE)):
try:
from .invariants import build_invariants, evaluate_invariants, regenerate_with_constraints, CATEGORY_EMP_COUNT_BY_DEPT
cat = classify_question(question)
if cat == CATEGORY_EMP_COUNT_BY_DEPT:
invs = build_invariants(cat, question)
missing = evaluate_invariants(sql_query, invs)
if missing:
print('[REGEN] Initiating LLM regeneration to add missing dept_emp join and related invariants')
regen_sql = regenerate_with_constraints(question, sql_query, missing, Settings.llm, run_llm_with_timeout, attempt=0)
if regen_sql:
sql_query = regen_sql
except Exception as regen_err:
print(f'[REGEN] Skipped regeneration due to error: {regen_err}')
if re.search(r"having\s+count\s*\(\s*\*\s*\)\s*=\s*\d+", sql_query, re.IGNORECASE) and 'join dept_emp' not in sql_query.lower():
repair = generate_employee_count_by_department_sql(question)
if repair:
print("[REPAIR] Replaced malformed HAVING constant count query with micro-generated employee count query")
sql_query = repair
hallucinated_tables = []
# Strip out EXTRACT(... FROM ...) and similar functions before scanning for tables
_sql_for_tables = re.sub(r'\bEXTRACT\s*\([^)]+\)', '', sql_query, flags=re.IGNORECASE)
for tbl in re.findall(r'\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b', _sql_for_tables, re.IGNORECASE):
if tbl and tbl.lower() not in [x.lower() for x in available_tables]:
hallucinated_tables.append(tbl)
if hallucinated_tables:
print(f"[REGEN] Hallucinated tables detected: {hallucinated_tables}. Regenerating SQL without them.")
feedback = f"Do NOT use these tables: {', '.join(hallucinated_tables)}. Use only these: {', '.join(available_tables)}. For current rows, use WHERE ...to_date = '9999-01-01'."
regen_sql = query_craft(question + " " + feedback)
sql_query = regen_sql
print("\n=== RESULT PRESENTER AGENT ===")
print("Executing query and preparing intelligent presentation...")
# Execute query using the present_results function
final_result = await present_results(question, sql_query)
# Add diagnostic verification for result quality
if final_result and isinstance(final_result, str):
print(f"[PRESENTER] Generated response of {len(final_result)} characters")
if len(final_result) > 100:
print("[PRESENTER] Response includes contextual insights and formatting")
else:
print("[PRESENTER] Concise response provided")
print("[PRESENTER] Result presentation completed successfully")
except Exception as exec_error:
error_msg = str(exec_error)
if 'syntax error' in error_msg.lower():
corrected = sql_query
corrected = re.sub(r';\s*LIMIT', ' LIMIT', corrected, flags=re.IGNORECASE)
if re.search(r'HAVING\s+COUNT\s*\(\s*\*\s*\)\s*=\s*\d+', corrected, flags=re.IGNORECASE) and not re.search(r'SELECT\s+.*COUNT\s*\(', corrected, flags=re.IGNORECASE):
try:
if re.search(r'from\s+department', corrected, re.IGNORECASE):
corrected = re.sub(r'^SELECT\s+dept_name', 'SELECT dept_name, COUNT(*) AS employee_count', corrected, flags=re.IGNORECASE)
corrected = re.sub(r'HAVING\s+COUNT\s*\(\s*\*\s*\)\s*=\s*\d+\s*', '', corrected, flags=re.IGNORECASE)
if 'GROUP BY' not in corrected.upper():
corrected = re.sub(r'FROM', 'FROM', corrected)
if not re.search(r'GROUP\s+BY', corrected, re.IGNORECASE):
corrected = re.sub(r'LIMIT', 'GROUP BY dept_name LIMIT', corrected, flags=re.IGNORECASE)
if 'employee_count' not in corrected.lower():
corrected = re.sub(r'COUNT\s*\(\s*\*\s*\)', 'COUNT(*) AS employee_count', corrected, count=1, flags=re.IGNORECASE)
except Exception:
pass
if corrected != sql_query:
print(f"[AUTO-FIX] Applying syntax corrections: {corrected}")
try:
results = await execute_query(corrected)
memory_manager.force_collect_garbage()
final_result = format_results(results, question)
update_history(question, final_result)
return final_result
except Exception as post_fix_err:
print(f"[AUTO-FIX] Corrected query still failed: {post_fix_err}")
if not 'tried_regen_after_error' in locals():
tried_regen_after_error = False
if not tried_regen_after_error and 'does not exist' in error_msg.lower() and 'column' in error_msg.lower():
tried_regen_after_error = True
missing_cols = []
for m in re.findall(r'column "([a-zA-Z0-9_\.]+)" does not exist', error_msg, flags=re.IGNORECASE):
missing_cols.append(m)
feedback = f"Previously generated SQL referenced non-existent columns: {', '.join(missing_cols)}. Remove or replace them using ONLY columns from the provided schemas."
print(f"[REGEN_AFTER_ERROR] Feedback: {feedback}")
if "relation" in error_msg.lower() and "does not exist" in error_msg.lower():
match = re.search(r'relation "([^"]+)" does not exist', error_msg.lower())
if match:
bad_table = match.group(1)
if bad_table.endswith('s') and bad_table[:-1] in available_tables:
fixed_query = sql_query.replace(bad_table, bad_table[:-1])
print(f"Fixing pluralization issue: {bad_table} -> {bad_table[:-1]}")
print(f"Retrying with fixed query: {fixed_query}")
try:
results = await execute_query(fixed_query)
final_result = format_results(results, question)
except Exception as retry_error:
print(f"Retry failed with error: {str(retry_error)}")
final_result = f"I encountered an error while querying the database: {str(retry_error)}\n\nPlease try rephrasing your question or ask about a different aspect of the data."
else:
available_tables_str = ", ".join(available_tables)
final_result = f"I encountered an error with the database query. The table '{bad_table}' doesn't exist in the database. Available tables are: {available_tables_str}."
else:
final_result = f"I encountered an error while querying the database: {error_msg}"
else:
if "syntax error" in error_msg.lower():
final_result = f"I encountered a syntax error in the SQL query. Please try rephrasing your question: {error_msg}"
else:
final_result = f"I encountered an error while querying the database: {error_msg}"
# Final system cleanup and reporting (separate from Result Presenter Agent)
print("\n--- Post-Query System Cleanup ---")
if hasattr(memory_manager, 'perform_full_cleanup'):
memory_manager.perform_full_cleanup()
else:
memory_manager.force_collect_garbage()
stats = memory_manager.get_memory_stats()
print(f"Memory after processing: {stats['available_mb']:.2f}MB available")
update_history(question, final_result)
return final_result
async def main():
global llm
# Initialize memory management
print("\n--- Memory Management Initialization ---")
# Force garbage collection and memory monitoring
memory_manager.force_collect_garbage()
# Check memory before starting
print("\n--- System Resource Check ---")
stats = memory_manager.get_memory_stats()
print(f"Memory status: {stats['available_mb']:.2f}MB available of {stats['total_mb']:.2f}MB total")
if stats['available_mb'] < 1000:
print("⚠️ WARNING: System memory is low at startup. Application may experience issues.")
print("Please close other applications to free up memory before proceeding.")
print("At least 1GB of free memory is recommended for reliable operation.")
# Continue anyway, but with a warning
# Initialize database
print("\n--- Database Initialization ---")
db_metadata = await initialize_database(uri=DB_CONNECTION_URL)
if not db_metadata["tables"]:
print("Database initialization failed: No tables found")
return
print(f"Database initialized with tables: {available_tables}")
# Print welcome message
print("\nWelcome to the SQL Assistant! Type 'quit' to exit.")
print("You can ask questions about the employee database, and I'll help you find the answers.")
print("Example questions:")
print("- What is the average salary for employees?")
print("- How many employees work in each department?")
print("- Who are the current department managers?")
print("\nWhat would you like to know?")
# Main loop
consecutive_errors = 0
max_consecutive_errors = 3
while True:
try:
# Blocking input removed for non-interactive/cloud stability
# For local terminal use, uncomment the line below:
# user_question = input("\nYour question: ").strip()
print("\n[INFO] Non-interactive mode: exiting main loop.")
break
# Handle empty input
if not user_question:
print("Please enter a question or type 'quit' to exit.")
continue
# Reset consecutive errors on successful input
consecutive_errors = 0
# Run garbage collection before each query
gc.collect()
except Exception as e:
print(f"Error in main loop: {e}")
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
print("Multiple consecutive errors detected. Performing memory cleanup...")
gc.collect()
consecutive_errors = 0
# Skip this iteration on input/read error
continue
# Check if there's enough memory to safely process the request
if not check_memory_safety():
stats_now = memory_manager.get_memory_stats()
# If memory is critically low, refuse to proceed
if stats_now['available_mb'] < memory_manager.critical_mb:
print("System is critically low on memory. Please close other applications and try again.")
print("Cannot proceed due to critically low memory. Please close some applications and try again.")
continue
# Otherwise, operate in a degraded, aggressive memory mode instead of outright refusing
print(f"⚠️ Low memory warning: Only {stats_now['available_mb']:.2f}MB available, need at least {memory_manager.threshold_mb:.2f}MB")
print("Proceeding in degraded mode with aggressive resource reduction. Responses may be less detailed or slower.")
# Try to create a reduced LLM and continue; if that fails, then abort
try:
gc.collect()
reduced = initialize_llm(progressive_reduction=2)
if reduced is not None:
llm = reduced
Settings.llm = reduced
print("Degraded LLM initialized and set as active model.")
else:
print("Failed to initialize degraded LLM instance. Aborting request.")
continue
except Exception as e:
print(f"Failed to initialize degraded LLM due to error: {e}")
print("Cannot proceed due to low memory. Please close other applications and try again.")
continue
# Process the query through the agentic workflow
print(f"\n--- Processing Query: {user_question} ---")
try:
# Run the agentic workflow
result = await agentic_query_process(user_question)
# Display the result
print(f"\nAnswer: {result}")
# Run garbage collection after successful query
gc.collect()
except Exception as e:
# Handle potential memory or timeout errors
if "model requires more system memory" in str(e) or "memory" in str(e).lower() or "timed out" in str(e).lower() or "timeout" in str(e).lower():
print(f"\n⚠️ Model issue detected: {str(e)}")
# Try to free up memory
gc.collect()
# (No template fallback path.)
print(f"Trying with smaller model ({FALLBACK_MODEL_NAME})...")
# Create a new LLM with the smaller model and more aggressive memory settings
try:
fallback_llm = Ollama(
model=FALLBACK_MODEL_NAME,
base_url=OLLAMA_BASE_URL,
temperature=0.1,
request_timeout=30.0, # Shorter timeout for fallback model
additional_kwargs={
"num_ctx": 1024, # Ultra-minimal context window
"num_batch": 1, # Minimal batch size
"num_gpu": 0, # Force CPU mode
"f16_kv": True, # Use half-precision
"mirostat": 0, # Disable mirostat sampling
"num_thread": 1, # Minimal threads
"seed": 42, # Consistent seed
"top_k": 20,
"top_p": 0.9,
"num_predict": 256
}
)
# Temporarily replace the LLM
original_llm = llm
llm = fallback_llm
Settings.llm = fallback_llm
# Try again with the smaller model
try:
result = await agentic_query_process(user_question)
print(f"\nAnswer (using fallback model): {result}")
except Exception as inner_fallback_error:
print(f"Fallback model also failed: {str(inner_fallback_error)}")
print("All query generation methods failed. Please try rephrasing your question.")
except Exception as fallback_error:
print(f"Error with fallback model: {str(fallback_error)}")
print("All query generation methods failed. Please try rephrasing your question.")
# If we've had too many consecutive errors, trigger a manual garbage collection
if consecutive_errors >= max_consecutive_errors:
print("Multiple consecutive errors detected. Performing memory cleanup...")
gc.collect()
consecutive_errors = 0
if __name__ == "__main__":
asyncio.run(main()) |