Spaces:
Runtime error
Runtime error
File size: 100,616 Bytes
fb8df06 | 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 | """
Palate / TasteOS Universal Intelligence Engine
100% ARCHITECTURAL PURITY. Zero regex brand name cheating. Zero paid Google APIs.
1. Universal Craft Lexicon: Evaluates genuine culinary techniques only.
2. Relational Accreditations: Joins third-party critic awards (Michelin, 50 Top Pizza, Eater).
"""
import json
import math
import os
import re
import sys
import pickle
from collections import Counter
import numpy as np
# Ensure UTF-8 output on Windows consoles
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
DATA_FILE = os.path.join(DATA_DIR, "amsterdam_places.json")
ACCREDITATIONS_FILE = os.path.join(DATA_DIR, "amsterdam_accreditations.json")
VECTORS_FILE = os.path.join(DATA_DIR, "amsterdam_vectors.npy")
MODEL_FILE = os.path.join(DATA_DIR, "semantic_model.pkl")
# Generic OSM administrative wrapper tags that do NOT dilute culinary purity
GENERIC_WRAPPER_TAGS = {
"restaurant", "fast_food", "cafe", "bar", "pub", "sandwich",
"diner", "food", "takeaway", "delivery", "food_court"
}
GENERIC_WRAPPER_RE = re.compile(
r'\b(restaurant|restaurants|studio|bistro|bistrot|cafe|caffe|coffee|kitchen|bar|grill|dubai|uae|amsterdam|'
r'bakery|bakkerij|pizzeria|pizza|gelato|gelateria|roasters|roastery|ramen|pasta|trattoria|osteria|'
r'brasserie|taverna|pub|lounge|house|shop|store|the|de|het|den|der|la|le|les|il|lo|el|al|da|di)\b',
re.IGNORECASE
)
# Generic query intent filler nouns (these indicate grammatical intent, not specific cuisine)
QUERY_INTENT_STOPWORDS = {
"restaurant", "restaurants", "resturant", "resturants",
"food", "foods", "place", "places", "spot", "spots",
"joint", "joints", "eatery", "eateries", "kitchen", "kitchens",
"eat", "eating", "dinner", "lunch", "breakfast",
"best", "good", "great", "top", "famous", "local", "authentic"
}
# Bidirectional mapping between country names and cuisine adjectives
CUISINE_NATIONALITY_EXPANSIONS = {
"syria": ["syria", "syrian"],
"syrian": ["syria", "syrian"],
"italy": ["italy", "italian"],
"italian": ["italy", "italian"],
"mexico": ["mexico", "mexican"],
"mexican": ["mexico", "mexican"],
"turkey": ["turkey", "turkish"],
"turkish": ["turkey", "turkish"],
"japan": ["japan", "japanese"],
"japanese": ["japan", "japanese"],
"korea": ["korea", "korean"],
"korean": ["korea", "korean"],
"china": ["china", "chinese"],
"chinese": ["china", "chinese"],
"vietnam": ["vietnam", "vietnamese"],
"vietnamese": ["vietnam", "vietnamese"],
"thailand": ["thailand", "thai"],
"thai": ["thailand", "thai"],
"greece": ["greece", "greek"],
"greek": ["greece", "greek"],
"spain": ["spain", "spanish"],
"spanish": ["spain", "spanish"],
"lebanon": ["lebanon", "lebanese"],
"lebanese": ["lebanon", "lebanese"],
"morocco": ["morocco", "moroccan"],
"moroccan": ["morocco", "moroccan"],
"ethiopia": ["ethiopia", "ethiopian"],
"ethiopian": ["ethiopia", "ethiopian"],
"india": ["india", "indian"],
"indian": ["india", "indian"],
"indonesia": ["indonesia", "indonesian"],
"indonesian": ["indonesia", "indonesian"],
"suriname": ["suriname", "surinamese"],
"surinamese": ["suriname", "surinamese"],
"historic": ["historic", "monument", "rijksmonument", "heritage", "monumenten", "century", "monumental"],
"monument": ["monument", "rijksmonument", "historic", "heritage", "monumenten", "monumental"],
"monuments": ["monument", "rijksmonument", "historic", "heritage", "monumenten", "monumental"],
"rijksmonument": ["rijksmonument", "monument", "historic", "heritage"],
"heritage": ["heritage", "historic", "monument", "rijksmonument"]
}
# Multi-City Neighborhood & Sub-District Spatial Lexicon
NEIGHBORHOOD_ALIASES_BY_CITY = {
"dubai": {
"jlt": ["jlt", "jumeirah lakes towers", "lake view"],
"jumeirah lakes towers": ["jlt", "jumeirah lakes towers"],
"marina": ["marina", "dubai marina"],
"dubai marina": ["marina", "dubai marina"],
"jbr": ["jbr", "jumeirah beach residence", "the beach"],
"jumeirah beach residence": ["jbr", "jumeirah beach residence"],
"satwa": ["satwa", "al dhiyafah", "2nd december"],
"al satwa": ["satwa", "al dhiyafah", "2nd december"],
"deira": ["deira", "al rigga", "naif", "al muraqqabat", "al sabkha"],
"bur dubai": ["bur dubai", "al fahidi", "meena bazaar", "al bastakiya", "al souq"],
"al fahidi": ["al fahidi", "bur dubai", "al bastakiya"],
"karama": ["karama", "al karama"],
"al karama": ["karama", "al karama"],
"downtown": ["downtown", "downtown dubai", "sheikh mohammed bin rashid"],
"downtown dubai": ["downtown", "downtown dubai"],
"difc": ["difc", "gate village", "gate avenue", "financial centre"],
"business bay": ["business bay"],
"al barsha": ["al barsha", "barsha"],
"barsha": ["al barsha", "barsha"],
"al quoz": ["al quoz", "alserkal", "alserkal avenue", "quoz"],
"jumeirah": ["jumeirah", "jumeirah 1", "jumeirah 2", "jumeirah 3"],
"umm suqeim": ["umm suqeim", "fishing harbour"],
"palm": ["palm", "palm jumeirah"],
"palm jumeirah": ["palm", "palm jumeirah"],
"bluewaters": ["bluewaters", "bluewaters island"],
"motor city": ["motor city"],
"mirdif": ["mirdif"],
"al safa": ["al safa", "wasl", "dar wasl"],
"wasl": ["wasl 51", "dar wasl", "al wasl"]
},
"amsterdam": {
"jordaan": ["jordaan"],
"pijp": ["de pijp", "pijp"],
"de pijp": ["de pijp", "pijp"],
"oud west": ["oud-west", "oud west", "de baarsjes"],
"de baarsjes": ["de baarsjes", "oud-west"],
"centrum": ["centrum", "amsterdam-centrum", "grachtengordel"],
"oost": ["oost", "amsterdam-oost", "indische buurt"],
"noord": ["noord", "amsterdam-noord", "ndsm"],
"zuid": ["zuid", "amsterdam-zuid", "oud-zuid"],
"westerpark": ["westerpark"],
"nieuw west": ["nieuw-west", "nieuw west"]
},
"istanbul": {
"kadikoy": ["kadıköy", "kadikoy", "moda", "caferağa"],
"kadıköy": ["kadıköy", "kadikoy", "moda", "caferağa"],
"moda": ["moda", "kadıköy", "kadikoy"],
"beyoglu": ["beyoğlu", "beyoglu", "taksim", "cihangir", "istiklal"],
"beyoğlu": ["beyoğlu", "beyoglu", "taksim", "cihangir", "istiklal"],
"karakoy": ["karaköy", "karakoy", "galata"],
"karaköy": ["karaköy", "karakoy", "galata"],
"besiktas": ["beşiktaş", "besiktas", "ortaköy", "akaretler"],
"beşiktaş": ["beşiktaş", "besiktas", "ortaköy", "akaretler"],
"fatih": ["fatih", "sultanahmet", "eminönü", "balat"],
"sultanahmet": ["sultanahmet", "fatih"],
"nisantasi": ["nişantaşı", "nisantasi", "teşvikiye"],
"nişantaşı": ["nişantaşı", "nisantasi", "teşvikiye"],
"sisli": ["şişli", "sisli", "mecidiyeköy"],
"şişli": ["şişli", "sisli", "mecidiyeköy"],
"uskudar": ["üsküdar", "uskudar"],
"üsküdar": ["üsküdar", "uskudar"],
"bebek": ["bebek", "arnavutköy"]
},
"london": {
"soho": ["soho"],
"shoreditch": ["shoreditch", "hackney"],
"covent garden": ["covent garden"],
"mayfair": ["mayfair"],
"camden": ["camden", "camden town"],
"brixton": ["brixton"],
"borough": ["borough", "london bridge"],
"kensington": ["kensington", "south kensington"],
"chelsea": ["chelsea"],
"notting hill": ["notting hill"],
"islington": ["islington", "angel"],
"fitzrovia": ["fitzrovia"],
"marylebone": ["marylebone"]
},
"paris": {
"marais": ["le marais", "marais"],
"le marais": ["le marais", "marais"],
"montmartre": ["montmartre"],
"saint germain": ["saint-germain", "saint germain"],
"bastille": ["bastille"],
"belleville": ["belleville"],
"canal saint martin": ["canal saint-martin", "canal saint martin"],
"latin quarter": ["quartier latin", "latin quarter"],
"pigalle": ["pigalle", "so-pi"],
"opera": ["opéra", "opera"]
},
"tokyo": {
"shinjuku": ["shinjuku", "新宿"],
"shibuya": ["shibuya", "渋谷"],
"ginza": ["ginza", "銀座"],
"roppongi": ["roppongi", "六本木"],
"asakusa": ["asakusa", "浅草"],
"akihabara": ["akihabara", "秋葉原"],
"tsukiji": ["tsukiji", "築地"],
"shimokitazawa": ["shimokitazawa", "下北沢"],
"ebisu": ["ebisu", "恵比寿"],
"ueno": ["ueno", "上野"],
"ikebukuro": ["ikebukuro", "池袋"]
},
"new york": {
"soho": ["soho"],
"williamsburg": ["williamsburg"],
"east village": ["east village"],
"west village": ["west village", "greenwich village"],
"greenwich village": ["greenwich village", "west village"],
"lower east side": ["lower east side", "les"],
"chelsea": ["chelsea"],
"chinatown": ["chinatown"],
"harlem": ["harlem"],
"astoria": ["astoria"],
"bushwick": ["bushwick"],
"dumbo": ["dumbo"],
"tribeca": ["tribeca"]
},
"rome": {
"trastevere": ["trastevere"],
"testaccio": ["testaccio"],
"monti": ["monti", "rione monti"],
"prati": ["prati"],
"centro storico": ["centro storico", "pantheon", "navona"],
"san lorenzo": ["san lorenzo"],
"pigneto": ["pigneto"],
"campo de fiori": ["campo de' fiori", "campo de fiori"]
}
}
# Global Industrial Fast Food Franchises (Actual commercial megachains to penalize)
GLOBAL_INDUSTRIAL_CHAINS = {
"mcdonald's", "mcdonalds", "burger king", "kfc", "subway",
"domino's", "dominos", "domino's pizza", "new york pizza",
"pizza hut", "starbucks", "dunkin'", "dunkin", "dunkin' donuts",
"five guys", "taco bell", "vapiano", "wagamama", "febò", "febo",
"dam express franchising", "nightlife food group", "tours & waffles holding bv"
}
# Amsterdam Tourist Trap Epicenters (Dense tourist pedestrian corridors)
TOURIST_EPICENTERS = [
{"name": "Dam Square", "lat": 52.3731, "lon": 4.8932},
{"name": "Damrak / Centraal Corridor", "lat": 52.3775, "lon": 4.8990},
{"name": "Red Light District (De Wallen)", "lat": 52.3725, "lon": 4.8970},
{"name": "Leidseplein Tourist Ring", "lat": 52.3639, "lon": 4.8829},
{"name": "Rembrandtplein Tourist Ring", "lat": 52.3660, "lon": 4.8967}
]
# Universal All-Dish Craft Lexicon
# STRICT RULE: Contains ONLY universal cooking techniques and culinary terminology.
# ZERO individual restaurant brand names permitted here!
CRAFT_PATTERNS = [
# 1. 🍕 Pizza & Italian Artisanal Craft
{
"regex": r"\b(forno a legna|wood[\s-]fired|houtoven|houtgestookt)\b",
"badge": "🔥 Wood-Fired Stone Oven",
"detail": "Bakes inside a high-temperature wood fire stone oven at 450°C",
"points": 35
},
{
"regex": r"\b(napoletana|neapolitan|verace|san marzano|impasto)\b",
"badge": "🍕 Authentic Neapolitan Pizza Craft",
"detail": "High-hydration long-fermented dough, San Marzano tomatoes, and blistered cornicione",
"points": 35
},
{
"regex": r"\b(pizzeria|pizzabakker|pizzabakkers|italian_pizza)\b",
"badge": "🍕 Dedicated Pizzeria Kitchen",
"detail": "Specialized pizza kitchen focusing on scratch-made dough and classic fermentation",
"points": 25
},
{
"regex": r"\b(fatta in casa|pasta fresca|casereccia|homemade pasta)\b",
"badge": "🍝 Fresh In-House Pasta",
"detail": "Noodles and pasta hand-rolled from scratch daily using semola and egg",
"points": 35
},
{
"regex": r"\b(trattoria|osteria|ristorante tradizionale)\b",
"badge": "🇮🇹 Traditional Italian Kitchen",
"detail": "Classic regional Italian tavern cooking",
"points": 20
},
# 2. 🍔 Burgers, Barbecue & Prime Meat Craft
{
"regex": r"\b(smash|smashburger|smashed)\b",
"badge": "🍔 Crispy-Edge Smash Patty",
"detail": "High-heat griddled smash patty with deep Maillard caramelization",
"points": 35
},
{
"regex": r"\b(butcher|slager|butchery|dry[\s-]aged|wagyu|angus|black angus|weidevlees|biologisch rundvlees)\b",
"badge": "🥩 Butcher Blend & Prime Meat",
"detail": "Coarse-ground prime beef blend (Angus/Wagyu/Dry-Aged) from dedicated butchery",
"points": 35
},
{
"regex": r"\b(brioche|potato bun|artisanaal broodje)\b",
"badge": "🍞 Artisanal Burger Bun",
"detail": "Freshly baked golden brioche or soft potato bun",
"points": 25
},
{
"regex": r"\b(smokehouse|barbecue|bbq|low[\s-]and[\s-]slow|pulled pork|smoked brisket)\b",
"badge": "🪵 Low & Slow Wood Smokehouse",
"detail": "Authentic wood-smoked barbecue slow-cooked over hardwood for 10+ hours",
"points": 35
},
{
"regex": r"\b(rotisserie|spit[\s-]roast)\b",
"badge": "🍗 Flame Rotisserie",
"detail": "Slow-turned spit-roasted poultry and meats over open flame",
"points": 25
},
# 3. 🍞 Sourdough, Bread & Viennoiserie Craft
{
"regex": r"\b(ambachtelijk|ambachtelijke|ambacht|artisan|artisanal)\b",
"badge": "🍞 Certified Scratch Artisan",
"detail": "Traditional scratch production without premade commercial mixes",
"points": 30
},
{
"regex": r"\b(desem|zuurdesem|sourdough|levain)\b",
"badge": "🌾 Wild Ferment Sourdough",
"detail": "Naturally fermented wild starter slow dough, zero commercial additives",
"points": 35
},
{
"regex": r"\b(croissant|pain au chocolat|viennoiserie|patisserie|kouign[\s-]amann)\b",
"badge": "🥐 French Viennoiserie",
"detail": "Laminated real butter pastry dough folded in-house",
"points": 30
},
{
"regex": r"\b(appeltaart|dutch apple pie|verse stroopwafel)\b",
"badge": "🥧 Traditional Dutch Baking",
"detail": "Heirloom Dutch bakery craft, warm cinnamon spice and fresh pressed dough",
"points": 25
},
# 4. 🍜 Japanese & East Asian Noodle/Grill Craft
{
"regex": r"\b(ramen|自家製麺|jikaseimen|tonkotsu|paitan|shoyu ramen)\b",
"badge": "🍜 Authentic Slow-Broth Ramen",
"detail": "12-hour simmered bone broth with crafted alkaline noodles",
"points": 35
},
{
"regex": r"\b(udon|soba|teuchi|hand[\s-]pulled|handmade noodles)\b",
"badge": "🥢 Hand-Pulled Artisan Noodles",
"detail": "Fresh hand-kneaded noodles cut or pulled to order",
"points": 35
},
{
"regex": r"\b(yakitori|sumibiyaki|binchotan|charcoal skewer)\b",
"badge": "🍢 Binchotan Charcoal Grill",
"detail": "Skewered meats grilled over smokeless Japanese white oak charcoal",
"points": 35
},
{
"regex": r"\b(omakase|edomae|sushi master)\b",
"badge": "🍣 Traditional Edomae Sushi",
"detail": "Hand-pressed nigiri with seasoned shari rice and cured wild fish",
"points": 35
},
{
"regex": r"\b(dim sum|dumpling|dumplings|gyoza|bao)\b",
"badge": "🥟 Fresh Hand-Pleated Dim Sum",
"detail": "Hand-folded dumplings and buns steamed fresh in bamboo baskets",
"points": 25
},
# 5. 🔥 Middle Eastern, Turkish & Levant Charcoal Craft
{
"regex": r"\b(közde|odun ateşi|mangal|charcoal|wood charcoal|cağ)\b",
"badge": "🔥 Oak Charcoal Embers",
"detail": "Cooks over natural hardwood charcoal embers, not gas or electric elements",
"points": 35
},
{
"regex": r"\b(yaprak döner|fillet|marinated lamb)\b",
"badge": "🥩 100% Whole Layered Meat",
"detail": "Hand-stacked whole meat fillets, never frozen industrial minced paste",
"points": 35
},
{
"regex": r"\b(tandır|tandoor|clay oven)\b",
"badge": "🔥 High-Heat Clay Tandoor",
"detail": "Bakes skewers and flatbreads inside an open-flame clay tandoor",
"points": 30
},
{
"regex": r"\b(falafel|hummus|mezze|sabich|lavaş|saj)\b",
"badge": "🧆 Traditional Levant Street Craft",
"detail": "House-ground fresh herbs, soaked chickpeas, and hot stone flatbreads",
"points": 25
},
# 6. 🐟 Dutch Seafood & Fishmonger Heritage
{
"regex": r"\b(haringhandel|vishandel|visspecialist|nieuwe haring|kibbeling|volendammer|paling)\b",
"badge": "🐟 Dedicated Fishmonger & Haring Master",
"detail": "Specialized fishmonger cleaning fresh Dutch raw herring daily",
"points": 35
},
# 7. 🍛 Surinamese & Indonesian Colonial Diaspora Craft
{
"regex": r"\b(surinaams|warung|spang|makandra|roti|doksa|bara|saoto|pom)\b",
"badge": "🍛 Authentic Surinamese Craft",
"detail": "Heirloom slow-cooked curries, Madame Jeanette sambal, and handmade roti",
"points": 30
},
{
"regex": r"\b(rijsttafel|indonesisch|rendang|sat[eé]|gado[\s-]gado)\b",
"badge": "🍚 Traditional Indonesian Rijsttafel",
"detail": "Authentic multi-dish Dutch-Indonesian heritage feast",
"points": 30
},
# 8. 🌮 Mexican, Taco & Latin Craft
{
"regex": r"\b(al carbón|hechas a mano|tortillería|birria|carnitas|suadero|taquería|trompo)\b",
"badge": "🌮 Authentic Mexican Street Craft",
"detail": "Fresh comal-pressed corn tortillas, slow-braised meats, and fire-roasted salsas",
"points": 35
},
# 9. 🍲 Southeast Asian Craft (Thai, Vietnamese)
{
"regex": r"\b(pho|phở|bánh mì|banh mi|pad thai|som tum|tom yum)\b",
"badge": "🍲 Authentic Southeast Asian Street Craft",
"detail": "Fresh herbs, 10-hour spiced bone broth, and crispy French-Vietnamese baguettes",
"points": 30
},
# 10. ☕ Specialty Coffee & Micro-Roasters
{
"regex": r"\b(specialty coffee|micro[\s-]roastery|koffiebranderij|single origin|filter coffee|v60|cold brew)\b",
"badge": "☕ Third-Wave Specialty Coffee",
"detail": "Direct-trade single origin beans roasted in-house and extracted to precision",
"points": 30
},
# 11. 🍦 Artisanal Gelato & Ice Cream
{
"regex": r"\b(gelato|gelateria|gelataio|cremeria|pozzetti|ambachtelijk ijs|ijssalon|vers gedraaid|sorbetto|granita)\b",
"badge": "🍦 Artisanal Scratch Gelato",
"detail": "Slow-churned whole milk and fresh seasonal fruit with zero industrial pastes",
"points": 30
},
# 12. 🍺 Historic Tasting Rooms, Brown Cafés & Craft Breweries
{
"regex": r"\b(jenever|proeflokaal|bruin caf[eé]|oude jenever|brouwerij|craft beer|microbrewery)\b",
"badge": "🍺 Historic Tasting Room & Craft Brewery",
"detail": "Traditional Dutch tavern culture with heritage genever and local small-batch beer",
"points": 30
}
]
# Tourist Trap & Commercial Warning Markers
TOURIST_TRAP_PATTERNS = [
{
"regex": r"\b(nutella)\b",
"penalty": -35,
"flag": "⚠️ Mass-Market Commercial Spread",
"reason": "Uses industrial palm-oil spread instead of artisanal chocolate/pastry"
},
{
"regex": r"\b(waffle|waffles)\b.*(steak|pizza|kebab|burger)",
"penalty": -40,
"flag": "⚠️ Multi-Concept Tourist Mill",
"reason": "Combines frozen waffles, steaks, and pizza under one tourist-focused roof"
},
{
"regex": r"\b(argentinian grill|argentinian steakhouse)\b",
"penalty": -35,
"flag": "⚠️ Damrak Tourist Steakhouse Archetype",
"reason": "Famous Amsterdam tourist corridor trap with inflated pricing and generic frozen beef"
},
{
"regex": r"\b(tours & tickets|nightlife food group|american bakery|ice bakery)\b",
"penalty": -30,
"flag": "⚠️ Commercial Holding Franchise",
"reason": "Owned by a nightlife or ticket-broker corporate holding company"
},
{
"regex": r"\b(express|corner)\b",
"penalty": -15,
"flag": "⚠️ High-Turnover Fast Casual",
"reason": "Optimized for speed rather than slow culinary craft"
}
]
def haversine_distance_m(lat1, lon1, lat2, lon2):
R = 6371000.0
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
delta_phi = math.radians(lat2 - lat1)
delta_lambda = math.radians(lon2 - lon1)
a = math.sin(delta_phi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
return R * c
MASTER_CRAFT_ARCHETYPES = [
# Italian & Mediterranean
{'id': 'pizza', 'label': 'Neapolitan Pizza', 'icon': '🍕', 'query': 'pizza', 'regex': r'\b(pizza|pizzeria|napoletana)\b'},
{'id': 'italian', 'label': 'Pasta & Italian Trattorias', 'icon': '🍝', 'query': 'italian', 'regex': r'\b(italian|pasta|trattoria|osteria|spaghetti)\b'},
{'id': 'tapas', 'label': 'Spanish Tapas & Jamón', 'icon': '🇪🇸', 'query': 'tapas', 'regex': r'\b(tapas|spanish|spaans|paella|jamon|jamón)\b'},
{'id': 'greek', 'label': 'Greek & Mediterranean', 'icon': '🇬🇷', 'query': 'greek', 'regex': r'\b(greek|grieks|souvlaki|gyros|taverna)\b'},
# Bread, Baking & Sweet Craft
{'id': 'bakery', 'label': 'Wild Sourdough & Bakeries', 'icon': '🍞', 'query': 'sourdough', 'regex': r'\b(bakery|bakkerij|sourdough|zuurdesem|croissant|artisan bread)\b'},
{'id': 'deli', 'label': 'Artisanal Broodjes & Delis', 'icon': '🥪', 'query': 'broodje', 'regex': r'\b(sandwich|broodje|deli|delicatessen|pastrami|bagel|belegd)\b'},
{'id': 'patisserie', 'label': 'Pâtisserie & Chocolaterie', 'icon': '🍫', 'query': 'patisserie', 'regex': r'\b(pastry|confectionery|chocolate|chocolatier|patisserie|stroopwafel|bonbon)\b'},
{'id': 'gelato', 'label': 'Artisanal Scratch Gelato', 'icon': '🍦', 'query': 'gelato', 'regex': r'\b(gelato|gelateria|ijssalon|ice_cream|sorbetto)\b'},
{'id': 'pancakes', 'label': 'Dutch Pannenkoeken & Crêpes', 'icon': '🥞', 'query': 'pancake', 'regex': r'\b(pancake|pannenkoek|poffertjes|crepe|cr[eê]pe)\b'},
# Low Countries & Local Heritage
{'id': 'seafood', 'label': 'Haring & North Sea Seafood', 'icon': '🐟', 'query': 'haring', 'regex': r'\b(haring|seafood|fish|vishandel|kibbeling|vis|oyster|mosselen)\b'},
{'id': 'frites', 'label': 'Artisanal Flemish Frites', 'icon': '🍟', 'query': 'friture', 'regex': r'\b(frit|friture|frites|patat|vlaams|friet)\b'},
{'id': 'tavern', 'label': 'Historic Bruin Cafés & Jenever', 'icon': '🏛️', 'query': 'jenever', 'regex': r'\b(jenever|bruin caf[eé]|brown caf[eé]|proeflokaal|historic tavern|eetcaf[eé])\b'},
{'id': 'craft_beer', 'label': 'Craft Breweries & Taprooms', 'icon': '🍺', 'query': 'craft beer', 'regex': r'\b(brewery|brouwerij|craft beer|taproom|microbrewery|draught)\b'},
# Diaspora & Heritage Kitchens
{'id': 'indonesian', 'label': 'Indonesian Rijsttafel', 'icon': '🍚', 'query': 'indonesian', 'regex': r'\b(indonesisch|indonesian|rijsttafel|rendang|warung|satay|sate)\b'},
{'id': 'surinamese', 'label': 'Surinamese Roti & Warung', 'icon': '🍛', 'query': 'surinaams', 'regex': r'\b(surinaams|surinamese|roti|saoto|bara|pom|bakkeljauw)\b'},
{'id': 'moroccan', 'label': 'Moroccan Tagine & Couscous', 'icon': '🫕', 'query': 'moroccan', 'regex': r'\b(moroccan|marokkaans|tagine|tajine|couscous|pastilla)\b'},
{'id': 'ethiopian', 'label': 'Ethiopian & Eritrean Injera', 'icon': '🫓', 'query': 'ethiopian', 'regex': r'\b(ethiopian|eritrean|injera|tibs|wat|habesha)\b'},
# Middle East, Levant, Anatolia & Gulf
{'id': 'esnaf', 'label': 'Historic Esnaf Lokantaları', 'icon': '🥘', 'query': 'esnaf', 'regex': r'\b(esnaf|lokanta|sulu yemek|guvec|güveç|kuru fasulye)\b'},
{'id': 'doner', 'label': 'Wood-Fired Yaprak Döner', 'icon': '🥙', 'query': 'doner', 'regex': r'\b(doner|döner|yaprak|donerci|dönerci)\b'},
{'id': 'ocakbasi', 'label': 'Ocakbaşı Charcoal Mangal', 'icon': '🔥', 'query': 'ocakbasi', 'regex': r'\b(ocakbasi|ocakbaşı|mangal|adana|urfa|cop sis|çöp şiş)\b'},
{'id': 'pide', 'label': 'Stone Oven Pide & Lahmacun', 'icon': '🥖', 'query': 'pide', 'regex': r'\b(pide|lahmacun|karadeniz|tas firin|taş fırın)\b'},
{'id': 'mandi', 'label': 'Yemeni Mandi & Khaleeji', 'icon': '🍗', 'query': 'mandi', 'regex': r'\b(mandi|kabsa|machboos|khaleeji)\b'},
{'id': 'persian', 'label': 'Persian Charcoal Grills', 'icon': '🍢', 'query': 'persian', 'regex': r'\b(persian|iranian|koobideh|sangak)\b'},
{'id': 'kofte', 'label': 'Kasap Köfte & Piyaz', 'icon': '🥩', 'query': 'kofte', 'regex': r'\b(kofte|köfte|köfteci|kofteci|piyaz)\b'},
{'id': 'baklava', 'label': 'Artisanal Antep Baklava', 'icon': '🍯', 'query': 'baklava', 'regex': r'\b(baklava|baklavacı|gulluoglu|fistikli|fıstıklı)\b'},
{'id': 'bosphorus_seafood', 'label': 'Coastal & Bosphorus Seafood', 'icon': '🐟', 'query': 'seafood', 'regex': r'\b(balik|balık|balikci|balıkçı|balik ekmek|hamour|fish fry)\b'},
{'id': 'breakfast', 'label': 'Village Breakfast & Kaymak', 'icon': '🍳', 'query': 'breakfast', 'regex': r'\b(kahvalti|kahvaltı|kaymak|menemen)\b'},
{'id': 'shawarma', 'label': 'Charcoal Kebab & Shawarma', 'icon': '🥙', 'query': 'shawarma', 'regex': r'\b(shawarma|shoarma|d[oö]ner|kebab|durum|d[uü]r[uü]m|mangal|tandur)\b'},
{'id': 'falafel', 'label': 'Levant Hummus & Falafel', 'icon': '🧆', 'query': 'falafel', 'regex': r'\b(falafel|hummus|mezze|meze|sabich|lebanese|libanees|manaeesh)\b'},
# Asian Craft
{'id': 'ramen', 'label': 'Slow-Broth Ramen & Noodles', 'icon': '🍜', 'query': 'ramen', 'regex': r'\b(ramen|noodles|udon|soba)\b'},
{'id': 'sushi', 'label': 'Edomae Sushi & Izakaya', 'icon': '🍣', 'query': 'sushi', 'regex': r'\b(sushi|japanese|izakaya|yakitori|omakase)\b'},
{'id': 'chinese', 'label': 'Dim Sum & Cantonese', 'icon': '🥟', 'query': 'chinese', 'regex': r'\b(chinese|chinees|dim sum|cantonese|sichuan|dumpling)\b'},
{'id': 'thai', 'label': 'Thai Street Kitchen', 'icon': '🍲', 'query': 'thai', 'regex': r'\b(thai|pad thai|curry|tom yum)\b'},
{'id': 'vietnamese', 'label': 'Vietnamese & Pho', 'icon': '🥢', 'query': 'vietnamese', 'regex': r'\b(vietnamese|vietnamees|pho|phở|b[aá]nh m[iì])\b'},
{'id': 'korean', 'label': 'Korean & Fried Chicken', 'icon': '🍗', 'query': 'korean', 'regex': r'\b(korean|koreaans|kimchi|bibimbap|chimaek)\b'},
{'id': 'indian', 'label': 'Indian & Curry Houses', 'icon': '🥘', 'query': 'indian', 'regex': r'\b(indian|indiase|curry|tandoori|masala|biryani|dosa|thali)\b'},
# The Americas & Bistro
{'id': 'burger', 'label': 'Craft & Smash Burgers', 'icon': '🍔', 'query': 'burger', 'regex': r'\b(burger|burgers|smash)\b'},
{'id': 'steak', 'label': 'Steak & Prime Grill', 'icon': '🥩', 'query': 'steak', 'regex': r'\b(steak|steakhouse|grill|ribeye|churrasco|asado)\b'},
{'id': 'bbq', 'label': 'Smokehouse BBQ & Ribs', 'icon': '🍖', 'query': 'bbq', 'regex': r'\b(barbecue|bbq|smokehouse|brisket|pulled pork|spareribs)\b'},
{'id': 'mexican', 'label': 'Mexican & Taquerías', 'icon': '🌮', 'query': 'mexican', 'regex': r'\b(mexican|mexicaans|taco|taqueria|birria|burrito)\b'},
{'id': 'french', 'label': 'French Bistro & Brasserie', 'icon': '🍷', 'query': 'french', 'regex': r'\b(french|frans|bistro|brasserie|charcuterie)\b'},
# Roast & Sips
{'id': 'coffee', 'label': 'Specialty Coffee Micro-Roasters', 'icon': '☕', 'query': 'coffee', 'regex': r'\b(coffee|koffie|espresso|roastery|filter coffee|pourover)\b'},
{'id': 'bubble_tea', 'label': 'Taiwanese Bubble Tea & Boba', 'icon': '🧋', 'query': 'bubble tea', 'regex': r'\b(bubble tea|boba|tapioca)\b'},
# Broad Regional, Heritage & Evening Dining (Collectively Exhaustive MECE Buckets)
{'id': 'dutch', 'label': 'Dutch Heritage & Eetcafés', 'icon': '🇳🇱', 'query': 'dutch', 'regex': r'\b(dutch|nederlands|stamppot|snert|bitterballen|poffertjes|hollands|eetcaf[eé])\b'},
{'id': 'bistro', 'label': 'Modern European & Chef Bistros', 'icon': '🍽️', 'query': 'bistro', 'regex': r'\b(bistro|european|contemporary|gastronomy|fine dining|seasonal|farm[\s-]to[\s-]table|international)\b'},
{'id': 'gastropub', 'label': 'British & Irish Gastropubs', 'icon': '🍺', 'query': 'pub', 'regex': r'\b(british|irish|cask_ale|sunday_roast|gastropub|pub)\b'},
{'id': 'wine_bar', 'label': 'Natural Wine Bars & Cellars', 'icon': '🍷', 'query': 'wine bar', 'regex': r'\b(wine_bar|wine bar|vinotheque|natural wine|wijnbar|cocktails|drinks)\b'},
{'id': 'latin', 'label': 'Latin American & Caribbean', 'icon': '🌮', 'query': 'latin', 'regex': r'\b(argentinian|caribbean|peruvian|colombian|empanada|arepa|ceviche|latin)\b'}
]
ARCHETYPE_BY_ID = {a['id']: a for a in MASTER_CRAFT_ARCHETYPES}
for _a in MASTER_CRAFT_ARCHETYPES:
_a['_re'] = re.compile(_a['regex'], re.IGNORECASE)
# MECE Mutually Exclusive Priority Order (Specific Craft Specialist > Broad Regional Dining)
SPECIFICITY_PRIORITY = [
# Level 1: Dedicated sweet & beverage specialists
'gelato', 'pancakes', 'patisserie', 'bubble_tea', 'coffee',
# Level 2: Dedicated craft mono-specialists
'pizza', 'ramen', 'sushi', 'doner', 'ocakbasi', 'pide', 'kofte', 'baklava',
'shawarma', 'falafel', 'mandi', 'persian', 'esnaf', 'frites', 'bbq', 'burger', 'steak',
# Level 3: Distinct national heritage kitchens
'surinamese', 'indonesian', 'ethiopian', 'moroccan', 'thai', 'vietnamese',
'korean', 'indian', 'chinese', 'mexican', 'tapas', 'greek',
# Level 4: Core traditional culinary categories
'seafood', 'deli', 'bakery', 'italian', 'french', 'craft_beer', 'tavern',
# Level 5: Broad European & Regional heritage
'latin', 'gastropub', 'wine_bar', 'bistro', 'dutch'
]
class PalateEngine:
def __init__(self, city_id="amsterdam", data_path=None, accreditations_path=None, vectors_path=None, model_path=None):
self.city_id = (city_id or "amsterdam").lower().strip()
self.city_config = self.load_city_config()
city_dir = os.path.join(DATA_DIR, self.city_id)
self.data_path = data_path or (os.path.join(city_dir, "places.json") if os.path.exists(os.path.join(city_dir, "places.json")) else DATA_FILE)
self.accreditations_path = accreditations_path or (os.path.join(city_dir, "accreditations.json") if os.path.exists(os.path.join(city_dir, "accreditations.json")) else ACCREDITATIONS_FILE)
self.vectors_path = vectors_path or (os.path.join(city_dir, "vectors.npy") if os.path.exists(os.path.join(city_dir, "vectors.npy")) else VECTORS_FILE)
self.model_path = model_path or (os.path.join(city_dir, "semantic_model.pkl") if os.path.exists(os.path.join(city_dir, "semantic_model.pkl")) else MODEL_FILE)
self.epicenters = self.city_config.get("epicenters", TOURIST_EPICENTERS)
self.places = []
self.accreditations = []
self.vectors = None
self.vectorizer = None
self.svd = None
self.load_accreditations()
self.load_data()
self.load_semantic_model()
self.neighborhood_map = NEIGHBORHOOD_ALIASES_BY_CITY.get(self.city_id, {})
def load_city_config(self):
cities_file = os.path.join(DATA_DIR, "cities.json")
if os.path.exists(cities_file):
try:
with open(cities_file, "r", encoding="utf-8") as f:
cities = json.load(f)
for c in cities:
if c.get("id") == self.city_id:
return c
except Exception as e:
print(f"Error loading cities.json: {e}")
return {
"id": self.city_id,
"name": self.city_id.capitalize(),
"epicenters": TOURIST_EPICENTERS,
"waterfront_label": "⛵ Canal Terrace",
"heritage_badge_label": "🏛️ Historic Monuments"
}
def load_semantic_model(self):
"""Load the precomputed 128-dim dense semantic vector matrix and SVD pipeline."""
if os.path.exists(self.vectors_path) and os.path.exists(self.model_path):
try:
self.vectors = np.load(self.vectors_path)
with open(self.model_path, "rb") as f:
model = pickle.load(f)
self.vectorizer = model.get("vectorizer")
self.svd = model.get("svd")
if self.vectorizer is not None and self.svd is not None and len(self.places) > len(self.vectors):
extra_places = self.places[len(self.vectors):]
extra_texts = [f"{p.get('name', '')} {p.get('primary_category', '')} {p.get('cuisine', '')} {' '.join(p.get('craft_badges', []))}" for p in extra_places]
extra_tfidf = self.vectorizer.transform(extra_texts)
extra_dense = self.svd.transform(extra_tfidf)
norms = np.linalg.norm(extra_dense, axis=1, keepdims=True)
norms[norms == 0] = 1.0
extra_dense = extra_dense / norms
self.vectors = np.vstack([self.vectors, extra_dense])
print(f"Loaded {self.vectors.shape[0]} dense semantic vectors (128-dim) for TasteOS semantic search.")
except Exception as e:
print(f"Warning: Failed to load semantic model: {e}")
self.vectors = None
self.vectorizer = None
self.svd = None
def load_accreditations(self):
"""Load the structured Guide of Guides awards table (place_accreditations)."""
if os.path.exists(self.accreditations_path):
with open(self.accreditations_path, "r", encoding="utf-8") as f:
raw_accs = json.load(f)
self.accreditations = []
for acc in raw_accs:
item = dict(acc)
item["_m_name"] = acc.get("match_name", "").lower().strip()
item["_m_norm"] = self._normalize_name(item["_m_name"])
m_core = GENERIC_WRAPPER_RE.sub(' ', item["_m_norm"])
item["_m_core"] = re.sub(r'\s+', ' ', m_core).strip()
item["_re_norm"] = re.compile(r'\b' + re.escape(item["_m_norm"]) + r'\b') if len(item["_m_norm"]) >= 6 else None
item["_re_core"] = re.compile(r'\b' + re.escape(item["_m_core"]) + r'\b') if len(item["_m_core"]) >= 6 else None
item["_ex_patterns"] = []
if acc.get("exclude_match"):
raw_ex = acc["exclude_match"]
patterns = raw_ex if isinstance(raw_ex, list) else [p.strip() for p in str(raw_ex).split("|") if p.strip()]
for p in patterns:
item["_ex_patterns"].append((p.lower(), self._normalize_name(p)))
self.accreditations.append(item)
print(f"Loaded {len(self.accreditations)} official third-party accreditations.")
def _normalize_name(self, text):
s = text.lower().strip()
replacements = {
'ö': 'o', 'ç': 'c', 'ş': 's', 'ı': 'i', 'ğ': 'g', 'ü': 'u',
'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e',
'à': 'a', 'á': 'a', 'â': 'a', 'ä': 'a',
'î': 'i', 'ï': 'i', 'ô': 'o', 'ó': 'o',
'û': 'u', 'ú': 'u', 'ñ': 'n',
'ō': 'o', 'ū': 'u', 'ī': 'i', 'ā': 'a', 'ē': 'e'
}
for k, v in replacements.items():
s = s.replace(k, v)
s = s.replace('\ufffd', 'e')
# Strip apostrophes and punctuation for fuzzy token joining
s = re.sub(r"['’`\.\-]", "", s)
s = re.sub(r"\s+", " ", s)
return s.strip()
def match_all_accreditations(self, place_name):
"""Find ALL matching third-party accreditations for a place (e.g. Michelin + Lekker 500)."""
p_name = place_name.lower().strip()
p_norm = self._normalize_name(place_name)
matches = []
source_priority = {
"michelin": 10,
"michelin_bib": 9,
"worlds_50_best": 10,
"50_top_pizza": 9,
"avpn_napoli": 9,
"gault_millau": 9,
"vedat_milor": 9,
"incili_gastronomi": 8,
"oad": 8,
"la_liste": 8,
"grandes_tables": 8,
"relais_chateaux": 8,
"50_best_discovery": 9,
"gouden_pollepel": 8,
"entree_awards": 7,
"lekker_500": 8,
"european_coffee_trip": 8,
"world_barista_championship": 8,
"specialty_coffee_association": 8,
"fact_awards": 7,
"timeout_awards": 7,
"whats_on": 7,
"eater_38": 7,
"worlds_101_best_steak": 9,
"burgerdudes_worlds_best_burgers": 8,
"gelato_festival_world_masters": 8,
"tokyo_ramen_of_the_year": 9,
"coupe_du_monde_boulangerie": 9,
"texas_monthly_bbq": 9,
"concurso_paella_valenciana": 8,
"tabelog": 9,
"gambero_rosso": 9,
"slow_food": 8,
"guia_repsol": 8,
"le_fooding": 8,
"good_food_guide": 8,
"james_beard": 9,
"het_parool": 6,
"amsterdam_heritage": 5
}
p_core = GENERIC_WRAPPER_RE.sub(' ', p_norm)
p_core = re.sub(r'\s+', ' ', p_core).strip()
for acc in self.accreditations:
if acc.get("_ex_patterns"):
if any(ex_norm in p_norm or ex_raw in p_name for ex_raw, ex_norm in acc["_ex_patterns"]):
continue
m_raw = acc["_m_name"]
m_norm = acc["_m_norm"]
# 1. Exact match on raw name or normalized form
if m_raw == p_name or m_norm == p_norm:
matches.append(acc)
continue
# 2. Distinctive core equality (e.g. "bocca" == "bocca", "massimo" == "massimo")
m_core = acc["_m_core"]
if len(m_core) >= 4 and len(p_core) >= 4 and m_core == p_core:
matches.append(acc)
# 3. Whole-phrase word-bounded match (e.g. "\bfort negen\b" in "fort negen bakery")
elif acc["_re_norm"] and acc["_re_norm"].search(p_norm):
matches.append(acc)
# 4. Multi-word or long distinctive core inclusion (>= 6 chars)
elif acc["_re_core"] and len(p_core) >= 6 and acc["_re_core"].search(p_core):
matches.append(acc)
# Sort so highest prestige comes first
matches.sort(key=lambda x: source_priority.get(x.get("source", ""), 1), reverse=True)
return [{k: v for k, v in x.items() if not k.startswith("_")} for x in matches]
def match_accreditation(self, place_name):
"""Simulate SQL JOIN place_accreditations ON place_id / name."""
matches = self.match_all_accreditations(place_name)
return matches[0] if matches else None
def get_place_archetypes(self, p):
badges = " ".join(p.get("craft_badges") or []).lower()
name = (p.get("name") or "").lower()
cat = (p.get("primary_category") or "").lower()
cuisine = (p.get("cuisine") or "").lower()
must_try = (p.get("must_try") or "").lower()
# Generic OSM tag sanitize: cafes, bars, and bistros frequently have "coffee;tea;pastry;espresso"
sanitized_cuisine = cuisine
if cat in ("cafe", "bar", "pub", "restaurant") or "coffee" in cuisine:
sanitized_cuisine = re.sub(r'\bpastry\b', '', sanitized_cuisine)
text = f"{name} {cat} {sanitized_cuisine} {badges} {must_try}".lower()
matched_candidates = set()
for a in MASTER_CRAFT_ARCHETYPES:
aid = a["id"]
if aid == "patisserie":
if re.search(r'\b(p[aâ]tisserie|chocolatier|chocolade|chocolate|bonbon|stroopwafel|banketbakkerij|confectionery|macaron|viennoiserie|eclair|éclair|croissant)\b', f"{name} {badges} {must_try}"):
matched_candidates.add("patisserie")
elif cat in ["bakery", "pastry", "chocolatier", "confectionery"]:
if re.search(r'\b(pastry|cake|taart|baking|sweet|viennoiserie|patisserie|chocolate|bonbon|stroopwafel|croissant)\b', f"{name} {cuisine} {must_try} {badges}") or cat in ["pastry", "chocolatier", "confectionery"]:
matched_candidates.add("patisserie")
elif re.search(r'\b(p[aâ]tisserie|chocolatier|bonbon|stroopwafel)\b', sanitized_cuisine):
matched_candidates.add("patisserie")
elif aid == "pizza":
if re.search(r'\b(pizza|pizzeria|napoletana)\b', f"{name} {cat} {cuisine}"):
matched_candidates.add("pizza")
elif aid == "dutch":
if self.city_id == "amsterdam" and a["_re"].search(text):
matched_candidates.add("dutch")
else:
if a["_re"].search(text):
matched_candidates.add(aid)
# MECE Selection: Mutually exclusive priority assignment (most specific craft wins)
for prio_id in SPECIFICITY_PRIORITY:
if prio_id in matched_candidates:
return [prio_id]
# MECE Fallback: 100% Collective Exhaustiveness (every authentic venue has a home)
if cat in ("cafe", "coffee_shop"):
return ["coffee"]
if cat in ("bakery", "pastry"):
return ["bakery"]
if cat == "pub" or "pub" in cuisine or "cask_ale" in cuisine:
return ["gastropub"]
if cat == "bar" or "wine" in cuisine or "cocktails" in cuisine:
return ["wine_bar"]
if cat == "pizza":
return ["pizza"]
if "argentinian" in cuisine or "latin" in cuisine:
return ["latin"]
if self.city_id == "amsterdam":
return ["dutch"]
return ["bistro"]
def load_data(self):
if not os.path.exists(self.data_path):
print(f"Data file not found: {self.data_path}")
return
with open(self.data_path, "r", encoding="utf-8") as f:
raw_places = json.load(f)
# Deduplication safeguard: eliminate duplicate venue records with same normalized (name, address)
seen_keys = set()
deduped = []
for p in raw_places:
addr = (p.get("address") or "").lower().strip()
name = (p.get("name") or "").lower().strip()
key = (name, addr)
if addr and addr not in ["amsterdam, netherlands", "dubai, united arab emirates", "istanbul, turkey"] and key in seen_keys:
continue
if addr and addr not in ["amsterdam, netherlands", "dubai, united arab emirates", "istanbul, turkey"]:
seen_keys.add(key)
deduped.append(p)
self.places = [self.evaluate_place(p) for p in deduped]
for p in self.places:
mece_clusters = self.get_place_archetypes(p)
p["_cluster_ids"] = mece_clusters
primary_id = mece_clusters[0] if mece_clusters else "bistro"
p["primary_cluster_id"] = primary_id
arch_meta = ARCHETYPE_BY_ID.get(primary_id, {})
p["primary_cluster_label"] = arch_meta.get("label", primary_id.capitalize())
p["primary_cluster_icon"] = arch_meta.get("icon", "✨")
print(f"PalateEngine [{self.city_id}] initialized with {len(self.places)} analyzed venues (100% MECE architecture).")
def evaluate_place(self, p):
name = p.get("name", "")
cats = p.get("alternate_categories", [])
combined_text = f"{name} {p.get('primary_category', '')} {' '.join(cats)} {p.get('cuisine', '')} {p.get('must_try', '')}".lower()
# 1. Smarter Category Purity
# Filters out generic administrative tags that do NOT dilute culinary craft
specialized_alts = [
c for c in cats
if c.lower() not in GENERIC_WRAPPER_TAGS
and c.lower() != p.get("primary_category", "").lower()
]
total_relevant_categories = 1 + len(specialized_alts)
category_purity = 1.0 / total_relevant_categories
purity_score = round(category_purity * 25.0, 1)
# 2. Universal Craft Lexicon Matching
craft_score = 0
craft_badges = []
craft_details = []
for rule in CRAFT_PATTERNS:
if re.search(rule["regex"], combined_text, re.IGNORECASE):
craft_badges.append(rule["badge"])
craft_details.append(rule["detail"])
craft_score += rule["points"]
craft_score = min(craft_score, 35)
# 3. Relational Critic Accreditations (The 'Guide of Guides' Join)
accreditations = self.match_all_accreditations(name)
primary_accreditation = accreditations[0] if accreditations else p.get("accreditation")
accreditation = primary_accreditation
critic_score = 0
if accreditations:
# 35 base for primary guide + 5 consensus bonus for each additional authority (max 45)
critic_score = min(45, 35 + (len(accreditations) - 1) * 5)
for acc in reversed(accreditations):
tier_name = acc.get("award_tier") or acc.get("award") or "Culinary Distinction"
award_badge = tier_name if any(tier_name.startswith(sym) for sym in ["🏅", "⭐", "🍴", "🏆", "🟢", "🍕", "☕", "🇳🇱", "🎖️", "💎"]) else f"🏅 {tier_name}"
if award_badge not in craft_badges:
craft_badges.insert(0, award_badge)
if len(accreditations) >= 2:
consensus_badge = f"🏆 Critical Consensus ({len(accreditations)} Independent Guides)"
if consensus_badge not in craft_badges:
craft_badges.insert(0, consensus_badge)
elif p.get("accreditation"):
critic_score = 35
tier_name = p["accreditation"].get("award_tier") or p["accreditation"].get("award") or "Culinary Distinction"
award_badge = tier_name if any(tier_name.startswith(sym) for sym in ["🏅", "⭐", "🍴", "🏆", "🟢", "🍕", "☕", "🇳🇱", "🎖️", "💎"]) else f"🏅 {tier_name}"
if award_badge not in craft_badges:
craft_badges.insert(0, award_badge)
# 4. Tourist Trap Negative Patterns
trap_flags = []
trap_penalty = 0
for pat in TOURIST_TRAP_PATTERNS:
if re.search(pat["regex"], combined_text, re.IGNORECASE):
trap_flags.append(pat["flag"])
trap_penalty += pat["penalty"]
# 5. Tourist Zone Spatial Penalty
lat = p.get("latitude")
lon = p.get("longitude")
min_epicenter_dist = 999999.0
nearest_epicenter = ""
if lat and lon:
for epic in self.epicenters:
d = haversine_distance_m(lat, lon, epic["lat"], epic["lon"])
if d < min_epicenter_dist:
min_epicenter_dist = d
nearest_epicenter = epic["name"]
spatial_score = 0
is_tourist_trap = False
if min_epicenter_dist < 85:
if craft_score < 20 and not accreditation:
spatial_score = -30
trap_flags.append(f"⚠️ Epicenter Alert: Located within {int(min_epicenter_dist)}m of {nearest_epicenter}")
is_tourist_trap = True
elif min_epicenter_dist < 180:
if craft_score < 15 and not accreditation:
spatial_score = -15
elif min_epicenter_dist > 350:
spatial_score = 10
# 6. Independence vs. Global Industrial Chains
ownership = p.get("ownership", {})
brand = p.get("brand")
brand_clean = (brand or "").strip().lower()
is_industrial_chain = brand_clean in GLOBAL_INDUSTRIAL_CHAINS or (not ownership.get("is_independent", True) if ownership else False)
if ownership.get("type") == "independent":
independence_score = 25
elif ownership.get("type") == "mini_group":
independence_score = 15
elif is_industrial_chain:
independence_score = 0
chain_label = ownership.get("badge") or f"Owned by '{brand}'"
trap_flags.append(f"🏢 Multi-Unit Chain: {chain_label}")
else:
independence_score = 15
# 7. Lindy Longevity Score
start_year = p.get("start_year")
longevity_score = 0
longevity_badge = None
if start_year:
age = 2026 - start_year
if age >= 100:
longevity_score = 30
longevity_badge = f"🏛️ Century-Old Institution ({age} yrs, est. {start_year})"
elif age >= 25:
longevity_score = 20
longevity_badge = f"⏳ Lindy Survivor ({age} yrs, est. {start_year})"
elif age >= 10:
longevity_score = 10
longevity_badge = f"Established Local Favorite (est. {start_year})"
# 8. Operational Rhythm & In-House Production Signal
rhythm_score = 0
schedule_rhythm = p.get("schedule_rhythm", "standard")
if schedule_rhythm == "artisan_rhythm":
rhythm_score = 10
elif schedule_rhythm == "tourist_continuous":
rhythm_score = -15
trap_flags.append("⚠️ Factory Schedule: Continuous 7-day all-hours commercial mass production")
amenities = p.get("amenities", {})
in_house_score = 0
if amenities.get("microbrewery"):
in_house_score += 15
craft_badges.append("🍺 In-House Craft Microbrewery")
if amenities.get("in_house_roastery"):
in_house_score += 15
craft_badges.append("☕ In-House Specialty Coffee Roastery")
# 10. Classic Cash-Only Lindy Marker
cash_score = 5 if p.get("cash_only") else 0
if p.get("cash_only") and "💶 Old-School Cash Only" not in craft_badges:
craft_badges.append("💶 Old-School Cash Only")
# 11. Architectural Monument & Heritage Signal (Gemeente Amsterdam / BAG)
heritage = p.get("heritage", {})
heritage_score = 0
if heritage.get("is_monument"):
if heritage.get("badge") and heritage["badge"] not in craft_badges:
craft_badges.append(heritage["badge"])
if heritage.get("authenticity_boost"):
# Meaningful quality boost for authentic operators in protected monuments
heritage_score = 15 if heritage.get("status") == "Rijksmonument" else 10
# Dietary Badges
dietary = p.get("dietary", {})
dietary_badges = []
if dietary.get("halal"):
dietary_badges.append("🥩 Halal Friendly")
if dietary.get("vegan"):
dietary_badges.append("🌿 100% Vegan Friendly")
elif dietary.get("vegetarian"):
dietary_badges.append("🌱 Vegetarian Friendly")
if dietary.get("gluten_free"):
dietary_badges.append("🌾 Gluten-Free Options")
if dietary.get("kosher"):
dietary_badges.append("✡️ Kosher")
# Amenity Badges
amenity_badges = []
if amenities.get("canal_terrace"):
waterfront_badge = "⛵ Canal Terrace" if self.city_id == "amsterdam" else ("🌊 Bosphorus Terrace" if self.city_id == "istanbul" else "🌊 Waterfront Terrace")
amenity_badges.append(waterfront_badge)
elif amenities.get("outdoor_seating"):
amenity_badges.append("☀️ Outdoor Seating")
if amenities.get("wheelchair_accessible"):
amenity_badges.append("♿ Wheelchair Accessible")
# Celiac & Medical Kitchen Safety
celiac_safety = p.get("celiac_safety", {})
celiac_score = 0
if celiac_safety.get("is_safe"):
celiac_score = 15 if celiac_safety.get("certified_by") == "NCV Horeca Alliantie" else 10
if celiac_safety.get("badge") and celiac_safety["badge"] not in dietary_badges:
dietary_badges.insert(0, celiac_safety["badge"])
# Scratch Kitchen Craft
scratch = p.get("scratch_kitchen", {})
scratch_score = 0
if scratch.get("is_scratch"):
scratch_score = 10
if scratch.get("badge") and scratch["badge"] not in craft_badges:
craft_badges.append(scratch["badge"])
# Honest Hospitality / Tap Water
hospitality = p.get("hospitality", {})
hospitality_score = 0
if hospitality.get("free_tap_water"):
hospitality_score = 5
if hospitality.get("badge") and hospitality["badge"] not in amenity_badges:
amenity_badges.append(hospitality["badge"])
# 12. Urban Food Habitat Matrix (Local Resident Enclave vs Tourist Corridor)
habitat = p.get("habitat", {})
habitat_score = 0
resident_density = habitat.get("resident_food_density", 0)
tourist_kiosks = habitat.get("tourist_kiosk_density", 0)
if resident_density >= 2:
habitat_score = 10
if "🏘️ Resident Food Habitat" not in craft_badges:
craft_badges.append("🏘️ Resident Food Habitat")
elif tourist_kiosks >= 3 and not accreditation:
spatial_score -= 15
trap_flags.append("⚠️ Tourist Funnel Corridor: Surrounded by souvenir & exchange kiosks")
# 13. Multilingual Over-Translation Distortion Index
translations_count = p.get("translations_count", len(p.get("name_translations", [])))
if translations_count >= 5 and not accreditation and not p.get("is_luxury_hotel"):
trap_penalty -= 15
trap_flags.append("🚩 Multilingual Tourist Lure: 5+ languages printed to capture tourist foot-traffic")
# 14. Dedicated Experiential Dining vs Ghost / Virtual Dark Kitchen
is_ghost = p.get("is_ghost_kitchen") or (
amenities.get("indoor_seating") is False and (amenities.get("takeaway_only") or amenities.get("delivery_only"))
)
if is_ghost:
p["is_ghost_kitchen"] = True
trap_flags.append("📦 Dark / Ghost Kitchen: Virtual delivery brand without experiential dining room")
# 15. Direct Digital Independence (Custom Domain vs Aggregator)
website = p.get("website", "")
digital_score = 0
if website:
domain = website.lower()
if not any(agg in domain for agg in ["deliveroo", "talabat", "ubereats", "yemeksepeti", "thuisbezorgd", "linktr.ee", "facebook.com", "instagram.com"]):
digital_score = 5
# 16. Government Food Safety & Clean Kitchen Inspection
food_safety = p.get("food_safety") or {}
fs_grade = food_safety.get("inspection_grade", "")
food_safety_score = 0
if fs_grade in ["Grade A", "5/5", "Très satisfaisant", "Elite Smiley"]:
food_safety_score = 15
fs_badge = food_safety.get("badge") or "🏆 Grade A Clean Kitchen"
if fs_badge not in amenity_badges:
amenity_badges.append(fs_badge)
elif fs_grade in ["Grade B", "4/5", "Satisfaisant"]:
food_safety_score = 5
fs_badge = food_safety.get("badge") or "🥈 Grade B Clean Kitchen"
if fs_badge not in amenity_badges:
amenity_badges.append(fs_badge)
elif fs_grade in ["Grade D", "Grade E", "0/5", "1/5", "Violation"]:
trap_penalty -= 50
trap_flags.append(f"⚠️ Municipal Hygiene Warning: Critical sanitation failure ({fs_grade})")
# Confidence Factor
conf = p.get("confidence", 0.85)
conf_multiplier = min(1.0, 0.85 + (conf * 0.15))
# Final Composite Quality Score
positive_components = (
purity_score +
craft_score +
independence_score +
longevity_score +
critic_score +
rhythm_score +
in_house_score +
cash_score +
heritage_score +
celiac_score +
scratch_score +
hospitality_score +
habitat_score +
digital_score +
food_safety_score
)
adjusted_positive = positive_components * conf_multiplier
raw_score = adjusted_positive + spatial_score + trap_penalty
final_score = max(5, min(99, int(raw_score)))
if trap_penalty <= -30 or (min_epicenter_dist < 85 and craft_score < 15 and not accreditation) or is_industrial_chain:
is_tourist_trap = True
if final_score >= 80:
tier_label = "🏆 Artisanal Culinary Master"
elif final_score >= 60:
tier_label = "⭐ Authentic Local Favorite"
elif final_score >= 40:
tier_label = "☕ Decent Neighborhood Spot"
else:
tier_label = "☕ Neighborhood Spot"
# 17. Rule-Based Culinary Price Tiering (Option B)
# Tier 4: Fine Dining / Michelin Stars / World's 50 Best / Luxury Omakase
# Tier 3: Upscale Dining / Bib Gourmand / Premium Steakhouses & Bistros
# Tier 2: Casual Sit-down / Neighborhood Favorites / Ramen / Trattorias / Burgers
# Tier 1: Street Food / Budget / Döner / Bakeries / Falafel / Fast Casual
award_sources = [a.get("source", "").lower() for a in accreditations]
award_tiers = [a.get("award_tier", "").lower() for a in accreditations]
primary_cat = p.get("primary_category", "").lower()
amenity_type = p.get("amenity", "").lower()
# Explicit budget / street food signals
has_budget_or_street_signal = (
any(k in t for t in award_tiers for k in ["budget", "street food", "fish fry", "cheap", "shack", "karahi", "kebab master"]) or
primary_cat in ["haringhandel", "doner", "shawarma", "falafel", "bakery", "tea", "ice_cream", "gelato", "fast_food", "soup", "deli", "sandwich", "mandi"] or
amenity_type in ["fast_food"] or
p.get("is_ghost_kitchen")
)
is_tier_4 = (
not has_budget_or_street_signal and (
any("star" in t and "green star" not in t.lower() for t in award_tiers) or
any(s in ["worlds_50_best", "oad", "grandes_tables"] for s in award_sources if not any("discovery" in t for t in award_tiers)) or
primary_cat in ["omakase", "fine_dining"] or
any("michelin 3 star" in b.lower() or "michelin 2 star" in b.lower() or "michelin 1 star" in b.lower() for b in craft_badges)
)
)
is_tier_3 = (
not has_budget_or_street_signal and not is_tier_4 and (
any("bib" in t for t in award_tiers) or
any(s in ["gault_millau", "50_top_pizza", "lekker_500", "vedat_milor"] for s in award_sources) or
primary_cat in ["steak", "french", "contemporary", "bistro", "seafood", "bosphorus_seafood", "spanish", "tapas"] or
(primary_cat == "italian" and "pizza" not in p.get("cuisine", ""))
)
)
is_tier_1 = (
has_budget_or_street_signal or
any("budget" in t or "street food" in t for t in award_tiers)
)
if is_tier_4:
price_tier = 4
elif is_tier_3:
price_tier = 3
elif is_tier_1:
price_tier = 1
else:
price_tier = 2
currency_sym = self.city_config.get("currency_symbol", "€")
tier_names = {
1: "Budget / Street Food",
2: "Casual Dining",
3: "Upscale Dining",
4: "Fine Dining / Tasting"
}
price_label = currency_sym * price_tier if currency_sym != "AED" else ("AED" if price_tier == 1 else f"AED {price_tier}")
return {
**p,
"quality_score": final_score,
"category_purity": round(category_purity, 2),
"purity_score": purity_score,
"craft_badges": craft_badges,
"craft_details": craft_details,
"dietary_badges": dietary_badges,
"amenity_badges": amenity_badges,
"trap_flags": trap_flags,
"is_tourist_trap": is_tourist_trap,
"longevity_badge": longevity_badge,
"is_chain": is_industrial_chain,
"tier_label": tier_label,
"accreditation": accreditation,
"all_accreditations": accreditations,
"confidence": conf,
"dist_to_tourist_hub_m": int(min_epicenter_dist) if min_epicenter_dist < 999999 else None,
"nearest_tourist_hub": nearest_epicenter,
"food_safety": food_safety,
"price_tier": price_tier,
"price_label": price_label,
"price_tier_name": tier_names[price_tier]
}
def parse_geo_intent(self, query):
"""Extract neighborhood entity and decouple location terms from food query."""
q_clean = (query or "").strip()
q_lower = q_clean.lower()
if not q_lower:
return query, None, []
sorted_keys = sorted(self.neighborhood_map.keys(), key=lambda k: len(k), reverse=True)
for key in sorted_keys:
aliases = self.neighborhood_map[key]
for alias in aliases:
# 1. Prepositional phrases: e.g. "in marina", "near jlt", "around satwa", "bij de pijp"
prep_pattern = rf'\b(in|at|near|around|by|bij)\s+{re.escape(alias)}\b'
prep_match = re.search(prep_pattern, q_lower)
if prep_match:
food_q = re.sub(prep_pattern, '', q_clean, flags=re.IGNORECASE).strip()
food_q = re.sub(r'\s+', ' ', food_q)
return food_q, key, aliases
# 2. Standalone neighborhood token: e.g. "shawarma satwa" or "satwa"
token_pattern = rf'\b{re.escape(alias)}\b'
if re.search(token_pattern, q_lower):
food_q = re.sub(token_pattern, '', q_clean, flags=re.IGNORECASE).strip()
food_q = re.sub(r'\b(in|at|near|around|by|bij)\b', '', food_q, flags=re.IGNORECASE).strip()
food_q = re.sub(r'\s+', ' ', food_q)
return food_q, key, aliases
return query, None, []
def search(
self,
query="",
user_lat=52.3680,
user_lon=4.8900,
offset=0,
max_results=40,
cuisine=None,
diet=None,
outdoor=False,
canal=False,
wheelchair=False,
accredited_only=False,
monuments_only=False,
independent_only=False,
celiac_safe_only=False,
scratch_only=False,
tap_water_only=False,
clean_only=False,
late_night_only=False,
roastery_only=False,
historic_only=False,
price_tiers=None,
min_score=0,
sort_by="quality",
has_user_coords=False,
return_total=False,
bbox=None,
max_dist_m=None
):
# Geographic Entity Decoupling
food_query, geo_target, geo_aliases = self.parse_geo_intent(query)
effective_query = food_query if geo_target else query
q_clean = effective_query.strip()
q_lower = q_clean.lower()
results = []
arch = ARCHETYPE_BY_ID.get(cuisine) if cuisine else None
arch_regex = re.compile(arch["regex"], re.IGNORECASE) if arch else None
# Vector semantic inference
sims = None
if q_lower and self.vectorizer is not None and self.svd is not None and self.vectors is not None:
try:
q_tfidf = self.vectorizer.transform([q_clean])
q_dense = self.svd.transform(q_tfidf)
q_norm = np.linalg.norm(q_dense)
if q_norm > 0:
q_dense = q_dense / q_norm
sims = np.dot(self.vectors, q_dense.T).flatten()
except Exception as e:
print(f"Semantic inference error: {e}")
sims = None
tokens = q_lower.split() if q_lower else []
intent_tokens = [t for t in tokens if t not in QUERY_INTENT_STOPWORDS]
if not intent_tokens:
intent_tokens = tokens # fallback if query was purely generic (e.g., "restaurants")
# Expand nationality / cuisine stems (e.g., syria <-> syrian, italy <-> italian)
expanded_intent = set(intent_tokens)
for t in intent_tokens:
if t in CUISINE_NATIONALITY_EXPANSIONS:
for exp in CUISINE_NATIONALITY_EXPANSIONS[t]:
expanded_intent.add(exp)
for i, p in enumerate(self.places):
# Exclude low-quality tourist traps completely
if p.get("is_tourist_trap"):
continue
# Exclude delivery-only dark / ghost kitchens from experiential dining discovery
if p.get("is_ghost_kitchen"):
continue
# Viewport Bounding Box Filter (min_lat, min_lon, max_lat, max_lon)
if bbox and len(bbox) == 4:
min_lat, min_lon, max_lat, max_lon = bbox
plat = p.get("latitude")
plon = p.get("longitude")
if plat is None or plon is None or not (min_lat <= plat <= max_lat and min_lon <= plon <= max_lon):
continue
# Calculate Distance
dist_m = 0
if p.get("latitude") and p.get("longitude"):
dist_m = int(haversine_distance_m(user_lat, user_lon, p["latitude"], p["longitude"]))
# Walking / Transit Radius Filter
if max_dist_m is not None and dist_m > max_dist_m:
continue
# Geographic Neighborhood Intent Filtering
venue_in_geo = False
if geo_target and geo_aliases:
venue_geo_text = f"{p.get('neighborhood') or ''} {p.get('address') or ''} {p.get('name') or ''}".lower()
venue_in_geo = any(a in venue_geo_text for a in geo_aliases)
# If query was solely a neighborhood (e.g. 'satwa', 'jordaan') without a food term, exclude other districts
if not q_lower and not venue_in_geo:
continue
# Dedicated craft archetype / cuisine cluster filter
if arch:
if arch["id"] not in p.get("_cluster_ids", []):
continue
elif arch_regex:
badges = " ".join(p.get("craft_badges") or [])
text = f"{p['name']} {p.get('primary_category', '')} {p.get('cuisine', '')} {badges}".lower()
if not arch_regex.search(text):
continue
# 1. Dietary filter
if diet:
diet_key = diet.lower().strip()
if diet_key in ("celiac", "celiac_safe"):
if not p.get("celiac_safety", {}).get("is_safe"):
continue
elif diet_key in ("gluten_free", "glutenfree", "gf"):
if not (p.get("dietary", {}).get("gluten_free") or p.get("celiac_safety", {}).get("is_safe")):
continue
elif not p.get("dietary", {}).get(diet_key):
continue
# 2. Amenity, Heritage & Transparency filters
if outdoor and not p.get("amenities", {}).get("outdoor_seating"):
continue
if canal and not p.get("amenities", {}).get("canal_terrace"):
continue
if wheelchair and not p.get("amenities", {}).get("wheelchair_accessible"):
continue
if accredited_only and not p.get("accreditation"):
continue
if monuments_only and not p.get("heritage", {}).get("is_monument"):
continue
if independent_only and not p.get("ownership", {}).get("is_independent"):
continue
if celiac_safe_only and not p.get("celiac_safety", {}).get("is_safe"):
continue
if scratch_only and not p.get("scratch_kitchen", {}).get("is_scratch"):
continue
if tap_water_only and not p.get("hospitality", {}).get("free_tap_water"):
continue
if clean_only:
fs = p.get("food_safety") or {}
if fs.get("inspection_grade") not in ["Grade A", "5/5", "Très satisfaisant", "Elite Smiley"]:
continue
if late_night_only and not p.get("amenities", {}).get("late_night"):
continue
if roastery_only and not p.get("amenities", {}).get("in_house_roastery"):
continue
if historic_only and not ((p.get("heritage") or {}).get("is_historic") or (p.get("start_year") and p["start_year"] <= 2010)):
continue
if price_tiers:
tiers_set = {price_tiers} if isinstance(price_tiers, int) else set(price_tiers)
if p.get("price_tier") not in tiers_set:
continue
if min_score and p["quality_score"] < min_score:
continue
sim = float(sims[i]) if (sims is not None and i < len(sims)) else 0.0
exact_bonus = 0.0
intent_matched = False
if not q_lower:
match_weight = 1.0
else:
p_name = p["name"].lower()
p_norm = self._normalize_name(p["name"])
q_norm = self._normalize_name(q_lower)
p_cat = p.get("primary_category", "").lower()
p_cuisine = (p.get("cuisine") or "").lower()
p_alts = [c.lower() for c in p.get("alternate_categories", [])]
p_badges = [b.lower() for b in p.get("craft_badges", [])]
# 1. Exact entity matches on place name (with unicode diacritic tolerance)
if q_lower == p_name or q_norm == p_norm:
exact_bonus += 50.0
intent_matched = True
elif q_lower in p_name or q_norm in p_norm:
exact_bonus += 35.0
intent_matched = True
elif any(t in p_name or self._normalize_name(t) in p_norm for t in intent_tokens if len(t) > 2):
exact_bonus += 20.0
intent_matched = True
# 2. Specific Category / Cuisine / Craft match (NEVER match generic stops like 'restaurant')
for t in expanded_intent:
if t == p_cat or (len(t) >= 4 and t in p_cat and p_cat not in QUERY_INTENT_STOPWORDS):
exact_bonus += 25.0
intent_matched = True
if t in p_cuisine:
exact_bonus += 20.0
intent_matched = True
if any(t in c for c in p_alts if c not in QUERY_INTENT_STOPWORDS):
exact_bonus += 15.0
intent_matched = True
if any(t in b for b in p_badges):
exact_bonus += 15.0
intent_matched = True
dossier = p.get("dossier")
if dossier:
sig_lower = dossier.get("signature_dishes", "").lower()
if len(t) >= 4 and t in sig_lower:
exact_bonus += 25.0
intent_matched = True
# 2b. Monument / Heritage queries boost authentic monuments
if any(h in expanded_intent for h in ["historic", "monument", "rijksmonument", "heritage", "century", "oldest", "brown"]):
if p.get("heritage", {}).get("is_monument") and p.get("heritage", {}).get("authenticity_boost"):
exact_bonus += 35.0
intent_matched = True
# 2c. Geographic Neighborhood Intent Match / Mismatch Penalty
if geo_target:
if venue_in_geo:
exact_bonus += 250.0
intent_matched = True
else:
exact_bonus -= 100.0
# 3. If user specified a clear intent (e.g. "syrian") and venue does NOT match it:
# require strong semantic similarity (>= 0.65) to even be considered.
has_clear_intent = len(intent_tokens) > 0 and len(intent_tokens) < len(tokens)
if has_clear_intent and not intent_matched and sim < 0.65:
continue
# Standard semantic cutoff
if not intent_matched and sim < 0.20:
continue
dist_penalty = min(15, (dist_m / 450.0))
if not q_lower:
if sort_by == "distance":
effective_rank = 10000 - dist_m + (p["quality_score"] * 0.5)
elif sort_by == "endorsed":
effective_rank = (p.get("endorsement_count", 0) * 15.0) + p["quality_score"]
elif sort_by == "heritage":
is_monument = 1 if p.get("heritage", {}).get("is_monument") else 0
dist_bias = dist_penalty if has_user_coords else 0
effective_rank = (is_monument * 500.0) + (p["quality_score"] * 0.85) - dist_bias
else: # quality (default)
# When sorting by Culinary Quality across a city, quality score is king.
# Endorsement count is a subtle tie-breaker; distance is only a gentle tie-breaker if user gave real GPS.
tie_breaker = (p.get("endorsement_count", 0) * 0.05)
if has_user_coords:
tie_breaker -= min(1.0, dist_m / 10000.0)
effective_rank = (p["quality_score"] * 10.0) + tie_breaker
else:
# Active Search Query: Hybrid Vector Cosine Similarity (75x) + Palate Quality (0.40x) + Intent Exact Bonuses
if sort_by == "distance":
effective_rank = 10000 - dist_m + (p["quality_score"] * 0.4) + (sim * 50.0) + exact_bonus
elif sort_by == "endorsed":
effective_rank = (p.get("endorsement_count", 0) * 15.0) + (p["quality_score"] * 0.7) + (sim * 50.0) + exact_bonus
elif sort_by == "heritage":
is_monument = 1 if p.get("heritage", {}).get("is_monument") else 0
dist_bias = dist_penalty if has_user_coords else 0
effective_rank = (is_monument * 500.0) + (sim * 75.0) + (p["quality_score"] * 0.40) + exact_bonus - dist_bias
else: # quality (default)
dist_bias = min(2.0, dist_penalty) if has_user_coords else 0
effective_rank = (sim * 75.0) + (p["quality_score"] * 0.40) + exact_bonus - dist_bias
results.append({
**p,
"distance_m": dist_m,
"similarity_score": round(sim, 3) if q_lower else None,
"effective_rank": round(effective_rank, 1),
"matched_neighborhood": geo_target if venue_in_geo else None
})
results.sort(key=lambda x: x["effective_rank"], reverse=True)
total_count = len(results)
paginated = results[offset:offset + max_results]
if return_total:
return paginated, total_count
return paginated
def get_facets(self, authentic_only=False):
"""Return aggregations for UI filter buttons and system stats."""
pool = [p for p in self.places if not p.get('is_tourist_trap') and not p.get('is_ghost_kitchen')] if authentic_only else self.places
total = len(pool)
return {
"total_eateries": total,
"halal_count": sum(1 for p in pool if p.get("dietary", {}).get("halal")),
"vegan_count": sum(1 for p in pool if p.get("dietary", {}).get("vegan")),
"vegetarian_count": sum(1 for p in pool if p.get("dietary", {}).get("vegetarian")),
"gluten_free_count": sum(1 for p in pool if p.get("dietary", {}).get("gluten_free")),
"canal_terrace_count": sum(1 for p in pool if p.get("amenities", {}).get("canal_terrace")),
"outdoor_count": sum(1 for p in pool if p.get("amenities", {}).get("outdoor_seating")),
"wheelchair_count": sum(1 for p in pool if p.get("amenities", {}).get("wheelchair_accessible")),
"accredited_count": sum(1 for p in pool if p.get("accreditation")),
"craft_artisans_detected": sum(1 for p in pool if p.get("craft_badges")),
"tourist_traps_flagged": sum(1 for p in self.places if p.get("is_tourist_trap")),
"mono_specialists": sum(1 for p in pool if p.get("category_purity", 0) == 1.0),
"high_confidence_count": sum(1 for p in pool if p.get("confidence", 0) >= 0.85),
"monuments_count": sum(1 for p in pool if p.get("heritage", {}).get("is_monument")),
"rijksmonumenten_count": sum(1 for p in pool if p.get("heritage", {}).get("status") == "Rijksmonument"),
"independent_count": sum(1 for p in pool if p.get("ownership", {}).get("is_independent")),
"corporate_chain_count": sum(1 for p in pool if not p.get("ownership", {}).get("is_independent")),
"celiac_safe_count": sum(1 for p in pool if p.get("celiac_safety", {}).get("is_safe")),
"scratch_count": sum(1 for p in pool if p.get("scratch_kitchen", {}).get("is_scratch")),
"tap_water_count": sum(1 for p in pool if p.get("hospitality", {}).get("free_tap_water")),
"clean_kitchen_count": sum(1 for p in pool if (p.get("food_safety") or {}).get("inspection_grade") in ["Grade A", "5/5", "Très satisfaisant", "Elite Smiley"]),
"late_night_count": sum(1 for p in pool if p.get("amenities", {}).get("late_night")),
"roastery_count": sum(1 for p in pool if p.get("amenities", {}).get("in_house_roastery")),
"historic_count": sum(1 for p in pool if (p.get("heritage") or {}).get("is_historic") or (p.get("start_year") and p["start_year"] <= 2010)),
"price_1_count": sum(1 for p in pool if p.get("price_tier") == 1),
"price_2_count": sum(1 for p in pool if p.get("price_tier") == 2),
"price_3_count": sum(1 for p in pool if p.get("price_tier") == 3),
"price_4_count": sum(1 for p in pool if p.get("price_tier") == 4)
}
def place_matches_filters(self, p, filters, exclude_keys=None):
if not filters:
return True
if exclude_keys is None:
exclude_keys = set()
# 0. Exclude low-quality tourist traps and ghost kitchens
if p.get("is_tourist_trap") or p.get("is_ghost_kitchen"):
return False
# 1. Price tier
if "price" not in exclude_keys and filters.get("price") is not None:
pt = filters["price"]
tiers = {pt} if isinstance(pt, int) else set(pt)
if p.get("price_tier") not in tiers:
return False
# 2. Cuisine cluster
if "cuisine" not in exclude_keys and filters.get("cuisine"):
target_c = filters["cuisine"]
if target_c not in p.get("_cluster_ids", []):
return False
# 3. Diet
if "diet" not in exclude_keys and filters.get("diet"):
d = str(filters["diet"]).lower().strip()
if d in ("celiac", "celiac_safe"):
if not p.get("celiac_safety", {}).get("is_safe"):
return False
elif d in ("gluten_free", "glutenfree", "gf"):
if not (p.get("dietary", {}).get("gluten_free") or p.get("celiac_safety", {}).get("is_safe")):
return False
elif not p.get("dietary", {}).get(d):
return False
# 4. Amenities / Flags
amenities_map = {
"outdoor": ("amenities", "outdoor_seating"),
"canal": ("amenities", "canal_terrace"),
"wheelchair": ("amenities", "wheelchair_accessible"),
"late_night": ("amenities", "late_night"),
"roastery": ("amenities", "in_house_roastery"),
"scratch": ("scratch_kitchen", "is_scratch"),
"water": ("hospitality", "free_tap_water"),
"celiac": ("celiac_safety", "is_safe"),
"monument": ("heritage", "is_monument"),
"independent": ("ownership", "is_independent"),
}
for key, (sec, prop) in amenities_map.items():
if key not in exclude_keys and filters.get(key):
if not p.get(sec, {}).get(prop):
return False
if "accredited" not in exclude_keys and filters.get("accredited"):
if not p.get("accreditation"):
return False
if "clean" not in exclude_keys and filters.get("clean"):
fs = p.get("food_safety") or {}
if fs.get("inspection_grade") not in ["Grade A", "5/5", "Très satisfaisant", "Elite Smiley"]:
return False
if "historic" not in exclude_keys and filters.get("historic"):
if not ((p.get("heritage") or {}).get("is_historic") or (p.get("start_year") and p["start_year"] <= 2010)):
return False
# 5. Spatial bounding box
if "bbox" not in exclude_keys and filters.get("bbox"):
bbox = filters["bbox"]
if len(bbox) == 4:
min_lat, min_lon, max_lat, max_lon = bbox
plat, plon = p.get("latitude"), p.get("longitude")
if plat is None or plon is None or not (min_lat <= plat <= max_lat and min_lon <= plon <= max_lon):
return False
# 6. Max distance
if "max_dist" not in exclude_keys and filters.get("max_dist") is not None and filters.get("user_lat") and filters.get("user_lon"):
plat, plon = p.get("latitude"), p.get("longitude")
if plat and plon:
dist = haversine_distance_m(filters["user_lat"], filters["user_lon"], plat, plon)
if dist > filters["max_dist"]:
return False
return True
def get_city_taxonomy(self, city_name=None, active_filters=None):
"""
Dynamic City Taxonomy Engine with Cascading Facets:
Computes top craft clusters, price tiers, and environmental facets for any city based on ground-truth data.
When active_filters is provided, dynamically calculates cross-facet and disjunctive counts.
"""
if not city_name:
city_name = self.city_config.get("name", self.city_id.capitalize())
authentic_places = [p for p in self.places if not p.get('is_tourist_trap') and not p.get('is_ghost_kitchen')]
has_active_filters = False
if active_filters:
for k, v in active_filters.items():
if v:
has_active_filters = True
break
if not has_active_filters:
base_pool = authentic_places
cuisine_pool = authentic_places
price_pool = authentic_places
else:
base_pool = [p for p in self.places if self.place_matches_filters(p, active_filters)]
cuisine_pool = [p for p in self.places if self.place_matches_filters(p, active_filters, exclude_keys={"cuisine"})]
price_pool = [p for p in self.places if self.place_matches_filters(p, active_filters, exclude_keys={"price"})]
currency_sym = self.city_config.get("currency_symbol", "€")
price_labels = {
1: currency_sym if currency_sym != "AED" else "AED",
2: currency_sym * 2 if currency_sym != "AED" else "AED 2",
3: currency_sym * 3 if currency_sym != "AED" else "AED 3",
4: currency_sym * 4 if currency_sym != "AED" else "AED 4"
}
price_tiers_data = [
{
"tier": 1,
"symbol": price_labels[1],
"label": "Budget / Street Food",
"count": sum(1 for p in price_pool if p.get("price_tier") == 1)
},
{
"tier": 2,
"symbol": price_labels[2],
"label": "Casual Dining",
"count": sum(1 for p in price_pool if p.get("price_tier") == 2)
},
{
"tier": 3,
"symbol": price_labels[3],
"label": "Upscale Dining",
"count": sum(1 for p in price_pool if p.get("price_tier") == 3)
},
{
"tier": 4,
"symbol": price_labels[4],
"label": "Fine Dining / Stars",
"count": sum(1 for p in price_pool if p.get("price_tier") == 4)
}
]
# Calculate cluster counts from cuisine_pool (Disjunctive faceting)
cluster_counts = Counter()
for p in cuisine_pool:
for cid in p.get("_cluster_ids", []):
cluster_counts[cid] += 1
all_clusters = []
for arch in MASTER_CRAFT_ARCHETYPES:
aid = arch["id"]
cnt = cluster_counts[aid]
label = arch["label"]
if aid == "seafood":
if self.city_id == "dubai":
label = "Coastal & Fresh Seafood"
elif self.city_id == "istanbul":
label = "Bosphorus & Coastal Seafood"
elif aid == "deli" and self.city_id != "amsterdam":
label = "Artisanal Delis & Sandwiches"
# Check if this cluster exists in this city at all
city_has_cluster = any(aid in p.get("_cluster_ids", []) for p in authentic_places)
if city_has_cluster:
all_clusters.append({
"id": aid,
"label": label,
"icon": arch["icon"],
"query": arch["query"],
"count": cnt,
"avg_score": 85.0
})
# Rank clusters by count descending
all_clusters.sort(key=lambda x: x["count"], reverse=True)
row1_clusters = all_clusters[:10]
waterfront_label = self.city_config.get("waterfront_label") or ("Canal Terrace" if self.city_id == "amsterdam" else "Waterfront Terrace")
heritage_label = self.city_config.get("heritage_badge_label") or "Historic Monuments"
amenity_counts = {
"independent": sum(1 for p in base_pool if p.get("ownership", {}).get("is_independent")),
"clean": sum(1 for p in base_pool if (p.get("food_safety") or {}).get("inspection_grade") in ["Grade A", "5/5", "Très satisfaisant", "Elite Smiley"]),
"accredited": sum(1 for p in base_pool if p.get("accreditation")),
"late_night": sum(1 for p in base_pool if p.get("amenities", {}).get("late_night")),
"roastery": sum(1 for p in base_pool if p.get("amenities", {}).get("in_house_roastery")),
"historic": sum(1 for p in base_pool if ((p.get("heritage") or {}).get("is_historic") or (p.get("start_year") and p["start_year"] <= 2010))),
"wheelchair": sum(1 for p in base_pool if p.get("amenities", {}).get("wheelchair_accessible")),
"canal": sum(1 for p in base_pool if p.get("amenities", {}).get("canal_terrace")),
"outdoor": sum(1 for p in base_pool if p.get("amenities", {}).get("outdoor_seating")),
"scratch": sum(1 for p in base_pool if p.get("scratch_kitchen", {}).get("is_scratch")),
"halal": sum(1 for p in base_pool if p.get("dietary", {}).get("halal")),
"vegan": sum(1 for p in base_pool if p.get("dietary", {}).get("vegan")),
"vegetarian": sum(1 for p in base_pool if p.get("dietary", {}).get("vegetarian")),
"celiac": sum(1 for p in base_pool if p.get("celiac_safety", {}).get("is_safe")),
"water": sum(1 for p in base_pool if p.get("hospitality", {}).get("free_tap_water")),
"monument": sum(1 for p in base_pool if p.get("heritage", {}).get("is_monument"))
}
# Baseline counts over all authentic places in city to know what options exist in the city
baseline_facets = self.get_facets(authentic_only=True)
city_supported_keys = {
"independent": baseline_facets["independent_count"] > 0,
"clean": baseline_facets.get("clean_kitchen_count", 0) > 0,
"accredited": baseline_facets["accredited_count"] > 0,
"late_night": baseline_facets.get("late_night_count", 0) > 0,
"roastery": baseline_facets.get("roastery_count", 0) > 0,
"historic": baseline_facets.get("historic_count", 0) > 0,
"wheelchair": baseline_facets.get("wheelchair_count", 0) > 0,
"canal": baseline_facets["canal_terrace_count"] > 0,
"outdoor": baseline_facets["outdoor_count"] > 0,
"scratch": baseline_facets["scratch_count"] > 0,
"halal": baseline_facets["halal_count"] > 0,
"vegan": baseline_facets["vegan_count"] > 0,
"vegetarian": baseline_facets["vegetarian_count"] > 0,
"celiac": baseline_facets["celiac_safe_count"] > 0,
"water": baseline_facets["tap_water_count"] > 0,
"monument": baseline_facets.get("monuments_count", 0) > 0
}
row2_facets = [
{"id": "independent", "type": "amenity", "label": "100% Independent", "icon": "👨🍳", "count": amenity_counts["independent"]},
{"id": "clean", "type": "amenity", "label": "Grade A Clean Kitchen" if self.city_id == "dubai" else "Gov Hygiene Certified", "icon": "🏆", "count": amenity_counts["clean"]},
{"id": "accredited", "type": "amenity", "label": "Awarded by Guides", "icon": "🏅", "count": amenity_counts["accredited"]},
{"id": "late_night", "type": "amenity", "label": "Late-Night Dining (Post-Midnight)", "icon": "🌙", "count": amenity_counts["late_night"]},
{"id": "roastery", "type": "amenity", "label": "On-Site Micro-Roaster", "icon": "☕", "count": amenity_counts["roastery"]},
{"id": "historic", "type": "amenity", "label": "Historic Icons (Est. Pre-2010)", "icon": "⏳", "count": amenity_counts["historic"]},
{"id": "wheelchair", "type": "amenity", "label": "Wheelchair Accessible", "icon": "♿", "count": amenity_counts["wheelchair"]},
{"id": "canal", "type": "amenity", "label": waterfront_label, "icon": "⛵" if "Canal" in waterfront_label else "🌊", "count": amenity_counts["canal"]},
{"id": "outdoor", "type": "amenity", "label": "Outdoor Seating", "icon": "☀️", "count": amenity_counts["outdoor"]},
{"id": "scratch", "type": "amenity", "label": "Scratch Kitchen", "icon": "🌾", "count": amenity_counts["scratch"]},
{"id": "halal", "type": "diet", "label": "Halal Verified", "icon": "🥩", "count": amenity_counts["halal"]},
{"id": "vegan", "type": "diet", "label": "100% Vegan", "icon": "🌿", "count": amenity_counts["vegan"]},
{"id": "vegetarian", "type": "diet", "label": "Vegetarian", "icon": "🌱", "count": amenity_counts["vegetarian"]},
{"id": "celiac", "type": "amenity", "label": "Celiac Safe Kitchen", "icon": "🩺", "count": amenity_counts["celiac"]},
{"id": "water", "type": "amenity", "label": "Free Tap Water", "icon": "💧", "count": amenity_counts["water"]},
{"id": "monument", "type": "amenity", "label": heritage_label, "icon": "🏛️", "count": amenity_counts["monument"]}
]
active_facets = [f for f in row2_facets if city_supported_keys.get(f["id"], False)]
return {
"city": city_name,
"city_id": self.city_id,
"currency_symbol": currency_sym,
"price_tiers": price_tiers_data,
"total_authentic": len(base_pool),
"row1_clusters": row1_clusters,
"all_clusters": all_clusters,
"row2_facets": active_facets,
"dining_guide": self.city_config.get("dining_guide", {}),
"is_dynamic": has_active_filters
}
def compare_head_to_head(self, id1, id2):
p1 = next((p for p in self.places if p["id"] == id1), None)
p2 = next((p for p in self.places if p["id"] == id2), None)
if not p1 or not p2:
return None
winner = p1 if p1["quality_score"] >= p2["quality_score"] else p2
loser = p2 if winner == p1 else p1
dist_between_m = int(haversine_distance_m(
p1["latitude"], p1["longitude"],
p2["latitude"], p2["longitude"]
))
advantages = []
if winner.get("ownership", {}).get("is_independent") and not loser.get("ownership", {}).get("is_independent"):
advantages.append(f"Ownership Advantage: 100% independent artisan shop vs multi-unit commercial chain ({loser.get('ownership', {}).get('badge', 'Chain')}).")
if winner.get("accreditation") and not loser.get("accreditation"):
tier = winner['accreditation'].get('award_tier') or winner['accreditation'].get('award')
advantages.append(f"Holds official culinary accreditation ({tier}).")
if winner.get("celiac_safety", {}).get("is_safe") and not loser.get("celiac_safety", {}).get("is_safe"):
advantages.append(f"Medical Safety Advantage: {winner['celiac_safety'].get('badge')} with verified cross-contamination protocols.")
if winner.get("scratch_kitchen", {}).get("is_scratch") and not loser.get("scratch_kitchen", {}).get("is_scratch"):
advantages.append("Culinary Craft Advantage: Verified scratch production (scratch dough, in-house butchery, fresh prep).")
if winner.get("heritage", {}).get("is_monument") and not loser.get("heritage", {}).get("is_monument"):
badge = winner['heritage'].get('badge') or 'Historic Monument'
advantages.append(f"Architectural Heritage Advantage: Housed in authentic protected monument ({badge}) vs modern commercial space.")
if winner.get("hospitality", {}).get("free_tap_water") and not loser.get("hospitality", {}).get("free_tap_water"):
advantages.append("Honest Hospitality: Pledged complimentary fresh tap water carafes.")
if winner["craft_badges"] and not loser["craft_badges"]:
advantages.append(f"Uses verified craft techniques ({', '.join(winner['craft_badges'][:2])}) whereas {loser['name']} has none.")
if winner.get("amenities", {}).get("canal_terrace") and not loser.get("amenities", {}).get("canal_terrace"):
advantages.append("Offers scenic outdoor canal terrace seating along historic waterways.")
if winner.get("schedule_rhythm") == "artisan_rhythm" and loser.get("schedule_rhythm") == "tourist_continuous":
advantages.append("Follows traditional artisan kitchen rhythms with fresh prep and rest days instead of continuous factory heating.")
if winner["category_purity"] > loser["category_purity"]:
advantages.append(f"100% focused on its core specialty ({winner['primary_category']}) instead of a diluted multi-menu.")
if not winner["is_chain"] and loser["is_chain"]:
advantages.append(f"Independent local artisan shop vs. a multi-unit corporate franchise ({loser['brand']}).")
if winner.get("start_year") and (not loser.get("start_year") or winner["start_year"] < loser["start_year"]):
advantages.append(f"Proven Lindy longevity: established in {winner['start_year']}.")
if loser.get("dist_to_tourist_hub_m") and loser["dist_to_tourist_hub_m"] < 100:
advantages.append(f"{winner['name']} avoids high-density commercial corridor congestion.")
if not advantages:
advantages.append(f"{winner['name']} ranks higher on authentic neighborhood presence and category focus.")
return {
"place_a": p1,
"place_b": p2,
"winner_id": winner["id"],
"winner_name": winner["name"],
"loser_name": loser["name"],
"distance_between_m": dist_between_m,
"advantages": advantages,
"recommendation": f"Walk {dist_between_m}m to {winner['name']} for authentic quality." if dist_between_m > 0 else f"Choose {winner['name']}."
}
class MultiCityEngineManager:
def __init__(self):
self.engines = {}
self.cities = self.load_cities()
self._cached_city_list = None
def load_cities(self):
cities_file = os.path.join(DATA_DIR, "cities.json")
if os.path.exists(cities_file):
try:
with open(cities_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"Error loading cities.json: {e}")
return [{"id": "amsterdam", "name": "Amsterdam", "country": "Netherlands", "flag": "🇳🇱"}]
def get_engine(self, city_id="amsterdam"):
cid = (city_id or "amsterdam").lower().strip()
valid_ids = [c["id"] for c in self.cities]
if cid not in valid_ids:
cid = "amsterdam"
if cid not in self.engines:
print(f"Initializing PalateEngine for city: {cid}...")
self.engines[cid] = PalateEngine(city_id=cid)
return self.engines[cid]
def list_cities(self):
result = []
for c in self.cities:
cid = c["id"]
eng = self.engines.get(cid)
total = len(eng.places) if eng else c.get("total_eateries", 3500)
authentic = sum(1 for p in eng.places if not p.get("is_tourist_trap")) if eng else total
craft = sum(1 for p in eng.places if p.get("craft_badges")) if eng else int(total * 0.4)
independent = sum(1 for p in eng.places if p.get("ownership", {}).get("is_independent")) if eng else int(total * 0.7)
result.append({
"id": cid,
"name": c["name"],
"country": c["country"],
"country_code": c.get("country_code", ""),
"flag": c.get("flag", "🌐"),
"center": c["center"],
"default_zoom": c.get("default_zoom", 13),
"tagline": c.get("tagline", ""),
"currency": c.get("currency", "EUR"),
"currency_symbol": c.get("currency_symbol", "€"),
"total_eateries": total,
"authentic_eateries": authentic,
"craft_artisans_detected": craft,
"independent_count": independent,
"dining_guide": c.get("dining_guide", {})
})
return result
if __name__ == "__main__":
engine = PalateEngine()
print("\n--- TEST: 'burger' TOP RESULTS ---")
for r in engine.search("burger", max_results=5):
print(f"[{r['quality_score']} pts] {r['name']} ({r['neighborhood']})")
print(f" Badges: {r['craft_badges']}")
print("\n--- TEST: 'pizza' TOP RESULTS ---")
for r in engine.search("pizza", max_results=6):
print(f"[{r['quality_score']} pts] {r['name']} ({r['neighborhood']})")
print(f" Badges: {r['craft_badges']}")
|