KashefTech/Data-Anonymization
0
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)