Spaces:
Sleeping
Sleeping
File size: 103,749 Bytes
98fa7bf | 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 | import gradio as gr
import re
import os
import requests
import json
import logging
import csv
import io
import tempfile
import time
from typing import Dict, List, Tuple, Optional
from llm_sender_unified import create_llm_sender
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────
# مدلهای موجود — برای تحلیل LLM
# ─────────────────────────────────────────────────────────────
AVAILABLE_MODELS = {
"chatgpt": ["gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],
"grok": ["grok-4-0709", "grok-3", "grok-3-mini", "grok-2-1212"],
"deepinfra": [
"Qwen/Qwen3-14B", "Qwen/Qwen3-32B", "Qwen/Qwen3-30B-A3B",
"Qwen/Qwen2.5-72B-Instruct", "Qwen/Qwen2.5-14B-Instruct",
],
}
ANON_MODEL = "Qwen/Qwen3-14B"
ANON_API_URL = "https://api.deepinfra.com/v1/openai/chat/completions"
# ─────────────────────────────────────────────────────────────
# SYSTEM PROMPT — برگرفته از نسخه بنچمارک ۹۰٪+
# ترکیب با قابلیت JSON output برای single call
# Thinking mode فعال — همان چیزی که دقت بالا میداد
# ─────────────────────────────────────────────────────────────
ANON_SYSTEM_PROMPT = """شما یک «ناشناسساز متون مالی/خبری فارسی» هستید. وظیفهتان جایگزینی اسامی خاص و مقادیر عددی با شناسههای بیمعناست.
قبل از دادن پاسخ نهایی، ابتدا در تگ <thinking> گامبهگام تحلیل کنید:
1. موجودیتهای موجود در متن را شناسایی کنید (شرکت، شخص، مبلغ، درصد)
2. ترتیب ظهور آنها را مشخص کنید
3. نامهای مختصر/تکرار را به همان توکن اول نسبت دهید
4. سپس JSON نهایی را بدهید
### قوانین اندیسگذاری:
- شرکتها: company-01, company-02, ... (بر اساس ترتیب ظهور)
- اشخاص: person-01, person-02, ...
- اعداد/مبالغ: amount-01, amount-02, ... (هر amount ایندکس یکتا دارد، هیچوقت تکرار نشود)
- درصدها: percent-01, percent-02, ...
- هر بار که همان موجودیت تکرار میشود → همان توکن قبلی
- فقط: company, person, amount, percent ❌ ممنوع: bank-01, sazman-01, group-XX
### تشخیص شرکتها (قانون کلی: هر نهاد با نام خاص = company-XX):
- شرکتهای خصوصی: با پیشوند شرکت/گروه/هلدینگ/بیمه/پتروشیمی/سرمایهگذاری + نام خاص
- بانکها: بانک ملت، بانک پارسیان، بانک مرکزی، بانک ملی ایران → همه company-XX
- وزارتخانهها: وزارت نفت، وزارت اقتصاد، وزارت صمت → company-XX
- سازمانهای دولتی: سازمان بورس، سازمان ثبت احوال، سازمان مالیاتی → company-XX
- ادارات دولتی: اداره کل مالیات تهران، اداره ثبت اسناد → company-XX
- نهادهای انقلابی/عمومی با نام خاص: ستاد اجرایی فرمان امام، بنیاد مستضعفان → company-XX
- دادگاهها/دادستانی با نام خاص: دادگاه تجدیدنظر استان تهران، دادستانی کل کشور → company-XX
- نمادهای بورسی: شپنا، وبملت، وتجارت، خودرو، شتران، فملی، فولاد، کگل، شپدیس → company-XX
- نام مختصر = همان توکن: «شرکت پتروشیمی بوعلی سینا» = «بوعلی» → هر دو company-01
- نام در پرانتز = همان توکن: «هلدینگ غدیر (وکغدیر)» → هر دو company-01
- حسابرس/بازرس قانونی با نام خاص: «وانیا نیک تدبیر» → company-XX
- ضمیر اشاره به شرکت قبلاً ذکرشده: «این شرکت»، «آن بانک»، «شرکت مزبور» → همان توکن قبلی
- قانون طلایی: اگر شک داری نام خاص است یا عمومی → آن را company-XX بگیر
### ❌ فقط این موارد company نیستند (ناشناس نشوند):
- میدانهای نفتی/گازی/معدنی: «پارس جنوبی»، «فرزاد ب»، «آزادگان» → مکان/میدان
- واحدهای داخلی بدون نام مستقل: «واحد حسابرسی داخلی»، «اداره بازرسی داخلی» → بخش سازمانی
- نقشهای شغلی بدون نام: «حسابرس مستقل»، «بازرس» (بدون اسم بعدی) → عنوان شغلی
- توصیفهای کاملاً عمومی: «یک شرکت»، «چند بانک»، «این اداره»، «بانکهای کشور» → بدون نام خاص
- «صندوق دولت»، «خزانه دولت»، «بیتالمال» → عبارت عمومی
- اسامی مکان صرف: تهران، اصفهان، ایران، خوزستان → مکان جغرافیایی
### قوانین amount — چه چیزی amount است:
✅ مبلغ پولی: «100 میلیون دلار»، «283 ریال»، «41.5 همت»، «1,429,349 میلیون ریال»
✅ مقدار فیزیکی: «320,000 تن»، «1.6 میلیون فوت مکعب در روز»، «800 هزار بشکه»
✅ تعداد شمارشی قابل اندازهگیری: «16 قرارداد»، «200 فرصت»، «23 میدان»، «12 فقره»، «500 نفر»
✅ بازه مقداری: «یک تا 1.5 میلیون تن» → یک توکن amount-01
❌ «amount-01 دلار» — واحد باید داخل توکن باشد
### ❌ این موارد amount نیستند:
- روز/ماه: «8 ماه»، «20 روز»، «6 ماهه»، «3 سال» — دوره زمانی، ناشناس نشود
- تاریخ: «30 آذر 1403»، «1403/04/12»، «سال 1401» — ناشناس نشود
- مجازات قضایی: «5 سال حبس»، «36 ضربه شلاق»، «3 سال انفصال» — ناشناس نشود
- عبارت تقریبی: «هزاران نفر»، «صدها شاکی»، «چند نفر» — ناشناس نشود
- ترتیبی و رتبهای: «ردیف اول»، «مرحله سوم»، «فاز 39» — ناشناس نشود
### قوانین percent (عدد + درصد = یک موجودیت):
✅ «80 درصد» → percent-01
✅ «14%» → percent-01
✅ «منفی 345 درصد» → percent-01 ❌ «منفی percent-01»
✅ «37 درصدی» → percent-01
✅ «50 الی 70 درصد» → percent-01 ❌ «percent-01 الی percent-02»
### تشخیص اشخاص (person) — قانون اصلی:
هر «نام + نامخانوادگی» که یک شخص حقیقی مشخص باشد = person-XX، با یا بدون عنوان:
- بدون عنوان: «محسن پاکنژاد»، «احمد کریمی» → person-XX (عنوان الزامی نیست)
- با عنوان: «دکتر علی رضایی»، «مهندس احمدی» → person-XX
- با نقش: «قاضی حسینی گفت...»، «مدیرعامل رضایی» → person-XX (نام بعد از نقش)
- در اسناد حقوقی: شهر محل سکونت، خیابان، محله = person-XX (اطلاعات شناسهساز)
### ❌ این موارد person نیستند:
- فقط نقش بدون نام: «مدیرعامل»، «وزیر»، «قاضی»، «بازرس»
- عناوین حقوقی: «محکومعلیه»، «شاکی»، «خوانده»، «خواهان»
- عبارات اداری/مالی: «عادی سالیانه»، «فوقالعاده»، «سود انباشته»
### تشخیص مبالغ (amount) — قانون تکمیلی:
- اعداد نوشتاری = amount: «شش حزب» → amount-XX «شش»، «هفت نفر» → amount-XX
- کد ملی = amount (۱۰ رقم)
- پلاک/بلوک/واحد/ناحیه + عدد = amount
- درصد که مقدار مشخص دارد: «۹ درصد» → ببین در context چیه؛ اگر نرخ/تغییر = percent، اگر مقدار خالص = amount
### موارد که باید حفظ شوند (ناشناس نشوند):
- تاریخ: «30 آذر 1403»، «1403/04/12»، «1404/04/29»، «سال 1401»
- دوره/بازه زمانی: «8 ماه»، «20 روز»، «9 ماهه»، «3 ساله»، «سال مالی منتهی به»، «سهماهه نخست»
- زمان: «راس ساعت 10:00»، «روز سه شنبه»، «مردادماه»
- مکان: تهران، اصفهان، ایران، خوزستان، عسلویه
- میدانهای نفتی/گازی: پارس جنوبی، فرزاد ب، آزادگان
- عناوین شغلی: مدیرعامل، رئیس کل، بازرس قانونی، حسابرس، دادستان، وزیر
- مجازات قضایی: سال حبس، ضربه شلاق، سال انفصال
- نماد بورسی → با company-XX جایگزین شود اگر نام شرکت مربوطه در متن نیست؛ وگرنه همان توکن شرکت
### فرمت خروجی نهایی (بعد از thinking):
{
"anonymized": "متن ناشناس شده اینجا",
"mapping": {"company-01": "نام کامل", "amount-01": "عدد+واحد", ...}
}"""
# ─────────────────────────────────────────────────────────────
# few-shot examples — از باگهای واقعی شناساییشده
# ─────────────────────────────────────────────────────────────
FEW_SHOT_EXAMPLES = """
=== EXAMPLES ===
EXAMPLE 1 — نام مختصر + نام در پرانتز + تکرار:
INPUT: شرکت گروه توسعه مالی مهر آیندگان (ومهان) رشد 14 درصدی داشت. سرمایهگذاریهای ومهان به 16 هزار و 495 میلیارد تومان رسید.
OUTPUT json:
{"anonymized": "company-01 رشد percent-01 داشت. سرمایهگذاریهای company-01 به amount-01 رسید.", "mapping": {"company-01": "شرکت گروه توسعه مالی مهر آیندگان (ومهان)", "percent-01": "14 درصد", "amount-01": "16 هزار و 495 میلیارد تومان"}}
KEY: «ومهان» = company-01 (same token, NOT company-02)
EXAMPLE 2 — نام کوتاه متفاوت برای شرکتهای متفاوت:
INPUT: مجمع شرکت پتروشیمی بوعلی سینا برگزار شد و وانیا نیک تدبیر بازرس شد. هزینه بوعلی 100 میلیون دلار بود. تحلیل شپنا (شرکت پالایش نفت اصفهان) نشان میدهد EPS به 936 ریال برسد.
OUTPUT json:
{"anonymized": "مجمع company-01 برگزار شد و company-02 بازرس شد. هزینه company-01 amount-01 بود. تحلیل company-03 نشان میدهد EPS به amount-02 برسد.", "mapping": {"company-01": "شرکت پتروشیمی بوعلی سینا", "company-02": "وانیا نیک تدبیر", "amount-01": "100 میلیون دلار", "company-03": "شرکت پالایش نفت اصفهان", "amount-02": "936 ریال"}}
KEY: «بوعلی» = company-01. «شپنا» = company-03 (شرکت پالایش نفت اصفهان، موجودیت جداگانه از بوعلی)
EXAMPLE 3 — کلمات عمومی ناشناس نشوند + بانکهای مشخص:
INPUT: دو بانک ملت و پاسارگاد سود 157 و 155 هزار میلیارد ریال داشتند. مجموع بانکهای مورد بررسی زیان 1388 هزار میلیارد ریال داشتند که 10 درصد افزایش یافت. 12 بانک کشور زیان 336 هزار میلیارد تومانی رقم زدند.
OUTPUT json:
{"anonymized": "دو company-01 و company-02 سود amount-01 و amount-02 داشتند. مجموع بانکهای مورد بررسی زیان amount-03 داشتند که percent-01 افزایش یافت. 12 بانک کشور زیان amount-04 رقم زدند.", "mapping": {"company-01": "بانک ملت", "company-02": "بانک پاسارگاد", "amount-01": "157 هزار میلیارد ریال", "amount-02": "155 هزار میلیارد ریال", "amount-03": "1388 هزار میلیارد ریال", "percent-01": "10 درصد", "amount-04": "336 هزار میلیارد تومانی"}}
KEY: «بانکهای مورد بررسی» و «12 بانک کشور» = generic → ناشناس نشوند
EXAMPLE 4 — نام چندکلمهای با مکان + بازه درصد:
INPUT: شرکت فولاد مبارکه اصفهان با شرکت ملی نفت ایران قرارداد امضا کرد. شرکت فاما سرمایه را از 8,700 میلیارد ریال به 12,500 میلیارد ریال افزایش میدهد. سهم سودهای ارزی 40 الی 60 درصد است.
OUTPUT json:
{"anonymized": "company-01 با company-02 قرارداد امضا کرد. company-03 سرمایه را از amount-01 به amount-02 افزایش میدهد. سهم سودهای ارزی percent-01 است.", "mapping": {"company-01": "شرکت فولاد مبارکه اصفهان", "company-02": "شرکت ملی نفت ایران", "company-03": "شرکت فاما", "amount-01": "8,700 میلیارد ریال", "amount-02": "12,500 میلیارد ریال", "percent-01": "40 الی 60 درصد"}}
KEY: «اصفهان» داخل company-01. «شرکت فاما» = company-03. بازه «40 الی 60 درصد» = یک توکن percent-01
EXAMPLE 5 — چند شرکت همنام + مبالغ کوچک + درصد با %:
INPUT: شرکت بیمه پارسیان از شرکت سرمایه گذاری پارسیان 1,429,349 میلیون ریال سود شناسایی کرد که 89 ریال برای هر سهم است. جواد شکرخواه مدیرعامل بانک پارسیان گفت سود 41.5 همت شد و 99.99 درصد سهام در اختیار است.
OUTPUT json:
{"anonymized": "company-01 از company-02 amount-01 سود شناسایی کرد که amount-02 برای هر سهم است. person-01 مدیرعامل company-03 گفت سود amount-03 شد و percent-01 سهام در اختیار است.", "mapping": {"company-01": "شرکت بیمه پارسیان", "company-02": "شرکت سرمایه گذاری پارسیان", "amount-01": "1,429,349 میلیون ریال", "amount-02": "89 ریال", "person-01": "جواد شکرخواه", "company-03": "بانک پارسیان", "amount-03": "41.5 همت", "percent-01": "99.99 درصد"}}
KEY: واحد داخل توکن. مبالغ کوچک ریال هم ناشناس میشوند.
EXAMPLE 6 — نام مختصر + پادرو + تیپیکو + شپنا:
INPUT: شرکت سرمایهگذاری دارویی تأمین (تیپیکو) درآمد 681,667 میلیارد ریال داشت. صورتهای مالی شرکت آسان پادرو 6 میلیارد تومان زیان نشان داد. پادرو 30 میلیارد تومان درآمد کسب کرد. شپنا EPS 936 ریال گزارش داد.
OUTPUT json:
{"anonymized": "company-01 درآمد amount-01 داشت. صورتهای مالی company-02 amount-02 زیان نشان داد. company-02 amount-03 درآمد کسب کرد. company-03 EPS amount-04 گزارش داد.", "mapping": {"company-01": "شرکت سرمایهگذاری دارویی تأمین (تیپیکو)", "amount-01": "681,667 میلیارد ریال", "company-02": "شرکت آسان پادرو", "amount-02": "6 میلیارد تومان", "amount-03": "30 میلیارد تومان", "company-03": "شرکت پالایش نفت اصفهان", "amount-04": "936 ریال"}}
KEY: «تیپیکو» = company-01. «پادرو» = company-02 (همان شرکت آسان پادرو). «شپنا» = company-03 (شرکت پالایش نفت اصفهان)
EXAMPLE 7 — میدان نفتی/گازی = مکان نه شرکت + این شرکت = همان توکن:
INPUT: قرارداد توسعه میدان گازی پارس جنوبی میان شرکت ملی نفت ایران و گروه پتروپارس به ارزش 20 میلیارد دلار امضا شد. این شرکت تا پایان سال تولید را افزایش خواهد داد.
OUTPUT json:
{"anonymized": "قرارداد توسعه میدان گازی پارس جنوبی میان company-01 و company-02 به ارزش amount-01 امضا شد. company-02 تا پایان سال تولید را افزایش خواهد داد.", "mapping": {"company-01": "شرکت ملی نفت ایران", "company-02": "گروه پتروپارس", "amount-01": "20 میلیارد دلار"}}
KEY: «پارس جنوبی» = میدان گازی (مکان) → ناشناس نشود. «این شرکت» = company-02 (همان گروه پتروپارس). اگر «وزارت نفت» در متن بود → company-XX میشود (نهاد دولتی با نام خاص).
EXAMPLE 8 — توصیف بدون نام خاص + واحد داخلی + حسابرس بدون نام + مجازات قضایی:
INPUT: طبق گزارش واحد حسابرسی داخلی، این شرکت بازرگانی مبلغ 78 میلیارد تومان اختلاف مالیاتی دارد. حسابرس مستقل تأیید کرد. شورای شهر درخواست بررسی کرد. متهم به 5 سال حبس و 36 ضربه شلاق محکوم شد.
OUTPUT json:
{"anonymized": "طبق گزارش واحد حسابرسی داخلی، این شرکت بازرگانی مبلغ amount-01 اختلاف مالیاتی دارد. حسابرس مستقل تأیید کرد. شورای شهر درخواست بررسی کرد. متهم به 5 سال حبس و 36 ضربه شلاق محکوم شد.", "mapping": {"amount-01": "78 میلیارد تومان"}}
KEY: «واحد حسابرسی داخلی» = بخش داخلی، ناشناس نشود. «این شرکت بازرگانی» = توصیف بدون نام خاص، ناشناس نشود. «حسابرس مستقل» = عنوان شغلی بدون نام. «شورای شهر» = عمومی. «5 سال حبس»، «36 ضربه شلاق» = مجازات، نه مبلغ مالی.
EXAMPLE 9 — نماد بورسی (وکغدیر) = همان شرکت + «این شرکت» ارجاع:
INPUT: دکتر علی رضایی مدیرعامل هلدینگ صنایع و معادن غدیر گفت سال مالی وکغدیر به پایان رسید و سود خالص این شرکت 5 هزار میلیارد تومان شد که 120 درصد رشد داشت.
OUTPUT json:
{"anonymized": "person-01 مدیرعامل company-01 گفت سال مالی company-01 به پایان رسید و سود خالص company-01 amount-01 شد که percent-01 رشد داشت.", "mapping": {"person-01": "دکتر علی رضایی", "company-01": "هلدینگ صنایع و معادن غدیر", "amount-01": "5 هزار میلیارد تومان", "percent-01": "120 درصد"}}
KEY: «وکغدیر» = نماد بورسی هلدینگ غدیر = company-01 (نه company-02). «این شرکت» = company-01 (ارجاع به همان موجودیت).
EXAMPLE 10 — روز/ماه ناشناس نشود + ایندکس یکتا:
INPUT: طبق قوانین بازار سرمایه شرکتهای بورسی موظفاند سود تقسیمی را حداکثر ظرف مدت 8 ماه پس از برگزاری مجمع به حساب سهامداران پرداخت کنند. شپدیس در سه سال اخیر سود سهامداران را در کمتر از 20 روز پرداخت کرده است.
OUTPUT json:
{"anonymized": "طبق قوانین بازار سرمایه شرکتهای بورسی موظفاند سود تقسیمی را حداکثر ظرف مدت 8 ماه پس از برگزاری مجمع به حساب سهامداران پرداخت کنند. company-01 در سه سال اخیر سود سهامداران را در کمتر از 20 روز پرداخت کرده است.", "mapping": {"company-01": "شپدیس"}}
KEY: «8 ماه»، «20 روز»، «سه سال» = دوره زمانی → ناشناس نشوند. هرگز روز/ماه/سال را amount نکنید.
EXAMPLE 11 — اعداد شمارشی + واحد فیزیکی + 6 amount با ایندکس یکتا:
INPUT: وزارت نفت تاکنون 16 قرارداد برای توسعه 23 میدان با سرمایهگذاری بیش از 27 میلیارد دلار امضا کرده که 9 قرارداد با ارزش 13 میلیارد دلار در اجرا است. تولید روزانه 1.6 میلیون فوت مکعب گاز هدفگذاری شده.
OUTPUT json:
{"anonymized": "company-01 تاکنون amount-01 قرارداد برای توسعه amount-02 میدان با سرمایهگذاری بیش از amount-03 امضا کرده که amount-04 قرارداد با ارزش amount-05 در اجرا است. تولید روزانه amount-06 هدفگذاری شده.", "mapping": {"company-01": "وزارت نفت", "amount-01": "16 قرارداد", "amount-02": "23 میدان", "amount-03": "27 میلیارد دلار", "amount-04": "9 قرارداد", "amount-05": "13 میلیارد دلار", "amount-06": "1.6 میلیون فوت مکعب گاز"}}
KEY: «وزارت نفت» = نهاد دولتی با نام خاص → company-01. هر amount ایندکس یکتا: 01,02,03,04,05,06. «فوت مکعب» = واحد فیزیکی معتبر. هرگز یک ایندکس را دوبار استفاده نکنید.
EXAMPLE 12 — وزارتخانه + سازمان دولتی + بانک مرکزی + نهاد انقلابی = همه company:
INPUT: وزارت نفت اعلام کرد سازمان مالیاتی کشور ۱۲۰ میلیارد تومان مطالبه دارد. بانک مرکزی نرخ بهره را ۲۳ درصد تعیین کرد. ستاد اجرایی فرمان امام ۵۰۰ میلیارد تومان تامین مالی کرد.
OUTPUT json:
{"anonymized": "company-01 اعلام کرد company-02 amount-01 مطالبه دارد. company-03 نرخ بهره را percent-01 تعیین کرد. company-04 amount-02 تامین مالی کرد.", "mapping": {"company-01": "وزارت نفت", "company-02": "سازمان مالیاتی کشور", "amount-01": "۱۲۰ میلیارد تومان", "company-03": "بانک مرکزی", "percent-01": "۲۳ درصد", "company-04": "ستاد اجرایی فرمان امام", "amount-02": "۵۰۰ میلیارد تومان"}}
KEY: وزارت/سازمان/بانک مرکزی/ستاد = همه company-XX. هیچ نهاد دولتی با نام خاصی استثنا ندارد.
EXAMPLE 13 — اداره دولتی + دادگاه + دادستانی = company:
INPUT: اداره کل مالیات تهران پرونده را به دادگاه تجدیدنظر استان تهران ارجاع داد. دادستانی کل کشور اعلام کرد بیش از ۲۰۰ میلیارد تومان اختلاس صورت گرفته است.
OUTPUT json:
{"anonymized": "company-01 پرونده را به company-02 ارجاع داد. company-03 اعلام کرد بیش از amount-01 اختلاس صورت گرفته است.", "mapping": {"company-01": "اداره کل مالیات تهران", "company-02": "دادگاه تجدیدنظر استان تهران", "company-03": "دادستانی کل کشور", "amount-01": "۲۰۰ میلیارد تومان"}}
KEY: ادارات/دادگاه/دادستانی با نام خاص = company-XX. فقط عبارات کاملاً عمومی ناشناس نمیشوند.
EXAMPLE 14 — اسم شخص بدون عنوان + صورتجلسه اداری:
INPUT: در صورتجلسه هیئت رسیدگی به تخلفات اداری، پرونده احمد کریمی بررسی شد. رضا محمدی مسئول مالی نیز در این جلسه حضور داشت. میزان خسارت حدود ۳۵ میلیارد تومان محاسبه شده است.
OUTPUT json:
{"anonymized": "در صورتجلسه هیئت رسیدگی به تخلفات اداری، پرونده person-01 بررسی شد. person-02 مسئول مالی نیز در این جلسه حضور داشت. میزان خسارت حدود amount-01 محاسبه شده است.", "mapping": {"person-01": "احمد کریمی", "person-02": "رضا محمدی", "amount-01": "۳۵ میلیارد تومان"}}
KEY: نام+نامخانوادگی بدون عنوان → person-XX. «هیئت رسیدگی به تخلفات اداری» = بدون نام خاص → ناشناس نشود.
EXAMPLE 15 — متن حقوقی با شعبه دادگاه:
INPUT: در رأی صادرشده از شعبه ۱۲ دادگاه تجدیدنظر استان تهران، قاضی محمدرضا حسینی پرونده علی اکبر صادقی را بررسی کرد. محکومعلیه مکلف شده مبلغ ۴۲ میلیارد تومان پرداخت کند.
OUTPUT json:
{"anonymized": "در رأی صادرشده از company-01، person-01 پرونده person-02 را بررسی کرد. محکومعلیه مکلف شده مبلغ amount-01 پرداخت کند.", "mapping": {"company-01": "شعبه ۱۲ دادگاه تجدیدنظر استان تهران", "person-01": "محمدرضا حسینی", "person-02": "علی اکبر صادقی", "amount-01": "۴۲ میلیارد تومان"}}
KEY: «شعبه ۱۲ دادگاه...» = نهاد با نام خاص → company-XX. «محکومعلیه» = عنوان حقوقی، نه نام خاص → ناشناس نشود.
EXAMPLE 16 — سند حقوقی با کد ملی و نشانی:
INPUT: دادسرای ناحیه ۶ تهران. شاکی: زهرا محمدی، فرزند حسین، کد ملی ۰۰۷۸۹۰۱۲۳۴، ساکن تهران، خیابان پاسداران، پلاک ۵۶۷.
OUTPUT json:
{"anonymized": "دادسرای ناحیه amount-01 person-01. شاکی: person-02، فرزند person-03، کد ملی amount-02، ساکن person-04، خیابان person-05، پلاک amount-03.", "mapping": {"amount-01": "۶", "person-01": "تهران", "person-02": "زهرا محمدی", "person-03": "حسین", "amount-02": "۰۰۷۸۹۰۱۲۳۴", "person-04": "تهران", "person-05": "پاسداران", "amount-03": "۵۶۷"}}
KEY: در اسناد حقوقی — کد ملی = amount. پلاک/بلوک/واحد/ناحیه + عدد = amount. شهر/خیابان/محله = person (مکان حساس). نامخانوادگی تنها (حسین = اسم والد) = person.
EXAMPLE 17 — عدد نوشتاری + entity تکرارشونده:
INPUT: شش حزب سیاسی کردستان ایران با انتشار فراخوانی مشترک خواستند در اعتراض به صدور حکم اعدام محمد فاروق فرهادی و ریبوار علیپور تجمع شود.
OUTPUT json:
{"anonymized": "amount-01 حزب سیاسی company-01 با انتشار فراخوانی مشترک خواستند در اعتراض به صدور حکم اعدام person-01 و person-02 تجمع شود.", "mapping": {"amount-01": "شش", "company-01": "کردستان ایران", "person-01": "محمد فاروق فرهادی", "person-02": "ریبوار علیپور"}}
KEY: اعداد نوشتاری (شش، هفت، ده، صد...) = amount. نام + نامخانوادگی = person (حتی غیرفارسی). کردستان + ایران = نهاد جغرافیاییسیاسی → company.
=== END EXAMPLES ===
"""
# ─────────────────────────────────────────────────────────────
# ساخت prompt
# ─────────────────────────────────────────────────────────────
def build_single_call_prompt(text: str, entities: list) -> str:
"""
یک prompt = یک call
Thinking mode فعال — برای دقت بالا (نسخه ۹۰٪+)
"""
active = []
if "company" in entities: active.append("company-XX (همه سازمانها)")
if "person" in entities: active.append("person-XX (نام اشخاص)")
if "amount" in entities: active.append("amount-XX (اعداد+واحد)")
if "percent" in entities: active.append("percent-XX (درصدها)")
mapping_hints = []
if "person" in entities: mapping_hints.append('"person-XX": "نام کامل"')
if "company" in entities: mapping_hints.append('"company-XX": "نام کامل سازمان"')
if "amount" in entities: mapping_hints.append('"amount-XX": "عدد + واحد کامل"')
if "percent" in entities: mapping_hints.append('"percent-XX": "عدد + درصد/% کامل"')
return f"""{FEW_SHOT_EXAMPLES}
موجودیتهای فعال: {' | '.join(active)}
متن زیر را ناشناس کن. ابتدا در <thinking> تحلیل کن، سپس JSON نهایی بده:
فرمت خروجی نهایی (بعد از </thinking>):
{{
"anonymized": "متن ناشناس شده",
"mapping": {{ {", ".join(mapping_hints)} }}
}}
متن:
{text}"""
def build_verification_prompt(
original_text: str,
anonymized_text: str,
current_mapping: Dict[str, str],
entities: list
) -> str:
"""
پاس تأیید: متن اصلی و ناشناسشده رو مقایسه کن
موجودیتهای جامانده رو پیدا کن
"""
active = []
if "company" in entities: active.append("company-XX (سازمان/شرکت/بانک)")
if "person" in entities: active.append("person-XX (نام اشخاص)")
if "amount" in entities: active.append("amount-XX (عدد+واحد)")
if "percent" in entities: active.append("percent-XX (درصد)")
# لیست توکنهای فعلی
existing_tokens = ""
if current_mapping:
existing_lines = [f" {tok} → {val}" for tok, val in sorted(current_mapping.items())]
existing_tokens = "\n".join(existing_lines)
# بالاترین شماره هر نوع برای ادامه شمارهگذاری
next_numbers = {}
for etype in entities:
nums = [int(t.split("-")[1]) for t in current_mapping.keys() if t.startswith(f"{etype}-")]
next_numbers[etype] = max(nums) + 1 if nums else 1
next_info = ", ".join(f"{e}: از {next_numbers[e]:02d}" for e in entities if e in next_numbers)
return f"""تو یک بازبین ناشناسسازی هستی. متن اصلی و نسخه ناشناسشده رو مقایسه کن.
موجودیتهای فعال: {' | '.join(active)}
=== توکنهای فعلی ===
{existing_tokens}
=== متن اصلی ===
{original_text}
=== متن ناشناسشده فعلی ===
{anonymized_text}
وظیفه: متن اصلی و ناشناسشده رو کلمهبهکلمه مقایسه کن.
- فقط موجودیتهایی که ۱۰۰٪ مطمئنی جا ماندهاند را اضافه کن.
- اگر شک داری، اضافه نکن. بهتره یک موجودیت جا بمونه تا اینکه کلمه عادی ناشناس بشه.
- اگر نام مختصر یکی از توکنهای موجود است → از همان توکن استفاده کن.
- شمارهگذاری توکنهای جدید: {next_info}
مهم — ناشناس نکن:
- تاریخ، مکان، نام کشور، نام شهر
- عنوان شغلی: مدیرعامل، رئیس، وزیر، معاون
- کلمات عمومی: «بانکها»، «شرکتها»، «این بانک»، «12 بانک کشور»
- واحد مبلغ داخل توکن: ✅ amount-XX (شامل عدد+واحد) ❌ amount-XX ریال
- واحد درصد داخل توکن: ✅ percent-XX (شامل عدد+درصد) ❌ percent-XX درصد
خروجی: فقط JSON (بدون توضیح):
{{
"fixed_text": "متن ناشناسشده اصلاحشده با توکنهای جدید",
"new_mapping": {{"token": "مقدار اصلی"}}
}}
اگر هیچ موجودیتی جا نمانده یا مطمئن نیستی:
{{
"fixed_text": "",
"new_mapping": {{}}
}}
مثال — اینها جا نماندهاند (ناشناس نکن):
❌ «تهران» → مکان است، ناشناس نشود
❌ «مدیرعامل» → عنوان شغلی
❌ «بانکهای مورد بررسی» → عمومی
❌ «12 بانک کشور» → عمومی
❌ «سال ۱۴۰۳» → تاریخ"""
def build_analysis_prompt(anonymized_text: str, analysis_prompt: str, entities: list) -> str:
tokens = []
if "person" in entities: tokens.append("person-XX")
if "company" in entities: tokens.append("company-XX")
if "amount" in entities: tokens.append("amount-XX")
if "percent" in entities: tokens.append("percent-XX")
return f"""متن ناشناسسازی شده:
{anonymized_text}
دستورات:
{analysis_prompt}
قوانین:
- فقط از توکنهای موجود استفاده کن: {', '.join(tokens)}
- هیچ کلمهای قبل/بعد از توکنها اضافه نکن
- توکن جدید ایجاد نکن"""
# ─────────────────────────────────────────────────────────────
# توابع کمکی
# ─────────────────────────────────────────────────────────────
def strip_thinking(text: str) -> str:
"""
حذف بلوکهای think/thinking از خروجی
thinking mode فعال است — برای دقت استفاده میشود ولی در خروجی نهایی نمیآید
"""
if not text:
return text
# تگهای Qwen3 thinking
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
# تگهای نسخه قدیمی
text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL)
return text.strip()
def parse_json_response(raw: str) -> dict:
"""parse JSON مقاوم — thinking block + markdown fence"""
raw = strip_thinking(raw)
raw = re.sub(r"```(?:json)?", "", raw).replace("```", "").strip()
start = raw.find("{")
end = raw.rfind("}") + 1
if start == -1 or end == 0:
raise ValueError("JSON یافت نشد")
return json.loads(raw[start:end])
def post_deepinfra(prompt: str, system: str, max_tokens: int = 6000, timeout: int = 60) -> str:
"""
DeepInfra Qwen3-14B
Thinking mode فعال — برای دقت بالا
timeout = (connect, read) — سریع شناسایی مشکل اتصال
"""
api_key = os.getenv("DEEPINFRA_API_KEY")
if not api_key:
raise ValueError("DEEPINFRA_API_KEY موجود نیست")
# connect: 10s | read: بقیه timeout
connect_timeout = 10
read_timeout = max(timeout - connect_timeout, 30)
resp = requests.post(
ANON_API_URL,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json={
"model": ANON_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt}
],
"max_tokens": max_tokens,
"temperature": 0.3,
"top_p": 0.9,
},
timeout=(connect_timeout, read_timeout)
)
if resp.status_code != 200:
raise Exception(f"DeepInfra {resp.status_code}: {resp.text[:300]}")
content = resp.json()["choices"][0]["message"]["content"]
if "<think>" in content or "<thinking>" in content:
thinking = re.search(r"<think(?:ing)?>(.*?)</think(?:ing)?>", content, re.DOTALL)
if thinking:
logger.info(f"🧠 Thinking ({len(thinking.group(1))} chars)...")
return strip_thinking(content)
# ─────────────────────────────────────────────────────────────
# تخمین هوشمند max_tokens و Chunking
# ─────────────────────────────────────────────────────────────
# آستانههای chunking
CHUNK_MAX_CHARS = 900 # حداکثر کاراکتر هر chunk
CHUNK_MAX_ENTITIES = 12 # حداکثر موجودیت تخمینی هر chunk
LONG_TEXT_THRESHOLD = 1200 # بالای این → chunking فعال
# ── حالت batch سریع ──
# True = CSV batch: retry=1، timeout=45s، بدون verification pass → سریع
# False = UI تک متن: retry=2، timeout=60/90s، با verification pass → دقیق
BATCH_FAST_MODE = False
def estimate_entity_count(text: str) -> int:
"""تخمین تعداد موجودیتها بر اساس نشانههای متن"""
markers = 0
# شرکتها
markers += len(re.findall(r'(?:شرکت|بانک|سازمان|گروه|هلدینگ|صندوق|بیمه|پتروشیمی|سرمایه\s*گذاری|وزارت|اداره\s+کل|دادگاه|دادستانی|ستاد)\s+\S', text))
# اشخاص — با عنوان
markers += len(re.findall(r'(?:آقای|خانم|دکتر|مهندس|حجت\s*الاسلام)\s+', text))
# اشخاص — نامخانوادگی با پسوند رایج فارسی
markers += len(re.findall(r'[ء-ی]{2,12}\s+[ء-ی]{2,12}(?:زاده|پور|نژاد|فر|مند|خواه|پناه|وند|دوست|یان|لو)(?![ء-ی])', text))
# مبالغ عددی
markers += len(re.findall(r'[\d۰-۹][,،\d۰-۹]*(?:\.\d+)?\s*(?:میلیارد|میلیون|هزار|همت|تومان|ریال|دلار|یورو)', text))
markers += len(re.findall(r'[\d۰-۹][,،\d۰-۹]*\s*(?:نفر|تن|دستگاه|واحد|فقره)', text))
# درصدها
markers += len(re.findall(r'[\d۰-۹]+(?:\.\d+)?\s*(?:درصد|%|٪)', text))
# اسناد حقوقی: کد ملی، پلاک، بلوک، ناحیه
markers += len(re.findall(r'کد\s+ملی\s+[\d۰-۹]', text))
markers += len(re.findall(r'(?:پلاک|بلوک|ناحیه|فاز|طبقه|واحد)\s+[\d۰-۹]', text))
return max(markers, 1)
def estimate_max_tokens(text: str) -> int:
"""محاسبه max_tokens بر اساس طول متن و تعداد موجودیت تخمینی"""
entity_est = estimate_entity_count(text)
text_len = len(text)
# thinking ≈ 1500-3000 tok + JSON output ≈ text_len*0.8 + mapping ≈ entity*80
base_thinking = 2500
json_output = int(text_len * 0.7)
mapping_space = entity_est * 100
buffer = 1500
tokens = base_thinking + json_output + mapping_space + buffer
# حداقل 6000، حداکثر 16000
return max(6000, min(tokens, 16000))
def split_text_to_chunks(text: str, max_chars: int = CHUNK_MAX_CHARS) -> List[str]:
"""
تقسیم متن به chunkهای کوچکتر بر اساس پاراگراف و جمله
"""
# اول تقسیم بر اساس خط جدید
paragraphs = re.split(r'\n+', text)
paragraphs = [p.strip() for p in paragraphs if p.strip()]
# اگر فقط یک پاراگراف بلند داریم، بر اساس نقطه تقسیم کن
if len(paragraphs) == 1 and len(paragraphs[0]) > max_chars:
sentences = re.split(r'(?<=[.،؛!؟\n])\s+', paragraphs[0])
paragraphs = [s.strip() for s in sentences if s.strip()]
chunks = []
current_chunk = ""
for para in paragraphs:
# اگه پاراگراف خودش بلندتر از حد هست
if len(para) > max_chars:
if current_chunk:
chunks.append(current_chunk.strip())
current_chunk = ""
# تقسیم بر اساس جمله
sents = re.split(r'(?<=[.،؛!؟])\s+', para)
sub_chunk = ""
for s in sents:
if len(sub_chunk) + len(s) + 1 <= max_chars:
sub_chunk += (" " if sub_chunk else "") + s
else:
if sub_chunk:
chunks.append(sub_chunk.strip())
sub_chunk = s
if sub_chunk:
chunks.append(sub_chunk.strip())
elif len(current_chunk) + len(para) + 1 <= max_chars:
current_chunk += ("\n" if current_chunk else "") + para
else:
if current_chunk:
chunks.append(current_chunk.strip())
current_chunk = para
if current_chunk:
chunks.append(current_chunk.strip())
# اگر هنوز خالیه، کل متن رو برگردون
if not chunks:
chunks = [text]
return chunks
def merge_chunk_mappings(
chunk_results: List[Tuple[str, Dict]],
entities: List[str]
) -> Tuple[str, Dict]:
"""
ادغام نتایج chunkها + بازشمارهگذاری توکنها به صورت سراسری
"""
merged_text = ""
merged_mapping = {}
global_counters = {e: 0 for e in entities}
for chunk_text, chunk_mapping in chunk_results:
if not chunk_mapping:
merged_text += ("\n" if merged_text else "") + chunk_text
continue
# نگاشت توکنهای محلی chunk به شماره سراسری
local_to_global = {}
# مرتبسازی بر اساس ترتیب ظاهر شدن در متن
sorted_tokens = sorted(
chunk_mapping.keys(),
key=lambda t: (t.split("-")[0], int(t.split("-")[1]))
)
for local_token in sorted_tokens:
original_value = chunk_mapping[local_token]
etype = local_token.split("-")[0]
# بررسی آیا این مقدار قبلاً در mapping سراسری هست
existing_token = None
for g_token, g_value in merged_mapping.items():
if g_value == original_value and g_token.startswith(etype):
existing_token = g_token
break
if existing_token:
local_to_global[local_token] = existing_token
else:
global_counters[etype] += 1
new_token = f"{etype}-{global_counters[etype]:02d}"
local_to_global[local_token] = new_token
merged_mapping[new_token] = original_value
# جایگزینی توکنهای محلی با سراسری در متن chunk
renamed_text = chunk_text
# از بلندترین شروع کن تا تداخل نباشه
for local_token in sorted(local_to_global.keys(), key=len, reverse=True):
global_token = local_to_global[local_token]
if local_token != global_token:
renamed_text = renamed_text.replace(local_token, global_token)
merged_text += ("\n" if merged_text else "") + renamed_text
return merged_text, merged_mapping
# ─────────────────────────────────────────────────────────────
# کلاس اصلی
# ─────────────────────────────────────────────────────────────
class AnonymizerAdvanced:
def __init__(
self,
llm_provider: str = "chatgpt",
llm_model: str = None,
entities_to_anonymize: List[str] = None
):
self.llm_provider = llm_provider
self.llm_model = llm_model
self.entities_to_anonymize = entities_to_anonymize or ["person", "company", "amount", "percent"]
self.mapping_table: Dict[str, str] = {}
self.reverse_mapping: Dict[str, str] = {}
self._create_llm_sender()
logger.info(f"✅ Anonymizer — {llm_provider}")
# ── LLM sender (تحلیل) ──────────────────────────────────
def _create_llm_sender(self):
try:
key_map = {
"chatgpt": os.getenv("OPENAI_API_KEY"),
"grok": os.getenv("XAI_API_KEY"),
"deepinfra": os.getenv("DEEPINFRA_API_KEY"),
}
self.llm_sender = create_llm_sender(
provider=self.llm_provider,
api_key=key_map.get(self.llm_provider),
model=self.llm_model
)
logger.info(f"✅ LLM Sender: {self.llm_provider} — {self.llm_sender.model}")
except Exception as e:
logger.error(f"❌ LLM Sender خطا: {e}")
self.llm_sender = create_llm_sender("chatgpt")
def set_llm_provider(self, provider: str, model: str = None, entities: List[str] = None):
self.llm_provider = provider
self.llm_model = model
if entities is not None:
self.entities_to_anonymize = entities
self._create_llm_sender()
# ── ناشناسسازی — thinking فعال، retry + chunking + verify ──
def anonymize(self, text: str) -> Tuple[str, Dict]:
"""
ناشناسسازی هوشمند:
1. متن کوتاه → single call
2. متن طولانی → chunking
3. هر call با retry
4. پاس تأیید (verification) برای پیدا کردن موجودیتهای جامانده
"""
if not self.entities_to_anonymize:
return text, {}
text_len = len(text)
entity_est = estimate_entity_count(text)
logger.info(f"🧠 ورودی: {text_len} char | ~{entity_est} موجودیت تخمینی")
# ── مرحله ۱: ناشناسسازی اولیه ──
needs_chunking = (
text_len > LONG_TEXT_THRESHOLD or
entity_est > CHUNK_MAX_ENTITIES
)
if needs_chunking:
anon_text, mapping = self._anonymize_chunked(text)
else:
anon_text, mapping = self._anonymize_single(text)
# ── مرحله ۲: پاس تأیید LLM ──
if anon_text and anon_text.strip() != text.strip():
anon_text, mapping = self._verification_pass(text, anon_text, mapping)
# ── مرحله ۳: Hybrid Rule-Based Pass ──
if anon_text and anon_text.strip() != text.strip():
anon_text, mapping = self._hybrid_rule_based_pass(text, anon_text)
# ── مرحله ۴: رفع توکن چسبیده ──
anon_text = self._fix_stuck_tokens(anon_text)
return anon_text, mapping
def _verification_pass(
self,
original_text: str,
anonymized_text: str,
current_mapping: Dict[str, str]
) -> Tuple[str, Dict]:
"""
پاس تأیید محافظهکارانه:
فقط وقتی اجرا شو که شواهد قوی از miss وجود داره
"""
found_count = len(current_mapping)
entity_est = estimate_entity_count(original_text)
# ── تصمیم: آیا verify لازمه؟ ──
gap = entity_est - found_count
has_name_entities = any(e in self.entities_to_anonymize for e in ["company", "person"])
if BATCH_FAST_MODE:
# batch mode: فقط برای company/person با gap بزرگ
needs_verify = has_name_entities and found_count > 0 and gap >= 2
else:
# UI mode: gap >= 1 کافیه
needs_verify = found_count > 0 and gap >= 1
if not needs_verify:
logger.info(f" ⏭️ Verify skip: {found_count} found, ~{entity_est} est, gap={gap}")
return anonymized_text, current_mapping
logger.info(f" 🔍 Verification Pass: {found_count} یافته، ~{entity_est} تخمینی، gap={gap}")
try:
prompt = build_verification_prompt(
original_text, anonymized_text,
current_mapping, self.entities_to_anonymize
)
raw = post_deepinfra(
prompt,
"You are a conservative verification agent. Only flag entities you are VERY SURE were missed. When in doubt, do NOT flag. Output ONLY valid JSON.",
max_tokens=estimate_max_tokens(original_text),
timeout=60
)
result = parse_json_response(raw)
fixed_text = result.get("fixed_text", "")
new_mapping = result.get("new_mapping", {})
# اگه مدل گفت هیچی جا نمونده
if not fixed_text or not new_mapping:
logger.info(f" ✅ Verify: هیچ موجودیت جاماندهای یافت نشد")
return anonymized_text, current_mapping
# ── اعتبارسنجی سختگیرانه ──
# ۱) توکنهای قبلی نباید حذف شن
old_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', anonymized_text))
new_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', fixed_text))
lost_tokens = old_tokens - new_tokens
if lost_tokens:
logger.warning(f" ⚠️ Verify reject: {len(lost_tokens)} توکن قبلی حذف شده: {lost_tokens}")
return anonymized_text, current_mapping
# ۲) تعداد توکنهای جدید نباید خیلی زیاد باشه (حداکثر ۲ برابر gap)
added_tokens = new_tokens - old_tokens
max_allowed_new = min(gap * 2, 10)
if len(added_tokens) > max_allowed_new:
logger.warning(f" ⚠️ Verify reject: {len(added_tokens)} توکن جدید > حد مجاز {max_allowed_new}")
return anonymized_text, current_mapping
# ۳) new_mapping فقط شامل توکنهای معتبر باشه
valid_new_mapping = {}
for token, value in new_mapping.items():
if not re.match(r'(company|person|amount|percent)-\d+', token):
continue
if token in current_mapping:
continue # تکراری
if token not in new_tokens:
continue # در متن نیست
# مقدار نباید خیلی کوتاه باشه
if len(str(value).strip()) < 2:
continue
valid_new_mapping[token] = value
if not valid_new_mapping:
logger.info(f" ✅ Verify: mapping معتبر جدیدی نیست")
return anonymized_text, current_mapping
# ادغام mapping
merged_mapping = dict(current_mapping)
merged_mapping.update(valid_new_mapping)
# پاکسازی نهایی
final_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', fixed_text))
merged_mapping = {k: v for k, v in merged_mapping.items() if k in final_tokens}
self.mapping_table = merged_mapping
self.reverse_mapping = {v: k for k, v in merged_mapping.items()}
logger.info(f" ✅ Verify: +{len(valid_new_mapping)} موجودیت | کل: {len(merged_mapping)}")
return fixed_text, merged_mapping
except Exception as e:
logger.warning(f" ⚠️ Verify fail: {e} — نتیجه اولیه حفظ میشود")
return anonymized_text, current_mapping
def _anonymize_single(self, text: str, max_retries: int = 2, is_chunk: bool = False) -> Tuple[str, Dict]:
"""
ناشناسسازی تکتکه با retry سریع
is_chunk=True → بدون fallback به chunking (جلوگیری از حلقه بازگشتی)
"""
base_max_tokens = estimate_max_tokens(text)
# حالت سریع: retry=2 و timeout=90s (افزایش از ۱ و ۴۵ برای کاهش timeout)
if BATCH_FAST_MODE:
max_retries = 2
base_timeout = 90
else:
base_timeout = 60
for attempt in range(max_retries):
current_max_tokens = min(base_max_tokens + (attempt * 1500), 16000)
current_timeout = base_timeout + (attempt * 30) # fast:45s | normal:60s,90s
logger.info(f" 🔄 تلاش {attempt+1}/{max_retries} | max_tokens={current_max_tokens} | timeout={current_timeout}s")
prompt = build_single_call_prompt(text, self.entities_to_anonymize)
try:
raw = post_deepinfra(
prompt, ANON_SYSTEM_PROMPT,
max_tokens=current_max_tokens,
timeout=current_timeout
)
logger.info(f" ✅ پاسخ: {len(raw)} کاراکتر")
result = parse_json_response(raw)
anonymized_text = result.get("anonymized", "")
self.mapping_table = result.get("mapping", {})
# ── بررسی کیفیت: آیا واقعاً ناشناس شده؟ ──
if anonymized_text.strip() == text.strip():
logger.warning(f" ⚠️ متن تغییر نکرده! (attempt {attempt+1})")
if attempt < max_retries - 1:
time.sleep(0.5)
continue
# آخرین تلاش fail شد
if not is_chunk:
logger.warning(" ⚠️ retry fail → chunking")
return self._anonymize_chunked(text)
else:
return text, {} # در حالت chunk: متن اصلی برگرده
# آیا mapping خالی هست ولی توکن در متن هست؟
tokens_in_text = re.findall(r'(?:company|person|amount|percent)-\d+', anonymized_text)
if tokens_in_text and not self.mapping_table:
logger.warning(f" ⚠️ mapping خالی ولی {len(tokens_in_text)} توکن در متن!")
if attempt < max_retries - 1:
time.sleep(0.5)
continue
self._clean_orphan_tokens(anonymized_text)
self._fix_mapping()
anonymized_text = self._fix_stuck_tokens(anonymized_text)
self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
for etype in self.entities_to_anonymize:
found = sorted(set(re.findall(rf'{etype}-\d+', anonymized_text)))
if found:
logger.info(f" {etype}: {found}")
logger.info(f" ✅ mapping: {len(self.mapping_table)} موجودیت")
return anonymized_text, self.mapping_table
except json.JSONDecodeError as e:
logger.warning(f" ⚠️ JSON خطا (attempt {attempt+1}): {e}")
if attempt < max_retries - 1:
time.sleep(0.5)
continue
# آخرین تلاش: fallback
if not is_chunk:
logger.info(" 🔄 fallback دو-call...")
try:
return self._anonymize_fallback(text)
except Exception:
return self._anonymize_chunked(text)
return text, {}
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
logger.warning(f" ⏳ اتصال/Timeout (attempt {attempt+1}): {type(e).__name__}")
if attempt < max_retries - 1:
time.sleep(1)
continue
# timeout → chunking فقط اگه chunk نیستیم
if not is_chunk:
return self._anonymize_chunked(text)
return text, {}
except Exception as e:
logger.error(f" ❌ Exception (attempt {attempt+1}): {e}")
if attempt < max_retries - 1:
time.sleep(0.5)
continue
if is_chunk:
return text, {}
raise
# اگه هیچکدوم جواب نداد
if not is_chunk:
logger.warning(" ⚠️ همه retryها fail → chunking")
return self._anonymize_chunked(text)
return text, {}
def _anonymize_chunked(self, text: str) -> Tuple[str, Dict]:
"""
تقسیم متن به chunk و ناشناسسازی جداگانه + ادغام
هر chunk فقط ۱ retry و بدون بازگشت به chunking
"""
chunks = split_text_to_chunks(text, max_chars=CHUNK_MAX_CHARS)
logger.info(f" 📦 Chunking: {len(chunks)} قطعه از {len(text)} کاراکتر")
chunk_results = []
for i, chunk in enumerate(chunks):
logger.info(f" 📦 Chunk {i+1}/{len(chunks)}: {len(chunk)} char")
# ریست mapping برای هر chunk
self.mapping_table = {}
self.reverse_mapping = {}
try:
# is_chunk=True → بدون fallback بازگشتی به chunking
anon_chunk, mapping = self._anonymize_single(chunk, max_retries=2, is_chunk=True)
chunk_results.append((anon_chunk, mapping))
except Exception as e:
logger.error(f" ❌ Chunk {i+1} fail: {e} — متن اصلی حفظ میشود")
chunk_results.append((chunk, {}))
time.sleep(0.3)
# ادغام chunkها
merged_text, merged_mapping = merge_chunk_mappings(
chunk_results, self.entities_to_anonymize
)
self.mapping_table = merged_mapping
self.reverse_mapping = {v: k for k, v in merged_mapping.items()}
logger.info(f" ✅ Chunked: {len(merged_mapping)} موجودیت از {len(chunks)} chunk")
return merged_text, merged_mapping
def _anonymize_fallback(self, text: str) -> Tuple[str, Dict]:
"""Fallback: دو call — اگر JSON parse شکست خورد"""
logger.info("🔄 fallback: دو call...")
rules_fa = (
"متن زیر را ناشناس کن.\n"
"- company-XX: نام کامل سازمان (بانک/شرکت/بیمه/پتروشیمی/...) — نام مختصر = همان توکن\n"
"- person-XX: نام کامل اشخاص\n"
"- amount-XX: عدد + واحد با هم\n"
"- percent-XX: عدد + درصد با هم (بازه هم یک توکن)\n"
"کلمات عمومی را دست نزن. فقط متن ناشناس شده."
)
prompt1 = f"{FEW_SHOT_EXAMPLES}\n{rules_fa}\n\nمتن:\n{text}"
anonymized_text = post_deepinfra(prompt1, ANON_SYSTEM_PROMPT, max_tokens=4096, timeout=60)
hints = []
if "person" in self.entities_to_anonymize: hints.append('"person-XX": "نام کامل"')
if "company" in self.entities_to_anonymize: hints.append('"company-XX": "نام کامل سازمان"')
if "amount" in self.entities_to_anonymize: hints.append('"amount-XX": "عدد+واحد"')
if "percent" in self.entities_to_anonymize: hints.append('"percent-XX": "عدد+درصد"')
prompt2 = (
f"متن اصلی: {text}\n"
f"متن ناشناس: {anonymized_text}\n\n"
f"فقط JSON mapping:\n{{ {', '.join(hints)} }}"
)
try:
raw2 = post_deepinfra(prompt2, "Output ONLY valid JSON. No explanation.", max_tokens=2048, timeout=45)
self.mapping_table = parse_json_response(raw2)
except Exception:
self._extract_mapping_fallback(text, anonymized_text)
self._clean_orphan_tokens(anonymized_text)
self._fix_mapping()
self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
return anonymized_text, self.mapping_table
# ── پاکسازی mapping ────────────────────────────────────
def _clean_orphan_tokens(self, anonymized_text: str):
to_remove = [t for t in self.mapping_table if t not in anonymized_text]
for t in to_remove:
logger.info(f" 🗑️ توکن اضافی: {t}")
del self.mapping_table[t]
def _fix_mapping(self):
"""اطمینان از صحت مقادیر — فقط percent بدون واحد"""
for token, value in list(self.mapping_table.items()):
val = str(value).strip()
if token.startswith("percent-") and not re.search(r"(درصد|%|٪|درصدی)", val):
self.mapping_table[token] = f"{val} درصد"
logger.info(f" اصلاح {token}: '{val}' → '{val} درصد'")
def _fix_stuck_tokens(self, text: str) -> str:
"""
رفع توکن چسبیده به حروف فارسی
مثال: «رperson-01» → «person-01» (کاراکتر «ر» قبل از توکن حذف میشود)
"""
# حرف فارسی بلافاصله قبل از توکن (بدون فاصله)
cleaned = re.sub(
r'([ء-ی\u200c]+)((?:company|person|amount|percent)-\d+)',
r' \2',
text
)
# توکن بلافاصله قبل از حرف فارسی (بدون فاصله)
cleaned = re.sub(
r'((?:company|person|amount|percent)-\d+)([ء-ی\u200c]+)',
r'\1 \2',
cleaned
)
# پاکسازی فاصله مضاعف
cleaned = re.sub(r' +', ' ', cleaned).strip()
if cleaned != text:
logger.info(f" 🔧 Fixed stuck tokens in text")
return cleaned
def _hybrid_rule_based_pass(self, original_text: str, anonymized_text: str) -> Tuple[str, Dict]:
"""
لایه Hybrid: شناسایی فعال موجودیتهای جامانده با Regex
اجرا بعد از LLM — فقط متن اصلی را اسکن میکند، نه ناشناسشده را
هیچ lookbehind متغیر ندارد (رفع خطای fixed-width)
"""
result_text = anonymized_text
counters = {}
for etype in self.entities_to_anonymize:
nums = [int(m) for m in re.findall(rf'{etype}-(\d+)', anonymized_text)]
counters[etype] = max(nums) + 1 if nums else 1
# تابع کمکی: جایگزینی ایمن در متن ناشناسشده
def replace_in_result(entity_text: str, token: str):
nonlocal result_text
# فقط اگه هنوز در متن ناشناسشده موجود است (توکن نشده)
if entity_text in result_text and not re.search(
r'(?:company|person|amount|percent)-\d+', entity_text
):
result_text = result_text.replace(entity_text, token, 1)
return True
return False
# ── تشخیص company با پیشوند + نام خاص حداقل ۳ کاراکتر ──
if "company" in self.entities_to_anonymize:
# الگو: پیشوند + فاصله + نام خاص (حداقل یک کلمه ۳+ کاراکتر)
# از lookbehind استفاده نمیکنیم — به جای آن group capture داریم
company_patterns = [
# بانک + نام مشخص (لیست ثابت)
r'(بانک\s+(?:ملت|پارسیان|پاسارگاد|ملی\s+ایران|مرکزی|صادرات|تجارت|رفاه|مسکن|کشاورزی|سپه|آینده|اقتصاد\s+نوین|کارآفرین|دی|ایران\s+زمین|سینا|شهر|سامان|خاورمیانه))',
# وزارت + نام مشخص (لیست ثابت)
r'(وزارت\s+(?:نفت|اقتصاد|صمت|بهداشت|آموزش|دفاع|کشور|امور\s+خارجه|راه|نیرو|ارتباطات|صنعت))',
# سازمان/اداره کل/دادستانی + نام خاص (حداقل ۵ کاراکتر بعد)
r'((?:سازمان|اداره\s+کل)\s+[ء-ی\u200c]{3,}(?:\s+[ء-ی\u200c]{2,}){0,3})',
r'(دادستانی\s+(?:کل\s+کشور|استان\s+[ء-ی\u200c]+|عمومی\s+و\s+انقلاب\s+[ء-ی\u200c]+))',
r'(دادگاه\s+(?:تجدیدنظر|انقلاب|عمومی)\s+[ء-ی\u200c]+(?:\s+[ء-ی\u200c]+)?)',
# شرکت/گروه/هلدینگ/پتروشیمی + نام خاص (حداقل ۴ کاراکتر)
r'((?:شرکت|گروه|هلدینگ|پتروشیمی|بیمه)\s+(?!از\s|در\s|با\s|به\s|که\s|این\s|آن\s|صفر\s|کند\s|محل\s|نماد\s|بخش\s)[ء-ی\u200c]{3,}(?:\s+[ء-ی\u200c]{2,}){0,2})',
# ستاد/بنیاد/نهاد + نام خاص
r'((?:ستاد|بنیاد)\s+[ء-ی\u200c]{4,}(?:\s+[ء-ی\u200c]{2,}){0,3})',
]
for pattern in company_patterns:
for match in re.finditer(pattern, original_text):
entity_text = match.group(1).strip()
if len(entity_text) < 5:
continue
# چک: آیا قبلاً در mapping داریم؟
existing = next((t for t, v in self.mapping_table.items()
if v == entity_text and t.startswith('company')), None)
if existing:
replace_in_result(entity_text, existing)
else:
token = f"company-{counters['company']:02d}"
counters['company'] += 1
if replace_in_result(entity_text, token):
self.mapping_table[token] = entity_text
logger.info(f" 🔧 Hybrid company: '{entity_text}' → {token}")
# ── تشخیص person — فقط الگوهای با دقت بالا ──
# ⚠️ فقط اگه متن اصلی نشانه حقوقی/رسمی داشته باشد یا عنوان صریح قبل نام
is_legal_doc = bool(re.search(
r'(?:شاکی|متهم|کد\s+ملی|فرزند\s+|نمایندگی|دادسرا|دادنامه|ابلاغ)', original_text
))
if "person" in self.entities_to_anonymize:
person_patterns = [
# عنوان + نام + نامخانوادگی (۲ یا ۳ کلمه) — همیشه فعال
r'((?:دکتر|مهندس|آقای|خانم|حجت[\u200c\s]الاسلام|سید)\s+[ء-ی\u200c]{2,12}\s+[ء-ی\u200c]{3,20}(?:\s+[ء-ی\u200c]{3,15})?)',
# نامهای مرکب شناختهشده — همیشه فعال
r'((?:امیرحسین|محمدرضا|محمدعلی|علیرضا|حمیدرضا)\s+[ء-ی\u200c]{3,20})',
]
# پسوند-محور فقط در اسناد حقوقی (کاهش FP در متون مالی/اقتصادی)
if is_legal_doc:
person_patterns.append(
r'([ء-ی\u200c]{2,12}\s+[ء-ی\u200c]{2,12}(?:زاده|پور|نژاد|مند|خواه|پناه|وند|دوست|یان|لو|بیگی|قلی)(?:ان)?(?![ء-ی\u200c]))'
)
for pattern in person_patterns:
for match in re.finditer(pattern, original_text):
entity_text = match.group(1).strip()
words = entity_text.split()
# حداقل ۲ کلمه، و کلمه آخر حداقل ۳ کاراکتر
if len(words) < 2 or len(words[-1]) < 3:
continue
# رد false positiveهای رایج
false_positives = {
'تا پایان', 'در پایان', 'در جریان', 'تا اطلاع',
'در نهایت', 'در واقع', 'به دلیل', 'این پرونده',
'در پرونده', 'با پرونده', 'عادی سالیانه',
'فوق العاده', 'فوقالعاده', 'هر سهم',
'سال مالی', 'سود خالص', 'زیان خالص',
'مدیره سالیانه', 'مدیره شرکت', 'عمومی عادی',
'انباشته دوره', 'عملیاتی شرکت',
}
if entity_text in false_positives:
continue
existing = next((t for t, v in self.mapping_table.items()
if v == entity_text and t.startswith('person')), None)
if existing:
replace_in_result(entity_text, existing)
else:
token = f"person-{counters['person']:02d}"
counters['person'] += 1
if replace_in_result(entity_text, token):
self.mapping_table[token] = entity_text
logger.info(f" 🔧 Hybrid person: '{entity_text}' → {token}")
self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
added = len(self.mapping_table) - (len(self.mapping_table) - sum(
1 for t in self.mapping_table if t not in anonymized_text or
self.mapping_table[t] not in original_text
))
return result_text, self.mapping_table
# ── fallback mapping با regex ────────────────────────────
def _extract_mapping_fallback(self, original: str, anonymized: str):
pats: Dict[str, str] = {}
if "person" in self.entities_to_anonymize:
pats["person"] = r'(?<![ء-یa-zA-Z])[ء-ی]+\s+[ء-ی]+(?:\s+[ء-ی]+)*(?![ء-یa-zA-Z])'
if "company" in self.entities_to_anonymize:
pats["company"] = (
r'(?:(?:شرکت|بانک|سازمان|گروه|هلدینگ|صندوق|بیمه|پتروشیمی|ملی|سرمایه\s*گذاری)\s+)'
r'[ء-ی][ء-ی\s]+(?:\([ء-یa-zA-Z۰-۹]+\))?'
)
if "amount" in self.entities_to_anonymize:
pats["amount"] = r'[\d۰-۹][,،\d۰-۹]*(?:\.\d+)?\s*(?:هزار\s+و\s+\d+|هزار|میلیون|میلیارد|همت)?\s*(?:میلیارد|میلیون|هزار|تومان|ریال|دلار|دستگاه|تن)?'
if "percent" in self.entities_to_anonymize:
pats["percent"] = r'[\d۰-۹]+(?:\.\d+)?\s*(?:الی|تا)?\s*(?:[\d۰-۹]+(?:\.\d+)?\s*)?(?:درصد|%|٪|درصدی)'
orig_entities = {
etype: [m.strip() for m in re.findall(pat, original) if m.strip()]
for etype, pat in pats.items()
}
for etype in self.entities_to_anonymize:
tokens = sorted(set(re.findall(rf'{etype}-(\d+)', anonymized)), key=int)
values = orig_entities.get(etype, [])
for tok_num in tokens:
token = f"{etype}-{tok_num}"
idx = int(tok_num) - 1
self.mapping_table[token] = values[idx] if idx < len(values) else (values[-1] if values else token)
self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
logger.info(f"✅ Fallback mapping: {len(self.mapping_table)} موجودیت")
# ── تحلیل LLM ───────────────────────────────────────────
def analyze_with_llm(self, anonymized_text: str, analysis_prompt: str = None) -> str:
logger.info(f"🤖 {self.llm_provider.upper()} تحلیل...")
if not analysis_prompt or not analysis_prompt.strip():
return "⚠️ هیچ دستور تحلیل داده نشده است"
prompt = build_analysis_prompt(anonymized_text, analysis_prompt, self.entities_to_anonymize)
try:
response = self.llm_sender.send(prompt, lang="fa", temperature=0.2, max_tokens=2000)
logger.info(f"✅ {self.llm_provider.upper()}: {len(response)} کاراکتر")
return response
except Exception as e:
logger.error(f"❌ {self.llm_provider.upper()} Exception: {e}")
return f"❌ خطا: {str(e)}"
# ── بازگردانی ────────────────────────────────────────────
def restore_text(self, anonymized_text: str) -> str:
logger.info("🔄 بازگردانی...")
if not self.mapping_table:
return anonymized_text
restored = self._normalize_tokens(anonymized_text)
count = 0
for placeholder, original in sorted(
self.mapping_table.items(), key=lambda x: len(x[0]), reverse=True
):
if placeholder in restored:
restored = restored.replace(placeholder, original)
count += 1
logger.info(f" ✅ {placeholder} → {original[:40]}")
else:
logger.warning(f" ⚠️ {placeholder} یافت نشد")
logger.info(f"✅ {count}/{len(self.mapping_table)} بازگردانی شد")
if count < len(self.mapping_table):
restored = self._restore_with_regex(restored)
return restored
def _normalize_tokens(self, text: str) -> str:
normalized = text
unicode_hyphens = r'[\u2010\u2011\u2012\u2013\u2014\u2212]'
for etype in self.entities_to_anonymize:
normalized = re.sub(rf'{etype}{unicode_hyphens}(\d+)', rf'{etype}-\1', normalized)
normalized = re.sub(rf'{etype}\s+-\s+(\d+)', rf'{etype}-\1', normalized)
normalized = re.sub(rf'({etype}-\d+)([ء-ی])', r'\1 \2', normalized)
normalized = re.sub(rf'({etype}-\d+)([،؛:.!?])', r'\1 \2', normalized)
return normalized
def _restore_with_regex(self, text: str) -> str:
restored = text
for placeholder, original in self.mapping_table.items():
if placeholder not in restored:
continue
etype, num = placeholder.split("-")
if re.search(rf'{etype}\s*-\s*{num}', restored):
restored = re.sub(rf'{etype}\s*-\s*{num}', original, restored)
logger.info(f" ✅ regex: {placeholder} → {original[:40]}")
return restored
def get_mapping_table_md(self) -> str:
if not self.mapping_table:
return "### 📋 جدول نگاشت\n\nهیچ موجودیتی شناسایی نشد"
table = "### 📋 جدول نگاشت\n\n| شناسه | متن اصلی |\n|-------|----------|\n"
for token, original in sorted(self.mapping_table.items()):
table += f"| **{token}** | {original} |\n"
return table
# ─────────────────────────────────────────────────────────────
# متغیر سراسری
# ─────────────────────────────────────────────────────────────
anonymizer: Optional[AnonymizerAdvanced] = None
# ─────────────────────────────────────────────────────────────
# تابع اصلی
# ─────────────────────────────────────────────────────────────
def process(
input_text: str,
analysis_prompt: str,
llm_provider: str,
llm_model: str,
anonymize_all: bool,
anonymize_person: bool,
anonymize_company: bool,
anonymize_amount: bool,
anonymize_percent: bool
):
global BATCH_FAST_MODE
BATCH_FAST_MODE = False
global anonymizer
if not input_text.strip():
return "", "", "", ""
entities = ["person", "company", "amount", "percent"] if anonymize_all else [
e for e, flag in [
("person", anonymize_person),
("company", anonymize_company),
("amount", anonymize_amount),
("percent", anonymize_percent),
] if flag
]
if not entities:
return "", "❌ لطفاً حداقل یک موجودیت انتخاب کنید", "", ""
if not anonymizer:
anonymizer = AnonymizerAdvanced(
llm_provider=llm_provider,
llm_model=llm_model,
entities_to_anonymize=entities
)
else:
anonymizer.set_llm_provider(llm_provider, llm_model, entities)
anonymizer.mapping_table = {}
anonymizer.reverse_mapping = {}
try:
logger.info("=" * 60)
logger.info(f"🧠 Qwen3-14B (thinking ON | single-call)")
logger.info(f"🤖 تحلیل: {llm_provider} ({llm_model})")
logger.info(f"🎯 موجودیتها: {entities}")
logger.info("=" * 60)
anon_text, _ = anonymizer.anonymize(input_text)
has_analysis = bool(analysis_prompt and analysis_prompt.strip())
llm_response = anonymizer.analyze_with_llm(anon_text, analysis_prompt) if has_analysis \
else "⚠️ هیچ دستور تحلیل داده نشده است"
source = llm_response if has_analysis else anon_text
restored = anonymizer.restore_text(source)
return restored, llm_response, anon_text, anonymizer.get_mapping_table_md()
except Exception as e:
logger.error(f"❌ خطا: {e}", exc_info=True)
return "", f"❌ خطا: {str(e)}", "", ""
def clear_all():
return "", "", "", "", "", "", True, False, False, False, False
# ─────────────────────────────────────────────────────────────
# توابع CSV — پردازش دستهای
# ─────────────────────────────────────────────────────────────
def read_csv_columns(file):
"""خواندن نام ستونها و تعداد ردیفهای CSV"""
if file is None:
return gr.update(choices=[], value=[]), "⚠️ فایلی آپلود نشده", gr.update(value=1), gr.update(value=1, maximum=1)
file_path = file if isinstance(file, str) else file.name
try:
with open(file_path, "r", encoding="utf-8-sig") as f:
reader = csv.reader(f)
headers = next(reader)
headers = [h.strip() for h in headers if h.strip()]
row_count = sum(1 for _ in reader)
if not headers:
return gr.update(choices=[], value=[]), "❌ ستونی یافت نشد", gr.update(value=1), gr.update(value=1, maximum=1)
return (
gr.update(choices=headers, value=headers),
f"✅ {len(headers)} ستون | {row_count} ردیف — ستونها: {' | '.join(headers)}",
gr.update(value=1, minimum=1, maximum=row_count),
gr.update(value=min(row_count, 50), minimum=1, maximum=row_count),
)
except Exception as e:
return gr.update(choices=[], value=[]), f"❌ خطا: {str(e)}", gr.update(value=1), gr.update(value=1, maximum=1)
def process_csv(
file,
selected_columns: list,
csv_anonymize_all: bool,
csv_anonymize_person: bool,
csv_anonymize_company: bool,
csv_anonymize_amount: bool,
csv_anonymize_percent: bool,
row_start: int,
row_end: int,
batch_size: int,
delay_between_batches: float,
progress=gr.Progress()
):
"""ناشناسسازی دستهای ستونهای انتخابشده فایل CSV"""
global BATCH_FAST_MODE
BATCH_FAST_MODE = True
if file is None:
return None, "❌ فایلی آپلود نشده", ""
file_path = file if isinstance(file, str) else file.name
if not selected_columns:
return None, "❌ حداقل یک ستون انتخاب کنید", ""
# تعیین موجودیتها
entities = ["person", "company", "amount", "percent"] if csv_anonymize_all else [
e for e, flag in [
("person", csv_anonymize_person),
("company", csv_anonymize_company),
("amount", csv_anonymize_amount),
("percent", csv_anonymize_percent),
] if flag
]
if not entities:
return None, "❌ حداقل یک نوع موجودیت انتخاب کنید", ""
try:
# خواندن CSV
with open(file_path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
headers = reader.fieldnames
all_rows = list(reader)
total_rows = len(all_rows)
if total_rows == 0:
return None, "❌ فایل خالی است", ""
# تنظیم بازه ردیفها (1-indexed → 0-indexed)
start_idx = max(0, int(row_start) - 1)
end_idx = min(total_rows, int(row_end))
if start_idx >= end_idx:
return None, f"❌ بازه نامعتبر: ردیف {row_start} تا {row_end} (کل: {total_rows})", ""
selected_rows = all_rows[start_idx:end_idx]
num_selected = len(selected_rows)
batch_sz = max(1, int(batch_size))
logger.info("=" * 60)
logger.info(f"📄 CSV: {total_rows} ردیف کل | بازه: {start_idx+1}-{end_idx} ({num_selected} ردیف)")
logger.info(f"📦 دسته: {batch_sz} ردیف | ستونها: {selected_columns}")
logger.info(f"🎯 موجودیتها: {entities}")
logger.info("=" * 60)
# ساخت anonymizer
csv_anon = AnonymizerAdvanced(
llm_provider="deepinfra",
llm_model=ANON_MODEL,
entities_to_anonymize=entities
)
# ساخت ستونهای جدید برای خروجی ناشناسشده
# هر ستون انتخابی → ستون جدید با پسوند _Anonymized
anon_col_map = {}
output_headers = list(headers)
for col in selected_columns:
if len(selected_columns) == 1:
new_col = "Anonymization_text"
else:
new_col = f"{col}_Anonymized"
anon_col_map[col] = new_col
if new_col not in output_headers:
output_headers.append(new_col)
logger.info(f"📝 ستونهای خروجی: {anon_col_map}")
# جدول نگاشت کلی
global_mapping = {}
processed_rows = []
errors = []
total_cells = 0
start_time = time.time()
# تعداد دستهها
num_batches = (num_selected + batch_sz - 1) // batch_sz
for batch_num in range(num_batches):
batch_start = batch_num * batch_sz
batch_end = min(batch_start + batch_sz, num_selected)
batch_rows = selected_rows[batch_start:batch_end]
actual_start = start_idx + batch_start + 1
actual_end = start_idx + batch_end
progress(
(batch_num + 1) / num_batches,
desc=f"دسته {batch_num+1}/{num_batches} | ردیف {actual_start}-{actual_end}"
)
logger.info(f"📦 دسته {batch_num+1}/{num_batches}: ردیف {actual_start}-{actual_end}")
for j, row in enumerate(batch_rows):
row_idx = actual_start + j
new_row = dict(row)
# مقدار پیشفرض خالی برای ستونهای جدید
for col in selected_columns:
new_row[anon_col_map[col]] = ""
for col in selected_columns:
if col not in row or not row[col].strip():
new_row[anon_col_map[col]] = row.get(col, "")
continue
cell_text = row[col].strip()
total_cells += 1
# ریست mapping برای هر سلول
csv_anon.mapping_table = {}
csv_anon.reverse_mapping = {}
try:
anon_text, mapping = csv_anon.anonymize(cell_text)
# متن اصلی دستنخورده باقی میماند، ناشناسشده در ستون جدید
new_row[anon_col_map[col]] = anon_text
for token, original in mapping.items():
if token not in global_mapping:
global_mapping[token] = original
logger.info(f" ✅ ردیف {row_idx}, '{col}' → '{anon_col_map[col]}': {len(mapping)} موجودیت")
except Exception as e:
logger.error(f" ❌ ردیف {row_idx}, '{col}': {e}")
errors.append(f"ردیف {row_idx}, ستون '{col}': {str(e)}")
# در صورت خطا متن اصلی در ستون جدید قرار میگیرد
new_row[anon_col_map[col]] = cell_text
# تاخیر کوتاه بین سلولها
time.sleep(0.2)
processed_rows.append(new_row)
# تاخیر بین دستهها
if batch_num < num_batches - 1:
pause = max(0.5, float(delay_between_batches))
logger.info(f" ⏸️ مکث {pause} ثانیه بین دستهها...")
time.sleep(pause)
elapsed = time.time() - start_time
# نوشتن CSV خروجی
output_dir = tempfile.gettempdir()
output_path = os.path.join(output_dir, f"anonymized_{int(time.time())}.csv")
with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=output_headers)
writer.writeheader()
writer.writerows(processed_rows)
# ساخت گزارش
mins, secs = divmod(int(elapsed), 60)
time_str = f"{mins} دقیقه و {secs} ثانیه" if mins > 0 else f"{secs} ثانیه"
avg_time = elapsed / total_cells if total_cells > 0 else 0
anon_cols_str = " | ".join(f"{c} → {anon_col_map[c]}" for c in selected_columns)
status_parts = [
f"✅ **{len(processed_rows)}** ردیف پردازش شد (ردیف {start_idx+1} تا {end_idx} از {total_rows})",
f"📋 **{len(global_mapping)}** موجودیت یکتا ناشناسسازی شد",
f"📦 **{num_batches}** دسته × **{batch_sz}** ردیف",
f"🔢 **{total_cells}** سلول پردازش شد",
f"📝 ستونها: {anon_cols_str}",
f"⏱️ زمان: {time_str} (میانگین {avg_time:.1f} ثانیه/سلول)",
]
if errors:
status_parts.append(f"\n⚠️ **{len(errors)}** خطا:")
for err in errors[:15]:
status_parts.append(f" - {err}")
if len(errors) > 15:
status_parts.append(f" - ... و {len(errors)-15} خطای دیگر")
status = "\n".join(status_parts)
# جدول نگاشت
if global_mapping:
mapping_md = "### 📋 جدول نگاشت کلی\n\n| شناسه | متن اصلی |\n|-------|----------|\n"
for token, original in sorted(global_mapping.items()):
mapping_md += f"| **{token}** | {original} |\n"
else:
mapping_md = "### 📋 جدول نگاشت\n\nهیچ موجودیتی شناسایی نشد"
logger.info(f"✅ CSV کامل شد: {len(processed_rows)} ردیف | {time_str}")
return output_path, status, mapping_md
except Exception as e:
logger.error(f"❌ خطای CSV: {e}", exc_info=True)
return None, f"❌ خطا: {str(e)}", ""
def clear_csv():
return (None, gr.update(choices=[], value=[]), "", None,
"⏳ هنوز پردازشی انجام نشده",
"### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده",
True, False, False, False, False,
1, 50, 10, 1.0)
# ─────────────────────────────────────────────────────────────
# رابط کاربری Gradio
# ─────────────────────────────────────────────────────────────
css_rtl = """
.textbox textarea { direction: rtl; text-align: right; font-family: 'Tahoma', serif; }
.input-box { direction: rtl; text-align: right; }
.compact-checkbox label { padding: 5px 10px !important; font-size: 0.95em !important; }
"""
with gr.Blocks(title="سیستم ناشناسسازی متون", theme=gr.themes.Soft(), css=css_rtl) as app:
gr.Markdown(
"# 🔐 پلتفرم ناشناسسازی متون فارسی\n"
"> 🧠 **Qwen3-14B** با thinking mode — دقت بالا (بنچمارک ۹۰٪+)",
elem_classes="input-box"
)
# ═══════════════════════════════════════════════════════════
# تبها
# ═══════════════════════════════════════════════════════════
with gr.Tabs():
# ─── تب ۱: ناشناسسازی متن ────────────────────────────
with gr.TabItem("📝 ناشناسسازی متن"):
with gr.Row():
with gr.Column(scale=1):
with gr.Group():
gr.Markdown("### ⚙️ مدل تحلیل", elem_classes="input-box")
llm_provider = gr.Dropdown(
choices=["chatgpt", "grok", "deepinfra"],
value="chatgpt", label="🤖 مدل زبانی تحلیل", interactive=True
)
llm_model = gr.Dropdown(
choices=AVAILABLE_MODELS["chatgpt"],
value="gpt-4o-mini", label="📦 نسخه مدل", interactive=True
)
with gr.Column(scale=1):
with gr.Group():
gr.Markdown("### 🎯 موجودیتهای ناشناسسازی", elem_classes="input-box")
anonymize_all = gr.Checkbox(label="✅ همه", value=True, elem_classes="compact-checkbox")
anonymize_person = gr.Checkbox(label="👤 اشخاص", value=False, elem_classes="compact-checkbox")
anonymize_company = gr.Checkbox(label="🏢 سازمانها", value=False, elem_classes="compact-checkbox")
anonymize_amount = gr.Checkbox(label="💰 ارقام مالی", value=False, elem_classes="compact-checkbox")
anonymize_percent = gr.Checkbox(label="📊 درصدها", value=False, elem_classes="compact-checkbox")
gr.Markdown("---")
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("### 📋 دستورات تحلیل (اختیاری)", elem_classes="input-box")
analysis_prompt = gr.Textbox(
lines=20, placeholder="مثال: این متن را خلاصه کن",
label="", elem_classes="textbox"
)
with gr.Column(scale=1):
gr.Markdown("### 📝 متن ورودی", elem_classes="input-box")
input_text = gr.Textbox(
lines=20, placeholder="متن فارسی را وارد کنید...",
label="", elem_classes="textbox"
)
with gr.Row():
process_btn = gr.Button("▶️ پردازش", variant="primary", size="lg", scale=2)
clear_btn = gr.Button("🗑️ پاک کردن", variant="stop", size="lg", scale=1)
gr.Markdown("## 📊 نتایج", elem_classes="input-box")
with gr.Row():
restored_text = gr.Textbox(lines=12, label="✅ متن بازگردانی شده", interactive=False, elem_classes="textbox")
llm_analysis = gr.Textbox(lines=12, label="🤖 تحلیل LLM", interactive=False, elem_classes="textbox")
anonymized_output = gr.Textbox(lines=12, label="🔒 متن ناشناسشده", interactive=False, elem_classes="textbox")
mapping_table = gr.Markdown("### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده", elem_classes="input-box")
# ── رویدادهای تب متن ──
def handle_provider_change(provider):
models = AVAILABLE_MODELS.get(provider, [])
return gr.update(choices=models, value=models[0] if models else None)
llm_provider.change(fn=handle_provider_change, inputs=[llm_provider], outputs=[llm_model])
def handle_select_all(select_all):
s = gr.update(value=False, interactive=not select_all)
return s, s, s, s
anonymize_all.change(
fn=handle_select_all, inputs=[anonymize_all],
outputs=[anonymize_person, anonymize_company, anonymize_amount, anonymize_percent]
)
process_btn.click(
fn=process,
inputs=[
input_text, analysis_prompt, llm_provider, llm_model,
anonymize_all, anonymize_person, anonymize_company, anonymize_amount, anonymize_percent
],
outputs=[restored_text, llm_analysis, anonymized_output, mapping_table]
)
clear_btn.click(
fn=clear_all,
outputs=[
input_text, analysis_prompt, restored_text, llm_analysis,
anonymized_output, mapping_table,
anonymize_all, anonymize_person, anonymize_company, anonymize_amount, anonymize_percent
]
)
# ─── تب ۲: ناشناسسازی CSV ────────────────────────────
with gr.TabItem("📄 ناشناسسازی CSV"):
gr.Markdown(
"### 📄 ناشناسسازی دستهای فایل CSV\n"
"> فایل CSV را آپلود کنید، بازه ردیفها و اندازه دسته را تنظیم کنید و ناشناسسازی را اجرا کنید.",
elem_classes="input-box"
)
with gr.Row():
# ── ستون ۱: فایل و ستونها ──
with gr.Column(scale=1):
with gr.Group():
gr.Markdown("### 📂 فایل ورودی", elem_classes="input-box")
csv_file = gr.File(
label="فایل CSV را آپلود کنید",
file_types=[".csv"],
type="filepath"
)
csv_column_status = gr.Textbox(
label="وضعیت", interactive=False,
value="⏳ فایل آپلود نشده", elem_classes="textbox"
)
with gr.Group():
gr.Markdown("### 📋 ستونهای قابل ناشناسسازی", elem_classes="input-box")
csv_columns = gr.CheckboxGroup(
choices=[], value=[],
label="ستونهایی که میخواهید ناشناس شوند",
interactive=True
)
# ── ستون ۲: بازه ردیفها و دستهبندی ──
with gr.Column(scale=1):
with gr.Group():
gr.Markdown("### 📐 بازه ردیفها", elem_classes="input-box")
with gr.Row():
csv_row_start = gr.Number(
label="از ردیف", value=1, minimum=1, maximum=99999,
precision=0, interactive=True
)
csv_row_end = gr.Number(
label="تا ردیف", value=50, minimum=1, maximum=99999,
precision=0, interactive=True
)
with gr.Group():
gr.Markdown("### 📦 تنظیمات دستهای", elem_classes="input-box")
csv_batch_size = gr.Slider(
label="اندازه هر دسته (تعداد ردیف)",
minimum=1, maximum=50, value=10, step=1,
interactive=True
)
csv_delay = gr.Slider(
label="مکث بین دستهها (ثانیه)",
minimum=0.5, maximum=10.0, value=1.0, step=0.5,
interactive=True
)
gr.Markdown(
"> 💡 **راهنما:** دسته کوچکتر = پایدارتر ولی کندتر | "
"مکث بیشتر = جلوگیری از rate limit",
elem_classes="input-box"
)
# ── ستون ۳: موجودیتها ──
with gr.Column(scale=1):
with gr.Group():
gr.Markdown("### 🎯 موجودیتها", elem_classes="input-box")
csv_anonymize_all = gr.Checkbox(label="✅ همه", value=True, elem_classes="compact-checkbox")
csv_anonymize_person = gr.Checkbox(label="👤 اشخاص", value=False, elem_classes="compact-checkbox")
csv_anonymize_company = gr.Checkbox(label="🏢 سازمانها", value=False, elem_classes="compact-checkbox")
csv_anonymize_amount = gr.Checkbox(label="💰 ارقام مالی", value=False, elem_classes="compact-checkbox")
csv_anonymize_percent = gr.Checkbox(label="📊 درصدها", value=False, elem_classes="compact-checkbox")
with gr.Row():
csv_process_btn = gr.Button("▶️ ناشناسسازی CSV", variant="primary", size="lg", scale=2)
csv_clear_btn = gr.Button("🗑️ پاک کردن", variant="stop", size="lg", scale=1)
gr.Markdown("## 📊 نتایج CSV", elem_classes="input-box")
with gr.Row():
with gr.Column(scale=1):
csv_output_file = gr.File(label="📥 دانلود فایل ناشناسشده")
with gr.Column(scale=2):
csv_status = gr.Markdown("⏳ هنوز پردازشی انجام نشده", elem_classes="input-box")
csv_mapping_table = gr.Markdown("### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده", elem_classes="input-box")
# ── رویدادهای تب CSV ──
csv_file.change(
fn=read_csv_columns,
inputs=[csv_file],
outputs=[csv_columns, csv_column_status, csv_row_start, csv_row_end]
)
def handle_csv_select_all(select_all):
s = gr.update(value=False, interactive=not select_all)
return s, s, s, s
csv_anonymize_all.change(
fn=handle_csv_select_all, inputs=[csv_anonymize_all],
outputs=[csv_anonymize_person, csv_anonymize_company, csv_anonymize_amount, csv_anonymize_percent]
)
csv_process_btn.click(
fn=process_csv,
inputs=[
csv_file, csv_columns,
csv_anonymize_all, csv_anonymize_person,
csv_anonymize_company, csv_anonymize_amount, csv_anonymize_percent,
csv_row_start, csv_row_end, csv_batch_size, csv_delay
],
outputs=[csv_output_file, csv_status, csv_mapping_table]
)
csv_clear_btn.click(
fn=clear_csv,
outputs=[
csv_file, csv_columns, csv_column_status, csv_output_file,
csv_status, csv_mapping_table,
csv_anonymize_all, csv_anonymize_person, csv_anonymize_company,
csv_anonymize_amount, csv_anonymize_percent,
csv_row_start, csv_row_end, csv_batch_size, csv_delay
]
)
if __name__ == "__main__":
print("=" * 60)
print("🧠 Qwen3-14B | thinking ON | retry+chunking | بنچمارک ۹۰٪+")
print("=" * 60)
app.queue(default_concurrency_limit=1)
app.launch(server_name="0.0.0.0", server_port=7860, share=False, show_error=True)
|