CoolFace
Apppublic

KashefTech/Data-Anonymization

sourceHugging Facemitupdated 7mo agoView on Hugging Face
0likes
app.py1810 linesDownload Raw Back to root
1import gradio as gr2import re3import os4import requests5import json6import logging7import csv8import io9import tempfile10import time11from typing import Dict, List, Tuple, Optional12from llm_sender_unified import create_llm_sender13 14logging.basicConfig(level=logging.INFO)15logger = logging.getLogger(__name__)16 17# ─────────────────────────────────────────────────────────────18# مدل‌های موجود — برای تحلیل LLM19# ─────────────────────────────────────────────────────────────20AVAILABLE_MODELS = {21    "chatgpt":   ["gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],22    "grok":      ["grok-4-0709", "grok-3", "grok-3-mini", "grok-2-1212"],23    "deepinfra": [24        "Qwen/Qwen3-14B", "Qwen/Qwen3-32B", "Qwen/Qwen3-30B-A3B",25        "Qwen/Qwen2.5-72B-Instruct", "Qwen/Qwen2.5-14B-Instruct",26    ],27}28 29ANON_MODEL   = "Qwen/Qwen3-14B"30ANON_API_URL = "https://api.deepinfra.com/v1/openai/chat/completions"31 32# ─────────────────────────────────────────────────────────────33# SYSTEM PROMPT — برگرفته از نسخه بنچمارک ۹۰٪+34# ترکیب با قابلیت JSON output برای single call35# Thinking mode فعال — همان چیزی که دقت بالا می‌داد36# ─────────────────────────────────────────────────────────────37ANON_SYSTEM_PROMPT = """شما یک «ناشناس‌ساز متون مالی/خبری فارسی» هستید. وظیفه‌تان جایگزینی اسامی خاص و مقادیر عددی با شناسه‌های بی‌معناست.38 39قبل از دادن پاسخ نهایی، ابتدا در تگ <thinking> گام‌به‌گام تحلیل کنید:401. موجودیت‌های موجود در متن را شناسایی کنید (شرکت، شخص، مبلغ، درصد)412. ترتیب ظهور آن‌ها را مشخص کنید423. نام‌های مختصر/تکرار را به همان توکن اول نسبت دهید434. سپس JSON نهایی را بدهید44 45### قوانین اندیس‌گذاری:46- شرکت‌ها: company-01, company-02, ... (بر اساس ترتیب ظهور)47- اشخاص: person-01, person-02, ...48- اعداد/مبالغ: amount-01, amount-02, ... (هر amount ایندکس یکتا دارد، هیچ‌وقت تکرار نشود)49- درصدها: percent-01, percent-02, ...50- هر بار که همان موجودیت تکرار می‌شود → همان توکن قبلی51- فقط: company, person, amount, percent  ❌ ممنوع: bank-01, sazman-01, group-XX52 53### تشخیص شرکت‌ها (قانون کلی: هر نهاد با نام خاص = company-XX):54- شرکت‌های خصوصی: با پیشوند شرکت/گروه/هلدینگ/بیمه/پتروشیمی/سرمایه‌گذاری + نام خاص55- بانک‌ها: بانک ملت، بانک پارسیان، بانک مرکزی، بانک ملی ایران → همه company-XX56- وزارتخانه‌ها: وزارت نفت، وزارت اقتصاد، وزارت صمت → company-XX57- سازمان‌های دولتی: سازمان بورس، سازمان ثبت احوال، سازمان مالیاتی → company-XX58- ادارات دولتی: اداره کل مالیات تهران، اداره ثبت اسناد → company-XX59- نهادهای انقلابی/عمومی با نام خاص: ستاد اجرایی فرمان امام، بنیاد مستضعفان → company-XX60- دادگاه‌ها/دادستانی با نام خاص: دادگاه تجدیدنظر استان تهران، دادستانی کل کشور → company-XX61- نمادهای بورسی: شپنا، وبملت، وتجارت، خودرو، شتران، فملی، فولاد، کگل، شپدیس → company-XX62- نام مختصر = همان توکن: «شرکت پتروشیمی بوعلی سینا» = «بوعلی» → هر دو company-0163- نام در پرانتز = همان توکن: «هلدینگ غدیر (وکغدیر)» → هر دو company-0164- حسابرس/بازرس قانونی با نام خاص: «وانیا نیک تدبیر» → company-XX65- ضمیر اشاره به شرکت قبلاً ذکرشده: «این شرکت»، «آن بانک»، «شرکت مزبور» → همان توکن قبلی66- قانون طلایی: اگر شک داری نام خاص است یا عمومی → آن را company-XX بگیر67 68### ❌ فقط این موارد company نیستند (ناشناس نشوند):69- میدان‌های نفتی/گازی/معدنی: «پارس جنوبی»، «فرزاد ب»، «آزادگان» → مکان/میدان70- واحدهای داخلی بدون نام مستقل: «واحد حسابرسی داخلی»، «اداره بازرسی داخلی» → بخش سازمانی71- نقش‌های شغلی بدون نام: «حسابرس مستقل»، «بازرس» (بدون اسم بعدی) → عنوان شغلی72- توصیف‌های کاملاً عمومی: «یک شرکت»، «چند بانک»، «این اداره»، «بانک‌های کشور» → بدون نام خاص73- «صندوق دولت»، «خزانه دولت»، «بیت‌المال» → عبارت عمومی74- اسامی مکان صرف: تهران، اصفهان، ایران، خوزستان → مکان جغرافیایی75 76### قوانین amount — چه چیزی amount است:77✅ مبلغ پولی:   «100 میلیون دلار»، «283 ریال»، «41.5 همت»، «1,429,349 میلیون ریال»78✅ مقدار فیزیکی: «320,000 تن»، «1.6 میلیون فوت مکعب در روز»، «800 هزار بشکه»79✅ تعداد شمارشی قابل اندازه‌گیری: «16 قرارداد»، «200 فرصت»، «23 میدان»، «12 فقره»، «500 نفر»80✅ بازه مقداری: «یک تا 1.5 میلیون تن» → یک توکن amount-0181❌ «amount-01 دلار» — واحد باید داخل توکن باشد82 83### ❌ این موارد amount نیستند:84- روز/ماه: «8 ماه»، «20 روز»، «6 ماهه»، «3 سال» — دوره زمانی، ناشناس نشود85- تاریخ: «30 آذر 1403»، «1403/04/12»، «سال 1401» — ناشناس نشود86- مجازات قضایی: «5 سال حبس»، «36 ضربه شلاق»، «3 سال انفصال» — ناشناس نشود87- عبارت تقریبی: «هزاران نفر»، «صدها شاکی»، «چند نفر» — ناشناس نشود88- ترتیبی و رتبه‌ای: «ردیف اول»، «مرحله سوم»، «فاز 39» — ناشناس نشود89 90### قوانین percent (عدد + درصد = یک موجودیت):91✅ «80 درصد» → percent-0192✅ «14%» → percent-0193✅ «منفی 345 درصد» → percent-01    ❌ «منفی percent-01»94✅ «37 درصدی» → percent-0195✅ «50 الی 70 درصد» → percent-01   ❌ «percent-01 الی percent-02»96 97### تشخیص اشخاص (person) — قانون اصلی:98هر «نام + نام‌خانوادگی» که یک شخص حقیقی مشخص باشد = person-XX، با یا بدون عنوان:99- بدون عنوان: «محسن پاک‌نژاد»، «احمد کریمی» → person-XX (عنوان الزامی نیست)100- با عنوان: «دکتر علی رضایی»، «مهندس احمدی» → person-XX101- با نقش: «قاضی حسینی گفت...»، «مدیرعامل رضایی» → person-XX (نام بعد از نقش)102- در اسناد حقوقی: شهر محل سکونت، خیابان، محله = person-XX (اطلاعات شناسه‌ساز)103 104### ❌ این موارد person نیستند:105- فقط نقش بدون نام: «مدیرعامل»، «وزیر»، «قاضی»، «بازرس»106- عناوین حقوقی: «محکوم‌علیه»، «شاکی»، «خوانده»، «خواهان»107- عبارات اداری/مالی: «عادی سالیانه»، «فوق‌العاده»، «سود انباشته»108 109### تشخیص مبالغ (amount) — قانون تکمیلی:110- اعداد نوشتاری = amount: «شش حزب» → amount-XX «شش»، «هفت نفر» → amount-XX111- کد ملی = amount (۱۰ رقم)112- پلاک/بلوک/واحد/ناحیه + عدد = amount113- درصد که مقدار مشخص دارد: «۹ درصد» → ببین در context چیه؛ اگر نرخ/تغییر = percent، اگر مقدار خالص = amount114 115### موارد که باید حفظ شوند (ناشناس نشوند):116- تاریخ: «30 آذر 1403»، «1403/04/12»، «1404/04/29»، «سال 1401»117- دوره/بازه زمانی: «8 ماه»، «20 روز»، «9 ماهه»، «3 ساله»، «سال مالی منتهی به»، «سه‌ماهه نخست»118- زمان: «راس ساعت 10:00»، «روز سه شنبه»، «مردادماه»119- مکان: تهران، اصفهان، ایران، خوزستان، عسلویه120- میدان‌های نفتی/گازی: پارس جنوبی، فرزاد ب، آزادگان121- عناوین شغلی: مدیرعامل، رئیس کل، بازرس قانونی، حسابرس، دادستان، وزیر122- مجازات قضایی: سال حبس، ضربه شلاق، سال انفصال123- نماد بورسی → با company-XX جایگزین شود اگر نام شرکت مربوطه در متن نیست؛ وگرنه همان توکن شرکت124 125### فرمت خروجی نهایی (بعد از thinking):126{127  "anonymized": "متن ناشناس شده اینجا",128  "mapping": {"company-01": "نام کامل", "amount-01": "عدد+واحد", ...}129}"""130 131 132# ─────────────────────────────────────────────────────────────133# few-shot examples — از باگ‌های واقعی شناسایی‌شده134# ─────────────────────────────────────────────────────────────135FEW_SHOT_EXAMPLES = """136=== EXAMPLES ===137 138EXAMPLE 1 — نام مختصر + نام در پرانتز + تکرار:139INPUT:  شرکت گروه توسعه مالی مهر آیندگان (ومهان) رشد 14 درصدی داشت. سرمایه‌گذاری‌های ومهان به 16 هزار و 495 میلیارد تومان رسید.140OUTPUT json:141{"anonymized": "company-01 رشد percent-01 داشت. سرمایه‌گذاری‌های company-01 به amount-01 رسید.", "mapping": {"company-01": "شرکت گروه توسعه مالی مهر آیندگان (ومهان)", "percent-01": "14 درصد", "amount-01": "16 هزار و 495 میلیارد تومان"}}142KEY: «ومهان» = company-01 (same token, NOT company-02)143 144EXAMPLE 2 — نام کوتاه متفاوت برای شرکت‌های متفاوت:145INPUT:  مجمع شرکت پتروشیمی بوعلی سینا برگزار شد و وانیا نیک تدبیر بازرس شد. هزینه بوعلی 100 میلیون دلار بود. تحلیل شپنا (شرکت پالایش نفت اصفهان) نشان می‌دهد EPS به 936 ریال برسد.146OUTPUT json:147{"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 ریال"}}148KEY: «بوعلی» = company-01. «شپنا» = company-03 (شرکت پالایش نفت اصفهان، موجودیت جداگانه از بوعلی)149 150EXAMPLE 3 — کلمات عمومی ناشناس نشوند + بانک‌های مشخص:151INPUT:  دو بانک ملت و پاسارگاد سود 157 و 155 هزار میلیارد ریال داشتند. مجموع بانک‌های مورد بررسی زیان 1388 هزار میلیارد ریال داشتند که 10 درصد افزایش یافت. 12 بانک کشور زیان 336 هزار میلیارد تومانی رقم زدند.152OUTPUT json:153{"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 هزار میلیارد تومانی"}}154KEY: «بانک‌های مورد بررسی» و «12 بانک کشور» = generic → ناشناس نشوند155 156EXAMPLE 4 — نام چندکلمه‌ای با مکان + بازه درصد:157INPUT:  شرکت فولاد مبارکه اصفهان با شرکت ملی نفت ایران قرارداد امضا کرد. شرکت فاما سرمایه را از 8,700 میلیارد ریال به 12,500 میلیارد ریال افزایش می‌دهد. سهم سودهای ارزی 40 الی 60 درصد است.158OUTPUT json:159{"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 درصد"}}160KEY: «اصفهان» داخل company-01. «شرکت فاما» = company-03. بازه «40 الی 60 درصد» = یک توکن percent-01161 162EXAMPLE 5 — چند شرکت هم‌نام + مبالغ کوچک + درصد با %:163INPUT:  شرکت بیمه پارسیان از شرکت سرمایه گذاری پارسیان 1,429,349 میلیون ریال سود شناسایی کرد که 89 ریال برای هر سهم است. جواد شکرخواه مدیرعامل بانک پارسیان گفت سود 41.5 همت شد و 99.99 درصد سهام در اختیار است.164OUTPUT json:165{"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 درصد"}}166KEY: واحد داخل توکن. مبالغ کوچک ریال هم ناشناس می‌شوند.167 168EXAMPLE 6 — نام مختصر + پادرو + تیپیکو + شپنا:169INPUT:  شرکت سرمایه‌گذاری دارویی تأمین (تیپیکو) درآمد 681,667 میلیارد ریال داشت. صورت‌های مالی شرکت آسان پادرو 6 میلیارد تومان زیان نشان داد. پادرو 30 میلیارد تومان درآمد کسب کرد. شپنا EPS 936 ریال گزارش داد.170OUTPUT json:171{"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 ریال"}}172KEY: «تیپیکو» = company-01. «پادرو» = company-02 (همان شرکت آسان پادرو). «شپنا» = company-03 (شرکت پالایش نفت اصفهان)173 174EXAMPLE 7 — میدان نفتی/گازی = مکان نه شرکت + این شرکت = همان توکن:175INPUT:  قرارداد توسعه میدان گازی پارس جنوبی میان شرکت ملی نفت ایران و گروه پتروپارس به ارزش 20 میلیارد دلار امضا شد. این شرکت تا پایان سال تولید را افزایش خواهد داد.176OUTPUT json:177{"anonymized": "قرارداد توسعه میدان گازی پارس جنوبی میان company-01 و company-02 به ارزش amount-01 امضا شد. company-02 تا پایان سال تولید را افزایش خواهد داد.", "mapping": {"company-01": "شرکت ملی نفت ایران", "company-02": "گروه پتروپارس", "amount-01": "20 میلیارد دلار"}}178KEY: «پارس جنوبی» = میدان گازی (مکان) → ناشناس نشود. «این شرکت» = company-02 (همان گروه پتروپارس). اگر «وزارت نفت» در متن بود → company-XX می‌شود (نهاد دولتی با نام خاص).179 180EXAMPLE 8 — توصیف بدون نام خاص + واحد داخلی + حسابرس بدون نام + مجازات قضایی:181INPUT:  طبق گزارش واحد حسابرسی داخلی، این شرکت بازرگانی مبلغ 78 میلیارد تومان اختلاف مالیاتی دارد. حسابرس مستقل تأیید کرد. شورای شهر درخواست بررسی کرد. متهم به 5 سال حبس و 36 ضربه شلاق محکوم شد.182OUTPUT json:183{"anonymized": "طبق گزارش واحد حسابرسی داخلی، این شرکت بازرگانی مبلغ amount-01 اختلاف مالیاتی دارد. حسابرس مستقل تأیید کرد. شورای شهر درخواست بررسی کرد. متهم به 5 سال حبس و 36 ضربه شلاق محکوم شد.", "mapping": {"amount-01": "78 میلیارد تومان"}}184KEY: «واحد حسابرسی داخلی» = بخش داخلی، ناشناس نشود. «این شرکت بازرگانی» = توصیف بدون نام خاص، ناشناس نشود. «حسابرس مستقل» = عنوان شغلی بدون نام. «شورای شهر» = عمومی. «5 سال حبس»، «36 ضربه شلاق» = مجازات، نه مبلغ مالی.185 186EXAMPLE 9 — نماد بورسی (وکغدیر) = همان شرکت + «این شرکت» ارجاع:187INPUT:  دکتر علی رضایی مدیرعامل هلدینگ صنایع و معادن غدیر گفت سال مالی وکغدیر به پایان رسید و سود خالص این شرکت 5 هزار میلیارد تومان شد که 120 درصد رشد داشت.188OUTPUT json:189{"anonymized": "person-01 مدیرعامل company-01 گفت سال مالی company-01 به پایان رسید و سود خالص company-01 amount-01 شد که percent-01 رشد داشت.", "mapping": {"person-01": "دکتر علی رضایی", "company-01": "هلدینگ صنایع و معادن غدیر", "amount-01": "5 هزار میلیارد تومان", "percent-01": "120 درصد"}}190KEY: «وکغدیر» = نماد بورسی هلدینگ غدیر = company-01 (نه company-02). «این شرکت» = company-01 (ارجاع به همان موجودیت).191 192 193EXAMPLE 10 — روز/ماه ناشناس نشود + ایندکس یکتا:194INPUT:  طبق قوانین بازار سرمایه شرکت‌های بورسی موظف‌اند سود تقسیمی را حداکثر ظرف مدت 8 ماه پس از برگزاری مجمع به حساب سهامداران پرداخت کنند. شپدیس در سه سال اخیر سود سهامداران را در کمتر از 20 روز پرداخت کرده است.195OUTPUT json:196{"anonymized": "طبق قوانین بازار سرمایه شرکت‌های بورسی موظف‌اند سود تقسیمی را حداکثر ظرف مدت 8 ماه پس از برگزاری مجمع به حساب سهامداران پرداخت کنند. company-01 در سه سال اخیر سود سهامداران را در کمتر از 20 روز پرداخت کرده است.", "mapping": {"company-01": "شپدیس"}}197KEY: «8 ماه»، «20 روز»، «سه سال» = دوره زمانی → ناشناس نشوند. هرگز روز/ماه/سال را amount نکنید.198 199EXAMPLE 11 — اعداد شمارشی + واحد فیزیکی + 6 amount با ایندکس یکتا:200INPUT:  وزارت نفت تاکنون 16 قرارداد برای توسعه 23 میدان با سرمایه‌گذاری بیش از 27 میلیارد دلار امضا کرده که 9 قرارداد با ارزش 13 میلیارد دلار در اجرا است. تولید روزانه 1.6 میلیون فوت مکعب گاز هدف‌گذاری شده.201OUTPUT json:202{"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 میلیون فوت مکعب گاز"}}203KEY: «وزارت نفت» = نهاد دولتی با نام خاص → company-01. هر amount ایندکس یکتا: 01,02,03,04,05,06. «فوت مکعب» = واحد فیزیکی معتبر. هرگز یک ایندکس را دوبار استفاده نکنید.204 205 206EXAMPLE 12 — وزارتخانه + سازمان دولتی + بانک مرکزی + نهاد انقلابی = همه company:207INPUT:  وزارت نفت اعلام کرد سازمان مالیاتی کشور ۱۲۰ میلیارد تومان مطالبه دارد. بانک مرکزی نرخ بهره را ۲۳ درصد تعیین کرد. ستاد اجرایی فرمان امام ۵۰۰ میلیارد تومان تامین مالی کرد.208OUTPUT json:209{"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": "۵۰۰ میلیارد تومان"}}210KEY: وزارت/سازمان/بانک مرکزی/ستاد = همه company-XX. هیچ نهاد دولتی با نام خاصی استثنا ندارد.211 212EXAMPLE 13 — اداره دولتی + دادگاه + دادستانی = company:213INPUT:  اداره کل مالیات تهران پرونده را به دادگاه تجدیدنظر استان تهران ارجاع داد. دادستانی کل کشور اعلام کرد بیش از ۲۰۰ میلیارد تومان اختلاس صورت گرفته است.214OUTPUT json:215{"anonymized": "company-01 پرونده را به company-02 ارجاع داد. company-03 اعلام کرد بیش از amount-01 اختلاس صورت گرفته است.", "mapping": {"company-01": "اداره کل مالیات تهران", "company-02": "دادگاه تجدیدنظر استان تهران", "company-03": "دادستانی کل کشور", "amount-01": "۲۰۰ میلیارد تومان"}}216KEY: ادارات/دادگاه/دادستانی با نام خاص = company-XX. فقط عبارات کاملاً عمومی ناشناس نمی‌شوند.217 218EXAMPLE 14 — اسم شخص بدون عنوان + صورت‌جلسه اداری:219INPUT:  در صورت‌جلسه هیئت رسیدگی به تخلفات اداری، پرونده احمد کریمی بررسی شد. رضا محمدی مسئول مالی نیز در این جلسه حضور داشت. میزان خسارت حدود ۳۵ میلیارد تومان محاسبه شده است.220OUTPUT json:221{"anonymized": "در صورت‌جلسه هیئت رسیدگی به تخلفات اداری، پرونده person-01 بررسی شد. person-02 مسئول مالی نیز در این جلسه حضور داشت. میزان خسارت حدود amount-01 محاسبه شده است.", "mapping": {"person-01": "احمد کریمی", "person-02": "رضا محمدی", "amount-01": "۳۵ میلیارد تومان"}}222KEY: نام+نام‌خانوادگی بدون عنوان → person-XX. «هیئت رسیدگی به تخلفات اداری» = بدون نام خاص → ناشناس نشود.223 224EXAMPLE 15 — متن حقوقی با شعبه دادگاه:225INPUT:  در رأی صادرشده از شعبه ۱۲ دادگاه تجدیدنظر استان تهران، قاضی محمدرضا حسینی پرونده علی اکبر صادقی را بررسی کرد. محکوم‌علیه مکلف شده مبلغ ۴۲ میلیارد تومان پرداخت کند.226OUTPUT json:227{"anonymized": "در رأی صادرشده از company-01، person-01 پرونده person-02 را بررسی کرد. محکوم‌علیه مکلف شده مبلغ amount-01 پرداخت کند.", "mapping": {"company-01": "شعبه ۱۲ دادگاه تجدیدنظر استان تهران", "person-01": "محمدرضا حسینی", "person-02": "علی اکبر صادقی", "amount-01": "۴۲ میلیارد تومان"}}228KEY: «شعبه ۱۲ دادگاه...» = نهاد با نام خاص → company-XX. «محکوم‌علیه» = عنوان حقوقی، نه نام خاص → ناشناس نشود.229 230EXAMPLE 16 — سند حقوقی با کد ملی و نشانی:231INPUT:  دادسرای ناحیه ۶ تهران. شاکی: زهرا محمدی، فرزند حسین، کد ملی ۰۰۷۸۹۰۱۲۳۴، ساکن تهران، خیابان پاسداران، پلاک ۵۶۷.232OUTPUT json:233{"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": "۵۶۷"}}234KEY: در اسناد حقوقی — کد ملی = amount. پلاک/بلوک/واحد/ناحیه + عدد = amount. شهر/خیابان/محله = person (مکان حساس). نام‌خانوادگی تنها (حسین = اسم والد) = person.235 236EXAMPLE 17 — عدد نوشتاری + entity تکرارشونده:237INPUT:  شش حزب سیاسی کردستان ایران با انتشار فراخوانی مشترک خواستند در اعتراض به صدور حکم اعدام محمد فاروق فرهادی و ریبوار علیپور تجمع شود.238OUTPUT json:239{"anonymized": "amount-01 حزب سیاسی company-01 با انتشار فراخوانی مشترک خواستند در اعتراض به صدور حکم اعدام person-01 و person-02 تجمع شود.", "mapping": {"amount-01": "شش", "company-01": "کردستان ایران", "person-01": "محمد فاروق فرهادی", "person-02": "ریبوار علیپور"}}240KEY: اعداد نوشتاری (شش، هفت، ده، صد...) = amount. نام + نام‌خانوادگی = person (حتی غیرفارسی). کردستان + ایران = نهاد جغرافیایی‌سیاسی → company.241 242=== END EXAMPLES ===243"""244 245 246# ─────────────────────────────────────────────────────────────247# ساخت prompt248# ─────────────────────────────────────────────────────────────249 250def build_single_call_prompt(text: str, entities: list) -> str:251    """252    یک prompt = یک call253    Thinking mode فعال — برای دقت بالا (نسخه ۹۰٪+)254    """255    active = []256    if "company" in entities: active.append("company-XX (همه سازمان‌ها)")257    if "person"  in entities: active.append("person-XX (نام اشخاص)")258    if "amount"  in entities: active.append("amount-XX (اعداد+واحد)")259    if "percent" in entities: active.append("percent-XX (درصدها)")260 261    mapping_hints = []262    if "person"  in entities: mapping_hints.append('"person-XX": "نام کامل"')263    if "company" in entities: mapping_hints.append('"company-XX": "نام کامل سازمان"')264    if "amount"  in entities: mapping_hints.append('"amount-XX": "عدد + واحد کامل"')265    if "percent" in entities: mapping_hints.append('"percent-XX": "عدد + درصد/% کامل"')266 267    return f"""{FEW_SHOT_EXAMPLES}268 269موجودیت‌های فعال: {' | '.join(active)}270 271متن زیر را ناشناس کن. ابتدا در <thinking> تحلیل کن، سپس JSON نهایی بده:272 273فرمت خروجی نهایی (بعد از </thinking>):274{{275  "anonymized": "متن ناشناس شده",276  "mapping": {{ {", ".join(mapping_hints)} }}277}}278 279متن:280{text}"""281 282 283def build_verification_prompt(284    original_text: str,285    anonymized_text: str,286    current_mapping: Dict[str, str],287    entities: list288) -> str:289    """290    پاس تأیید: متن اصلی و ناشناس‌شده رو مقایسه کن291    موجودیت‌های جامانده رو پیدا کن292    """293    active = []294    if "company" in entities: active.append("company-XX (سازمان/شرکت/بانک)")295    if "person"  in entities: active.append("person-XX (نام اشخاص)")296    if "amount"  in entities: active.append("amount-XX (عدد+واحد)")297    if "percent" in entities: active.append("percent-XX (درصد)")298 299    # لیست توکن‌های فعلی300    existing_tokens = ""301    if current_mapping:302        existing_lines = [f"  {tok} → {val}" for tok, val in sorted(current_mapping.items())]303        existing_tokens = "\n".join(existing_lines)304 305    # بالاترین شماره هر نوع برای ادامه شماره‌گذاری306    next_numbers = {}307    for etype in entities:308        nums = [int(t.split("-")[1]) for t in current_mapping.keys() if t.startswith(f"{etype}-")]309        next_numbers[etype] = max(nums) + 1 if nums else 1310 311    next_info = ", ".join(f"{e}: از {next_numbers[e]:02d}" for e in entities if e in next_numbers)312 313    return f"""تو یک بازبین ناشناس‌سازی هستی. متن اصلی و نسخه ناشناس‌شده رو مقایسه کن.314 315موجودیت‌های فعال: {' | '.join(active)}316 317=== توکن‌های فعلی ===318{existing_tokens}319 320=== متن اصلی ===321{original_text}322 323=== متن ناشناس‌شده فعلی ===324{anonymized_text}325 326وظیفه: متن اصلی و ناشناس‌شده رو کلمه‌به‌کلمه مقایسه کن.327- فقط موجودیت‌هایی که ۱۰۰٪ مطمئنی جا مانده‌اند را اضافه کن.328- اگر شک داری، اضافه نکن. بهتره یک موجودیت جا بمونه تا اینکه کلمه عادی ناشناس بشه.329- اگر نام مختصر یکی از توکن‌های موجود است → از همان توکن استفاده کن.330- شماره‌گذاری توکن‌های جدید: {next_info}331 332مهم — ناشناس نکن:333- تاریخ، مکان، نام کشور، نام شهر334- عنوان شغلی: مدیرعامل، رئیس، وزیر، معاون335- کلمات عمومی: «بانک‌ها»، «شرکت‌ها»، «این بانک»، «12 بانک کشور»336- واحد مبلغ داخل توکن: ✅ amount-XX (شامل عدد+واحد) ❌ amount-XX ریال337- واحد درصد داخل توکن: ✅ percent-XX (شامل عدد+درصد) ❌ percent-XX درصد338 339خروجی: فقط JSON (بدون توضیح):340{{341  "fixed_text": "متن ناشناس‌شده اصلاح‌شده با توکن‌های جدید",342  "new_mapping": {{"token": "مقدار اصلی"}}343}}344 345اگر هیچ موجودیتی جا نمانده یا مطمئن نیستی:346{{347  "fixed_text": "",348  "new_mapping": {{}}349}}350 351مثال — اینها جا نمانده‌اند (ناشناس نکن):352❌ «تهران» → مکان است، ناشناس نشود353❌ «مدیرعامل» → عنوان شغلی354❌ «بانک‌های مورد بررسی» → عمومی355❌ «12 بانک کشور» → عمومی356❌ «سال ۱۴۰۳» → تاریخ"""357 358 359def build_analysis_prompt(anonymized_text: str, analysis_prompt: str, entities: list) -> str:360    tokens = []361    if "person"  in entities: tokens.append("person-XX")362    if "company" in entities: tokens.append("company-XX")363    if "amount"  in entities: tokens.append("amount-XX")364    if "percent" in entities: tokens.append("percent-XX")365 366    return f"""متن ناشناس‌سازی شده:367{anonymized_text}368 369دستورات:370{analysis_prompt}371 372قوانین:373- فقط از توکن‌های موجود استفاده کن: {', '.join(tokens)}374- هیچ کلمه‌ای قبل/بعد از توکن‌ها اضافه نکن375- توکن جدید ایجاد نکن"""376 377 378# ─────────────────────────────────────────────────────────────379# توابع کمکی380# ─────────────────────────────────────────────────────────────381 382def strip_thinking(text: str) -> str:383    """384    حذف بلوک‌های think/thinking از خروجی385    thinking mode فعال است — برای دقت استفاده می‌شود ولی در خروجی نهایی نمی‌آید386    """387    if not text:388        return text389    # تگ‌های Qwen3 thinking390    text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)391    # تگ‌های نسخه قدیمی392    text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL)393    return text.strip()394 395 396def parse_json_response(raw: str) -> dict:397    """parse JSON مقاوم — thinking block + markdown fence"""398    raw = strip_thinking(raw)399    raw = re.sub(r"```(?:json)?", "", raw).replace("```", "").strip()400    start = raw.find("{")401    end   = raw.rfind("}") + 1402    if start == -1 or end == 0:403        raise ValueError("JSON یافت نشد")404    return json.loads(raw[start:end])405 406 407def post_deepinfra(prompt: str, system: str, max_tokens: int = 6000, timeout: int = 60) -> str:408    """409    DeepInfra Qwen3-14B410    Thinking mode فعال — برای دقت بالا411    timeout = (connect, read) — سریع شناسایی مشکل اتصال412    """413    api_key = os.getenv("DEEPINFRA_API_KEY")414    if not api_key:415        raise ValueError("DEEPINFRA_API_KEY موجود نیست")416 417    # connect: 10s | read: بقیه timeout418    connect_timeout = 10419    read_timeout    = max(timeout - connect_timeout, 30)420 421    resp = requests.post(422        ANON_API_URL,423        headers={424            "Authorization": f"Bearer {api_key}",425            "Content-Type":  "application/json"426        },427        json={428            "model":       ANON_MODEL,429            "messages":    [430                {"role": "system", "content": system},431                {"role": "user",   "content": prompt}432            ],433            "max_tokens":  max_tokens,434            "temperature": 0.3,435            "top_p":       0.9,436        },437        timeout=(connect_timeout, read_timeout)438    )439 440    if resp.status_code != 200:441        raise Exception(f"DeepInfra {resp.status_code}: {resp.text[:300]}")442 443    content = resp.json()["choices"][0]["message"]["content"]444    if "<think>" in content or "<thinking>" in content:445        thinking = re.search(r"<think(?:ing)?>(.*?)</think(?:ing)?>", content, re.DOTALL)446        if thinking:447            logger.info(f"🧠 Thinking ({len(thinking.group(1))} chars)...")448 449    return strip_thinking(content)450 451 452# ─────────────────────────────────────────────────────────────453# تخمین هوشمند max_tokens و Chunking454# ─────────────────────────────────────────────────────────────455 456# آستانه‌های chunking457CHUNK_MAX_CHARS    = 900       # حداکثر کاراکتر هر chunk458CHUNK_MAX_ENTITIES = 12        # حداکثر موجودیت تخمینی هر chunk459LONG_TEXT_THRESHOLD = 1200     # بالای این → chunking فعال460 461# ── حالت batch سریع ──462# True  = CSV batch: retry=1، timeout=45s، بدون verification pass  → سریع463# False = UI تک متن: retry=2، timeout=60/90s، با verification pass → دقیق464BATCH_FAST_MODE = False465 466def estimate_entity_count(text: str) -> int:467    """تخمین تعداد موجودیت‌ها بر اساس نشانه‌های متن"""468    markers = 0469    # شرکت‌ها470    markers += len(re.findall(r'(?:شرکت|بانک|سازمان|گروه|هلدینگ|صندوق|بیمه|پتروشیمی|سرمایه\s*گذاری|وزارت|اداره\s+کل|دادگاه|دادستانی|ستاد)\s+\S', text))471    # اشخاص — با عنوان472    markers += len(re.findall(r'(?:آقای|خانم|دکتر|مهندس|حجت\s*الاسلام)\s+', text))473    # اشخاص — نام‌خانوادگی با پسوند رایج فارسی474    markers += len(re.findall(r'[ء-ی]{2,12}\s+[ء-ی]{2,12}(?:زاده|پور|نژاد|فر|مند|خواه|پناه|وند|دوست|یان|لو)(?![ء-ی])', text))475    # مبالغ عددی476    markers += len(re.findall(r'[\d۰-۹][,،\d۰-۹]*(?:\.\d+)?\s*(?:میلیارد|میلیون|هزار|همت|تومان|ریال|دلار|یورو)', text))477    markers += len(re.findall(r'[\d۰-۹][,،\d۰-۹]*\s*(?:نفر|تن|دستگاه|واحد|فقره)', text))478    # درصدها479    markers += len(re.findall(r'[\d۰-۹]+(?:\.\d+)?\s*(?:درصد|%|٪)', text))480    # اسناد حقوقی: کد ملی، پلاک، بلوک، ناحیه481    markers += len(re.findall(r'کد\s+ملی\s+[\d۰-۹]', text))482    markers += len(re.findall(r'(?:پلاک|بلوک|ناحیه|فاز|طبقه|واحد)\s+[\d۰-۹]', text))483    return max(markers, 1)484 485 486def estimate_max_tokens(text: str) -> int:487    """محاسبه max_tokens بر اساس طول متن و تعداد موجودیت تخمینی"""488    entity_est = estimate_entity_count(text)489    text_len   = len(text)490 491    # thinking ≈ 1500-3000 tok + JSON output ≈ text_len*0.8 + mapping ≈ entity*80492    base_thinking = 2500493    json_output   = int(text_len * 0.7)494    mapping_space = entity_est * 100495    buffer        = 1500496 497    tokens = base_thinking + json_output + mapping_space + buffer498    # حداقل 6000، حداکثر 16000499    return max(6000, min(tokens, 16000))500 501 502def split_text_to_chunks(text: str, max_chars: int = CHUNK_MAX_CHARS) -> List[str]:503    """504    تقسیم متن به chunk‌های کوچکتر بر اساس پاراگراف و جمله505    """506    # اول تقسیم بر اساس خط جدید507    paragraphs = re.split(r'\n+', text)508    paragraphs = [p.strip() for p in paragraphs if p.strip()]509 510    # اگر فقط یک پاراگراف بلند داریم، بر اساس نقطه تقسیم کن511    if len(paragraphs) == 1 and len(paragraphs[0]) > max_chars:512        sentences = re.split(r'(?<=[.،؛!؟\n])\s+', paragraphs[0])513        paragraphs = [s.strip() for s in sentences if s.strip()]514 515    chunks = []516    current_chunk = ""517 518    for para in paragraphs:519        # اگه پاراگراف خودش بلندتر از حد هست520        if len(para) > max_chars:521            if current_chunk:522                chunks.append(current_chunk.strip())523                current_chunk = ""524            # تقسیم بر اساس جمله525            sents = re.split(r'(?<=[.،؛!؟])\s+', para)526            sub_chunk = ""527            for s in sents:528                if len(sub_chunk) + len(s) + 1 <= max_chars:529                    sub_chunk += (" " if sub_chunk else "") + s530                else:531                    if sub_chunk:532                        chunks.append(sub_chunk.strip())533                    sub_chunk = s534            if sub_chunk:535                chunks.append(sub_chunk.strip())536        elif len(current_chunk) + len(para) + 1 <= max_chars:537            current_chunk += ("\n" if current_chunk else "") + para538        else:539            if current_chunk:540                chunks.append(current_chunk.strip())541            current_chunk = para542 543    if current_chunk:544        chunks.append(current_chunk.strip())545 546    # اگر هنوز خالیه، کل متن رو برگردون547    if not chunks:548        chunks = [text]549 550    return chunks551 552 553def merge_chunk_mappings(554    chunk_results: List[Tuple[str, Dict]],555    entities: List[str]556) -> Tuple[str, Dict]:557    """558    ادغام نتایج chunk‌ها + بازشماره‌گذاری توکن‌ها به صورت سراسری559    """560    merged_text = ""561    merged_mapping = {}562    global_counters = {e: 0 for e in entities}563 564    for chunk_text, chunk_mapping in chunk_results:565        if not chunk_mapping:566            merged_text += ("\n" if merged_text else "") + chunk_text567            continue568 569        # نگاشت توکن‌های محلی chunk به شماره سراسری570        local_to_global = {}571 572        # مرتب‌سازی بر اساس ترتیب ظاهر شدن در متن573        sorted_tokens = sorted(574            chunk_mapping.keys(),575            key=lambda t: (t.split("-")[0], int(t.split("-")[1]))576        )577 578        for local_token in sorted_tokens:579            original_value = chunk_mapping[local_token]580            etype = local_token.split("-")[0]581 582            # بررسی آیا این مقدار قبلاً در mapping سراسری هست583            existing_token = None584            for g_token, g_value in merged_mapping.items():585                if g_value == original_value and g_token.startswith(etype):586                    existing_token = g_token587                    break588 589            if existing_token:590                local_to_global[local_token] = existing_token591            else:592                global_counters[etype] += 1593                new_token = f"{etype}-{global_counters[etype]:02d}"594                local_to_global[local_token] = new_token595                merged_mapping[new_token] = original_value596 597        # جایگزینی توکن‌های محلی با سراسری در متن chunk598        renamed_text = chunk_text599        # از بلندترین شروع کن تا تداخل نباشه600        for local_token in sorted(local_to_global.keys(), key=len, reverse=True):601            global_token = local_to_global[local_token]602            if local_token != global_token:603                renamed_text = renamed_text.replace(local_token, global_token)604 605        merged_text += ("\n" if merged_text else "") + renamed_text606 607    return merged_text, merged_mapping608 609 610# ─────────────────────────────────────────────────────────────611# کلاس اصلی612# ─────────────────────────────────────────────────────────────613class AnonymizerAdvanced:614 615    def __init__(616        self,617        llm_provider: str = "chatgpt",618        llm_model:    str = None,619        entities_to_anonymize: List[str] = None620    ):621        self.llm_provider = llm_provider622        self.llm_model    = llm_model623        self.entities_to_anonymize = entities_to_anonymize or ["person", "company", "amount", "percent"]624        self.mapping_table:   Dict[str, str] = {}625        self.reverse_mapping: Dict[str, str] = {}626        self._create_llm_sender()627        logger.info(f"✅ Anonymizer — {llm_provider}")628 629    # ── LLM sender (تحلیل) ──────────────────────────────────630 631    def _create_llm_sender(self):632        try:633            key_map = {634                "chatgpt":   os.getenv("OPENAI_API_KEY"),635                "grok":      os.getenv("XAI_API_KEY"),636                "deepinfra": os.getenv("DEEPINFRA_API_KEY"),637            }638            self.llm_sender = create_llm_sender(639                provider=self.llm_provider,640                api_key=key_map.get(self.llm_provider),641                model=self.llm_model642            )643            logger.info(f"✅ LLM Sender: {self.llm_provider} — {self.llm_sender.model}")644        except Exception as e:645            logger.error(f"❌ LLM Sender خطا: {e}")646            self.llm_sender = create_llm_sender("chatgpt")647 648    def set_llm_provider(self, provider: str, model: str = None, entities: List[str] = None):649        self.llm_provider = provider650        self.llm_model    = model651        if entities is not None:652            self.entities_to_anonymize = entities653        self._create_llm_sender()654 655    # ── ناشناس‌سازی — thinking فعال، retry + chunking + verify ──656 657    def anonymize(self, text: str) -> Tuple[str, Dict]:658        """659        ناشناس‌سازی هوشمند:660        1. متن کوتاه → single call661        2. متن طولانی → chunking662        3. هر call با retry663        4. پاس تأیید (verification) برای پیدا کردن موجودیت‌های جامانده664        """665        if not self.entities_to_anonymize:666            return text, {}667 668        text_len   = len(text)669        entity_est = estimate_entity_count(text)670 671        logger.info(f"🧠 ورودی: {text_len} char | ~{entity_est} موجودیت تخمینی")672 673        # ── مرحله ۱: ناشناس‌سازی اولیه ──674        needs_chunking = (675            text_len > LONG_TEXT_THRESHOLD or676            entity_est > CHUNK_MAX_ENTITIES677        )678 679        if needs_chunking:680            anon_text, mapping = self._anonymize_chunked(text)681        else:682            anon_text, mapping = self._anonymize_single(text)683 684        # ── مرحله ۲: پاس تأیید LLM ──685        if anon_text and anon_text.strip() != text.strip():686            anon_text, mapping = self._verification_pass(text, anon_text, mapping)687 688        # ── مرحله ۳: Hybrid Rule-Based Pass ──689        if anon_text and anon_text.strip() != text.strip():690            anon_text, mapping = self._hybrid_rule_based_pass(text, anon_text)691 692        # ── مرحله ۴: رفع توکن چسبیده ──693        anon_text = self._fix_stuck_tokens(anon_text)694 695        return anon_text, mapping696 697    def _verification_pass(698        self,699        original_text: str,700        anonymized_text: str,701        current_mapping: Dict[str, str]702    ) -> Tuple[str, Dict]:703        """704        پاس تأیید محافظه‌کارانه:705        فقط وقتی اجرا شو که شواهد قوی از miss وجود داره706        """707        found_count = len(current_mapping)708        entity_est  = estimate_entity_count(original_text)709 710        # ── تصمیم: آیا verify لازمه؟ ──711        gap = entity_est - found_count712        has_name_entities = any(e in self.entities_to_anonymize for e in ["company", "person"])713 714        if BATCH_FAST_MODE:715            # batch mode: فقط برای company/person با gap بزرگ716            needs_verify = has_name_entities and found_count > 0 and gap >= 2717        else:718            # UI mode: gap >= 1 کافیه719            needs_verify = found_count > 0 and gap >= 1720 721        if not needs_verify:722            logger.info(f"  ⏭️ Verify skip: {found_count} found, ~{entity_est} est, gap={gap}")723            return anonymized_text, current_mapping724 725        logger.info(f"  🔍 Verification Pass: {found_count} یافته، ~{entity_est} تخمینی، gap={gap}")726 727        try:728            prompt = build_verification_prompt(729                original_text, anonymized_text,730                current_mapping, self.entities_to_anonymize731            )732 733            raw = post_deepinfra(734                prompt,735                "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.",736                max_tokens=estimate_max_tokens(original_text),737                timeout=60738            )739 740            result = parse_json_response(raw)741            fixed_text  = result.get("fixed_text", "")742            new_mapping = result.get("new_mapping", {})743 744            # اگه مدل گفت هیچی جا نمونده745            if not fixed_text or not new_mapping:746                logger.info(f"  ✅ Verify: هیچ موجودیت جامانده‌ای یافت نشد")747                return anonymized_text, current_mapping748 749            # ── اعتبارسنجی سختگیرانه ──750 751            # ۱) توکن‌های قبلی نباید حذف شن752            old_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', anonymized_text))753            new_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', fixed_text))754            lost_tokens = old_tokens - new_tokens755            if lost_tokens:756                logger.warning(f"  ⚠️ Verify reject: {len(lost_tokens)} توکن قبلی حذف شده: {lost_tokens}")757                return anonymized_text, current_mapping758 759            # ۲) تعداد توکن‌های جدید نباید خیلی زیاد باشه (حداکثر ۲ برابر gap)760            added_tokens = new_tokens - old_tokens761            max_allowed_new = min(gap * 2, 10)762            if len(added_tokens) > max_allowed_new:763                logger.warning(f"  ⚠️ Verify reject: {len(added_tokens)} توکن جدید > حد مجاز {max_allowed_new}")764                return anonymized_text, current_mapping765 766            # ۳) new_mapping فقط شامل توکن‌های معتبر باشه767            valid_new_mapping = {}768            for token, value in new_mapping.items():769                if not re.match(r'(company|person|amount|percent)-\d+', token):770                    continue771                if token in current_mapping:772                    continue  # تکراری773                if token not in new_tokens:774                    continue  # در متن نیست775                # مقدار نباید خیلی کوتاه باشه776                if len(str(value).strip()) < 2:777                    continue778                valid_new_mapping[token] = value779 780            if not valid_new_mapping:781                logger.info(f"  ✅ Verify: mapping معتبر جدیدی نیست")782                return anonymized_text, current_mapping783 784            # ادغام mapping785            merged_mapping = dict(current_mapping)786            merged_mapping.update(valid_new_mapping)787 788            # پاک‌سازی نهایی789            final_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', fixed_text))790            merged_mapping = {k: v for k, v in merged_mapping.items() if k in final_tokens}791 792            self.mapping_table   = merged_mapping793            self.reverse_mapping = {v: k for k, v in merged_mapping.items()}794 795            logger.info(f"  ✅ Verify: +{len(valid_new_mapping)} موجودیت | کل: {len(merged_mapping)}")796            return fixed_text, merged_mapping797 798        except Exception as e:799            logger.warning(f"  ⚠️ Verify fail: {e} — نتیجه اولیه حفظ می‌شود")800            return anonymized_text, current_mapping801 802    def _anonymize_single(self, text: str, max_retries: int = 2, is_chunk: bool = False) -> Tuple[str, Dict]:803        """804        ناشناس‌سازی تک‌تکه با retry سریع805        is_chunk=True → بدون fallback به chunking (جلوگیری از حلقه بازگشتی)806        """807        base_max_tokens = estimate_max_tokens(text)808 809        # حالت سریع: retry=2 و timeout=90s (افزایش از ۱ و ۴۵ برای کاهش timeout)810        if BATCH_FAST_MODE:811            max_retries    = 2812            base_timeout   = 90813        else:814            base_timeout   = 60815 816        for attempt in range(max_retries):817            current_max_tokens = min(base_max_tokens + (attempt * 1500), 16000)818            current_timeout    = base_timeout + (attempt * 30)  # fast:45s | normal:60s,90s819 820            logger.info(f"  🔄 تلاش {attempt+1}/{max_retries} | max_tokens={current_max_tokens} | timeout={current_timeout}s")821 822            prompt = build_single_call_prompt(text, self.entities_to_anonymize)823 824            try:825                raw = post_deepinfra(826                    prompt, ANON_SYSTEM_PROMPT,827                    max_tokens=current_max_tokens,828                    timeout=current_timeout829                )830                logger.info(f"  ✅ پاسخ: {len(raw)} کاراکتر")831 832                result = parse_json_response(raw)833                anonymized_text    = result.get("anonymized", "")834                self.mapping_table = result.get("mapping", {})835 836                # ── بررسی کیفیت: آیا واقعاً ناشناس شده؟ ──837                if anonymized_text.strip() == text.strip():838                    logger.warning(f"  ⚠️ متن تغییر نکرده! (attempt {attempt+1})")839                    if attempt < max_retries - 1:840                        time.sleep(0.5)841                        continue842                    # آخرین تلاش fail شد843                    if not is_chunk:844                        logger.warning("  ⚠️ retry fail → chunking")845                        return self._anonymize_chunked(text)846                    else:847                        return text, {}  # در حالت chunk: متن اصلی برگرده848 849                # آیا mapping خالی هست ولی توکن در متن هست؟850                tokens_in_text = re.findall(r'(?:company|person|amount|percent)-\d+', anonymized_text)851                if tokens_in_text and not self.mapping_table:852                    logger.warning(f"  ⚠️ mapping خالی ولی {len(tokens_in_text)} توکن در متن!")853                    if attempt < max_retries - 1:854                        time.sleep(0.5)855                        continue856 857                self._clean_orphan_tokens(anonymized_text)858                self._fix_mapping()859                anonymized_text = self._fix_stuck_tokens(anonymized_text)860                self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}861 862                for etype in self.entities_to_anonymize:863                    found = sorted(set(re.findall(rf'{etype}-\d+', anonymized_text)))864                    if found:865                        logger.info(f"    {etype}: {found}")866 867                logger.info(f"  ✅ mapping: {len(self.mapping_table)} موجودیت")868                return anonymized_text, self.mapping_table869 870            except json.JSONDecodeError as e:871                logger.warning(f"  ⚠️ JSON خطا (attempt {attempt+1}): {e}")872                if attempt < max_retries - 1:873                    time.sleep(0.5)874                    continue875                # آخرین تلاش: fallback876                if not is_chunk:877                    logger.info("  🔄 fallback دو-call...")878                    try:879                        return self._anonymize_fallback(text)880                    except Exception:881                        return self._anonymize_chunked(text)882                return text, {}883 884            except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:885                logger.warning(f"  ⏳ اتصال/Timeout (attempt {attempt+1}): {type(e).__name__}")886                if attempt < max_retries - 1:887                    time.sleep(1)888                    continue889                # timeout → chunking فقط اگه chunk نیستیم890                if not is_chunk:891                    return self._anonymize_chunked(text)892                return text, {}893 894            except Exception as e:895                logger.error(f"  ❌ Exception (attempt {attempt+1}): {e}")896                if attempt < max_retries - 1:897                    time.sleep(0.5)898                    continue899                if is_chunk:900                    return text, {}901                raise902 903        # اگه هیچ‌کدوم جواب نداد904        if not is_chunk:905            logger.warning("  ⚠️ همه retry‌ها fail → chunking")906            return self._anonymize_chunked(text)907        return text, {}908 909    def _anonymize_chunked(self, text: str) -> Tuple[str, Dict]:910        """911        تقسیم متن به chunk و ناشناس‌سازی جداگانه + ادغام912        هر chunk فقط ۱ retry و بدون بازگشت به chunking913        """914        chunks = split_text_to_chunks(text, max_chars=CHUNK_MAX_CHARS)915        logger.info(f"  📦 Chunking: {len(chunks)} قطعه از {len(text)} کاراکتر")916 917        chunk_results = []918        for i, chunk in enumerate(chunks):919            logger.info(f"  📦 Chunk {i+1}/{len(chunks)}: {len(chunk)} char")920 921            # ریست mapping برای هر chunk922            self.mapping_table   = {}923            self.reverse_mapping = {}924 925            try:926                # is_chunk=True → بدون fallback بازگشتی به chunking927                anon_chunk, mapping = self._anonymize_single(chunk, max_retries=2, is_chunk=True)928                chunk_results.append((anon_chunk, mapping))929            except Exception as e:930                logger.error(f"  ❌ Chunk {i+1} fail: {e} — متن اصلی حفظ می‌شود")931                chunk_results.append((chunk, {}))932 933            time.sleep(0.3)934 935        # ادغام chunk‌ها936        merged_text, merged_mapping = merge_chunk_mappings(937            chunk_results, self.entities_to_anonymize938        )939 940        self.mapping_table   = merged_mapping941        self.reverse_mapping = {v: k for k, v in merged_mapping.items()}942 943        logger.info(f"  ✅ Chunked: {len(merged_mapping)} موجودیت از {len(chunks)} chunk")944        return merged_text, merged_mapping945 946    def _anonymize_fallback(self, text: str) -> Tuple[str, Dict]:947        """Fallback: دو call — اگر JSON parse شکست خورد"""948        logger.info("🔄 fallback: دو call...")949 950        rules_fa = (951            "متن زیر را ناشناس کن.\n"952            "- company-XX: نام کامل سازمان (بانک/شرکت/بیمه/پتروشیمی/...) — نام مختصر = همان توکن\n"953            "- person-XX: نام کامل اشخاص\n"954            "- amount-XX: عدد + واحد با هم\n"955            "- percent-XX: عدد + درصد با هم (بازه هم یک توکن)\n"956            "کلمات عمومی را دست نزن. فقط متن ناشناس شده."957        )958 959        prompt1 = f"{FEW_SHOT_EXAMPLES}\n{rules_fa}\n\nمتن:\n{text}"960        anonymized_text = post_deepinfra(prompt1, ANON_SYSTEM_PROMPT, max_tokens=4096, timeout=60)961 962        hints = []963        if "person"  in self.entities_to_anonymize: hints.append('"person-XX": "نام کامل"')964        if "company" in self.entities_to_anonymize: hints.append('"company-XX": "نام کامل سازمان"')965        if "amount"  in self.entities_to_anonymize: hints.append('"amount-XX": "عدد+واحد"')966        if "percent" in self.entities_to_anonymize: hints.append('"percent-XX": "عدد+درصد"')967 968        prompt2 = (969            f"متن اصلی: {text}\n"970            f"متن ناشناس: {anonymized_text}\n\n"971            f"فقط JSON mapping:\n{{ {', '.join(hints)} }}"972        )973 974        try:975            raw2 = post_deepinfra(prompt2, "Output ONLY valid JSON. No explanation.", max_tokens=2048, timeout=45)976            self.mapping_table = parse_json_response(raw2)977        except Exception:978            self._extract_mapping_fallback(text, anonymized_text)979 980        self._clean_orphan_tokens(anonymized_text)981        self._fix_mapping()982        self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}983        return anonymized_text, self.mapping_table984 985    # ── پاک‌سازی mapping ────────────────────────────────────986 987    def _clean_orphan_tokens(self, anonymized_text: str):988        to_remove = [t for t in self.mapping_table if t not in anonymized_text]989        for t in to_remove:990            logger.info(f"  🗑️ توکن اضافی: {t}")991            del self.mapping_table[t]992 993    def _fix_mapping(self):994        """اطمینان از صحت مقادیر — فقط percent بدون واحد"""995        for token, value in list(self.mapping_table.items()):996            val = str(value).strip()997            if token.startswith("percent-") and not re.search(r"(درصد|%|٪|درصدی)", val):998                self.mapping_table[token] = f"{val} درصد"999                logger.info(f"  اصلاح {token}: '{val}' → '{val} درصد'")1000 1001    def _fix_stuck_tokens(self, text: str) -> str:1002        """1003        رفع توکن چسبیده به حروف فارسی1004        مثال: «رperson-01» → «person-01»  (کاراکتر «ر» قبل از توکن حذف می‌شود)1005        """1006        # حرف فارسی بلافاصله قبل از توکن (بدون فاصله)1007        cleaned = re.sub(1008            r'([ء-ی\u200c]+)((?:company|person|amount|percent)-\d+)',1009            r' \2',1010            text1011        )1012        # توکن بلافاصله قبل از حرف فارسی (بدون فاصله)1013        cleaned = re.sub(1014            r'((?:company|person|amount|percent)-\d+)([ء-ی\u200c]+)',1015            r'\1 \2',1016            cleaned1017        )1018        # پاک‌سازی فاصله مضاعف1019        cleaned = re.sub(r'  +', ' ', cleaned).strip()1020        if cleaned != text:1021            logger.info(f"  🔧 Fixed stuck tokens in text")1022        return cleaned1023 1024    def _hybrid_rule_based_pass(self, original_text: str, anonymized_text: str) -> Tuple[str, Dict]:1025        """1026        لایه Hybrid: شناسایی فعال موجودیت‌های جامانده با Regex1027        اجرا بعد از LLM — فقط متن اصلی را اسکن می‌کند، نه ناشناس‌شده را1028        هیچ lookbehind متغیر ندارد (رفع خطای fixed-width)1029        """1030        result_text = anonymized_text1031        counters = {}1032        for etype in self.entities_to_anonymize:1033            nums = [int(m) for m in re.findall(rf'{etype}-(\d+)', anonymized_text)]1034            counters[etype] = max(nums) + 1 if nums else 11035 1036        # تابع کمکی: جایگزینی ایمن در متن ناشناس‌شده1037        def replace_in_result(entity_text: str, token: str):1038            nonlocal result_text1039            # فقط اگه هنوز در متن ناشناس‌شده موجود است (توکن نشده)1040            if entity_text in result_text and not re.search(1041                r'(?:company|person|amount|percent)-\d+', entity_text1042            ):1043                result_text = result_text.replace(entity_text, token, 1)1044                return True1045            return False1046 1047        # ── تشخیص company با پیشوند + نام خاص حداقل ۳ کاراکتر ──1048        if "company" in self.entities_to_anonymize:1049            # الگو: پیشوند + فاصله + نام خاص (حداقل یک کلمه ۳+ کاراکتر)1050            # از lookbehind استفاده نمی‌کنیم — به جای آن group capture داریم1051            company_patterns = [1052                # بانک + نام مشخص (لیست ثابت)1053                r'(بانک\s+(?:ملت|پارسیان|پاسارگاد|ملی\s+ایران|مرکزی|صادرات|تجارت|رفاه|مسکن|کشاورزی|سپه|آینده|اقتصاد\s+نوین|کارآفرین|دی|ایران\s+زمین|سینا|شهر|سامان|خاورمیانه))',1054                # وزارت + نام مشخص (لیست ثابت)1055                r'(وزارت\s+(?:نفت|اقتصاد|صمت|بهداشت|آموزش|دفاع|کشور|امور\s+خارجه|راه|نیرو|ارتباطات|صنعت))',1056                # سازمان/اداره کل/دادستانی + نام خاص (حداقل ۵ کاراکتر بعد)1057                r'((?:سازمان|اداره\s+کل)\s+[ء-ی\u200c]{3,}(?:\s+[ء-ی\u200c]{2,}){0,3})',1058                r'(دادستانی\s+(?:کل\s+کشور|استان\s+[ء-ی\u200c]+|عمومی\s+و\s+انقلاب\s+[ء-ی\u200c]+))',1059                r'(دادگاه\s+(?:تجدیدنظر|انقلاب|عمومی)\s+[ء-ی\u200c]+(?:\s+[ء-ی\u200c]+)?)',1060                # شرکت/گروه/هلدینگ/پتروشیمی + نام خاص (حداقل ۴ کاراکتر)1061                r'((?:شرکت|گروه|هلدینگ|پتروشیمی|بیمه)\s+(?!از\s|در\s|با\s|به\s|که\s|این\s|آن\s|صفر\s|کند\s|محل\s|نماد\s|بخش\s)[ء-ی\u200c]{3,}(?:\s+[ء-ی\u200c]{2,}){0,2})',1062                # ستاد/بنیاد/نهاد + نام خاص1063                r'((?:ستاد|بنیاد)\s+[ء-ی\u200c]{4,}(?:\s+[ء-ی\u200c]{2,}){0,3})',1064            ]1065            for pattern in company_patterns:1066                for match in re.finditer(pattern, original_text):1067                    entity_text = match.group(1).strip()1068                    if len(entity_text) < 5:1069                        continue1070                    # چک: آیا قبلاً در mapping داریم؟1071                    existing = next((t for t, v in self.mapping_table.items()1072                                     if v == entity_text and t.startswith('company')), None)1073                    if existing:1074                        replace_in_result(entity_text, existing)1075                    else:1076                        token = f"company-{counters['company']:02d}"1077                        counters['company'] += 11078                        if replace_in_result(entity_text, token):1079                            self.mapping_table[token] = entity_text1080                            logger.info(f"  🔧 Hybrid company: '{entity_text}' → {token}")1081 1082        # ── تشخیص person — فقط الگوهای با دقت بالا ──1083        # ⚠️ فقط اگه متن اصلی نشانه حقوقی/رسمی داشته باشد یا عنوان صریح قبل نام1084        is_legal_doc = bool(re.search(1085            r'(?:شاکی|متهم|کد\s+ملی|فرزند\s+|نمایندگی|دادسرا|دادنامه|ابلاغ)', original_text1086        ))1087        if "person" in self.entities_to_anonymize:1088            person_patterns = [1089                # عنوان + نام + نام‌خانوادگی (۲ یا ۳ کلمه) — همیشه فعال1090                r'((?:دکتر|مهندس|آقای|خانم|حجت[\u200c\s]الاسلام|سید)\s+[ء-ی\u200c]{2,12}\s+[ء-ی\u200c]{3,20}(?:\s+[ء-ی\u200c]{3,15})?)',1091                # نام‌های مرکب شناخته‌شده — همیشه فعال1092                r'((?:امیرحسین|محمدرضا|محمدعلی|علیرضا|حمیدرضا)\s+[ء-ی\u200c]{3,20})',1093            ]1094            # پسوند-محور فقط در اسناد حقوقی (کاهش FP در متون مالی/اقتصادی)1095            if is_legal_doc:1096                person_patterns.append(1097                    r'([ء-ی\u200c]{2,12}\s+[ء-ی\u200c]{2,12}(?:زاده|پور|نژاد|مند|خواه|پناه|وند|دوست|یان|لو|بیگی|قلی)(?:ان)?(?![ء-ی\u200c]))'1098                )1099            for pattern in person_patterns:1100                for match in re.finditer(pattern, original_text):1101                    entity_text = match.group(1).strip()1102                    words = entity_text.split()1103                    # حداقل ۲ کلمه، و کلمه آخر حداقل ۳ کاراکتر1104                    if len(words) < 2 or len(words[-1]) < 3:1105                        continue1106                    # رد false positive‌های رایج1107                    false_positives = {1108                        'تا پایان', 'در پایان', 'در جریان', 'تا اطلاع',1109                        'در نهایت', 'در واقع', 'به دلیل', 'این پرونده',1110                        'در پرونده', 'با پرونده', 'عادی سالیانه',1111                        'فوق العاده', 'فوق‌العاده', 'هر سهم',1112                        'سال مالی', 'سود خالص', 'زیان خالص',1113                        'مدیره سالیانه', 'مدیره شرکت', 'عمومی عادی',1114                        'انباشته دوره', 'عملیاتی شرکت',1115                    }1116                    if entity_text in false_positives:1117                        continue1118                    existing = next((t for t, v in self.mapping_table.items()1119                                     if v == entity_text and t.startswith('person')), None)1120                    if existing:1121                        replace_in_result(entity_text, existing)1122                    else:1123                        token = f"person-{counters['person']:02d}"1124                        counters['person'] += 11125                        if replace_in_result(entity_text, token):1126                            self.mapping_table[token] = entity_text1127                            logger.info(f"  🔧 Hybrid person: '{entity_text}' → {token}")1128 1129        self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}1130        added = len(self.mapping_table) - (len(self.mapping_table) - sum(1131            1 for t in self.mapping_table if t not in anonymized_text or1132            self.mapping_table[t] not in original_text1133        ))1134        return result_text, self.mapping_table1135 1136    # ── fallback mapping با regex ────────────────────────────1137 1138    def _extract_mapping_fallback(self, original: str, anonymized: str):1139        pats: Dict[str, str] = {}1140        if "person"  in self.entities_to_anonymize:1141            pats["person"]  = r'(?<![ء-یa-zA-Z])[ء-ی]+\s+[ء-ی]+(?:\s+[ء-ی]+)*(?![ء-یa-zA-Z])'1142        if "company" in self.entities_to_anonymize:1143            pats["company"] = (1144                r'(?:(?:شرکت|بانک|سازمان|گروه|هلدینگ|صندوق|بیمه|پتروشیمی|ملی|سرمایه\s*گذاری)\s+)'1145                r'[ء-ی][ء-ی\s]+(?:\([ء-یa-zA-Z۰-۹]+\))?'1146            )1147        if "amount"  in self.entities_to_anonymize:1148            pats["amount"]  = r'[\d۰-۹][,،\d۰-۹]*(?:\.\d+)?\s*(?:هزار\s+و\s+\d+|هزار|میلیون|میلیارد|همت)?\s*(?:میلیارد|میلیون|هزار|تومان|ریال|دلار|دستگاه|تن)?'1149        if "percent" in self.entities_to_anonymize:1150            pats["percent"] = r'[\d۰-۹]+(?:\.\d+)?\s*(?:الی|تا)?\s*(?:[\d۰-۹]+(?:\.\d+)?\s*)?(?:درصد|%|٪|درصدی)'1151 1152        orig_entities = {1153            etype: [m.strip() for m in re.findall(pat, original) if m.strip()]1154            for etype, pat in pats.items()1155        }1156 1157        for etype in self.entities_to_anonymize:1158            tokens = sorted(set(re.findall(rf'{etype}-(\d+)', anonymized)), key=int)1159            values = orig_entities.get(etype, [])1160            for tok_num in tokens:1161                token = f"{etype}-{tok_num}"1162                idx   = int(tok_num) - 11163                self.mapping_table[token] = values[idx] if idx < len(values) else (values[-1] if values else token)1164 1165        self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}1166        logger.info(f"✅ Fallback mapping: {len(self.mapping_table)} موجودیت")1167 1168    # ── تحلیل LLM ───────────────────────────────────────────1169 1170    def analyze_with_llm(self, anonymized_text: str, analysis_prompt: str = None) -> str:1171        logger.info(f"🤖 {self.llm_provider.upper()} تحلیل...")1172 1173        if not analysis_prompt or not analysis_prompt.strip():1174            return "⚠️ هیچ دستور تحلیل داده نشده است"1175 1176        prompt = build_analysis_prompt(anonymized_text, analysis_prompt, self.entities_to_anonymize)1177        try:1178            response = self.llm_sender.send(prompt, lang="fa", temperature=0.2, max_tokens=2000)1179            logger.info(f"✅ {self.llm_provider.upper()}: {len(response)} کاراکتر")1180            return response1181        except Exception as e:1182            logger.error(f"❌ {self.llm_provider.upper()} Exception: {e}")1183            return f"❌ خطا: {str(e)}"1184 1185    # ── بازگردانی ────────────────────────────────────────────1186 1187    def restore_text(self, anonymized_text: str) -> str:1188        logger.info("🔄 بازگردانی...")1189 1190        if not self.mapping_table:1191            return anonymized_text1192 1193        restored = self._normalize_tokens(anonymized_text)1194        count = 01195 1196        for placeholder, original in sorted(1197            self.mapping_table.items(), key=lambda x: len(x[0]), reverse=True1198        ):1199            if placeholder in restored:1200                restored = restored.replace(placeholder, original)

Showing the first 1,200 of 1810 lines. Download the file for the rest.