KashefTech commited on
Commit
666ff2c
·
verified ·
1 Parent(s): d368d4d

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1596
app.py DELETED
@@ -1,1596 +0,0 @@
1
- import gradio as gr
2
- import re
3
- import os
4
- import requests
5
- import json
6
- import logging
7
- import csv
8
- import io
9
- import tempfile
10
- import time
11
- from typing import Dict, List, Tuple, Optional
12
- from llm_sender_unified import create_llm_sender
13
-
14
- logging.basicConfig(level=logging.INFO)
15
- logger = logging.getLogger(__name__)
16
-
17
- # ─────────────────────────────────────────────────────────────
18
- # مدل‌های موجود — برای تحلیل LLM
19
- # ─────────────────────────────────────────────────────────────
20
- AVAILABLE_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
-
29
- ANON_MODEL = "Qwen/Qwen3-14B"
30
- ANON_API_URL = "https://api.deepinfra.com/v1/openai/chat/completions"
31
-
32
- # ─────────────────────────────────────────────────────────────
33
- # SYSTEM PROMPT — برگرفته از نسخه بنچمارک ۹۰٪+
34
- # ترکیب با قابلیت JSON output برای single call
35
- # Thinking mode فعال — همان چیزی که دقت بالا می‌داد
36
- # ─────────────────────────────────────────────────────────────
37
- ANON_SYSTEM_PROMPT = """شما یک «ناشناس‌ساز متون مالی/خبری فارسی» هستید. وظیفه‌تان جایگزینی اسامی خاص و مقادیر عددی با شناسه‌های بی‌معناست.
38
-
39
- قبل از دادن پاسخ نهایی، ابتدا در تگ <thinking> گام‌به‌گام تحلیل کنید:
40
- 1. موجودیت‌های موجود در متن را شناسایی کنید (شرکت، شخص، مبلغ، درصد)
41
- 2. ترتیب ظهور آن‌ها را مشخص کنید
42
- 3. نام‌های مختصر/تکرار را به همان توکن اول نسبت دهید
43
- 4. سپس JSON نهایی را بدهید
44
-
45
- ### قوانین اندیس‌گذاری:
46
- - شرکت‌ها: company-01, company-02, ... (بر اساس ترتیب ظهور)
47
- - اشخاص: person-01, person-02, ...
48
- - اعداد/مبالغ: amount-01, amount-02, ...
49
- - درصدها: percent-01, percent-02, ...
50
- - هر بار که همان موجودیت تکرار می‌شود → همان توکن قبلی
51
- - فقط: company, person, amount, percent ❌ ممنوع: bank-01, sazman-01, group-XX
52
-
53
- ### تشخیص شرکت‌ها (نام خاص داشته باشد):
54
- - با پیشوند: شرکت، بانک، سازمان، گروه، هلدینگ، بیمه، پتروشیمی، سرمایه‌گذاری + نام خاص
55
- - بدون پیشوند (نام‌های تجاری): ایران خودرو، سایپا، تاپیکو، پارسیان → company-XX
56
- - نمادهای بورسی: شپنا، وبملت، وتجارت، خودرو، خساپا، شتران، فملی، فولاد، کگل → company-XX
57
- - نام مختصر = همان توکن: «شرکت پتروشیمی بوعلی سینا» = «بوعلی» → هر دو company-01
58
- - نام در پرانتز = همان توکن: «شرکت X (Y)» → company-01، و «Y» بعداً → company-01
59
- - نماد بورسی بعد از ذکر شرکت = همان توکن: «هلدینگ غدیر (وکغدیر)» → هر دو company-01
60
- - حسابرس/بازرس قانونی با نام خاص: «وانیا نیک تدبیر» → company-XX
61
- - سازمان‌های دولتی با نام خاص: سازمان بورس، سازمان ثبت احوال، سازمان غذا و دارو → company-XX
62
- - ضمیر اشاره به شرکت قبلاً ذکرشده: «این شرکت»، «آن بانک»، «شرکت مزبور» → همان توکن قبلی (نه entity جدید)
63
-
64
- ### ❌ این موارد شرکت نیستند (ناشناس نشوند):
65
- - میدان‌های نفتی/گازی/معدنی: «پارس جنوبی»، «فرزاد ب»، «آزادگان»، «یادآوران» → مکان/میدان
66
- - واحدهای داخلی سازمان: «واحد حسابرسی داخلی»، «واحد مالی»، «اداره بازرسی» → بخش، نه شرکت
67
- - نهادهای عمومی/شهری عمومی: «شورای شهر»، «شورا»، «کمیسیون خدمات شهری»، «کمیسیون اقتصادی» → عمومی
68
- - نقش‌های شغلی بدون نام: «حسابرس مستقل»، «بازرس» بدون نام بعدی → عنوان شغلی
69
- - توصیف‌های عمومی شرکت: «این شرکت بازرگانی»، «این شرکت فناوری اطلاعات»، «یک بانک خصوصی» → توصیف بدون نام
70
- - وزارتخانه‌ها: «وزارت نفت»، «وزارت اقتصاد» → نهاد عمومی شناخته‌شده
71
- - «صندوق دولت»، «خزانه دولت» → عمومی
72
- - کلمات عمومی: «بانک‌های کشور»، «این بانک»، «12 بانک کشور»، «سه شرکت»، «پیمانکار» (بدون نام)
73
-
74
- ### قوانین amount (مبلغ + واحد = یک موجودیت):
75
- ✅ «100 میلیون دلار» → amount-01 ❌ «amount-01 دلار»
76
- ✅ «283 ریال» → amount-01 ❌ «amount-01 ریال»
77
- ✅ «41.5 همت» → amount-01
78
- ✅ «1,429,349 میلیون ریال» → amount-01
79
- ✅ «320,000 تن» → amount-01
80
- ✅ «3.4 میلیارد دلار» → amount-01
81
- ✅ «15 نفر» → amount-01 (تعداد افراد مشخص)
82
- ✅ «۱۲ فقره» → amount-01
83
- ❌ «5 سال حبس»، «36 ضربه شلاق»، «3 سال انفصال» → مجازات نه مبلغ مالی، ناشناس نشود
84
- ❌ «هزاران نفر»، «صدها شاکی» → عبارت تقریبی کمی، ناشناس نشود
85
-
86
- ### قوانین percent (عدد + درصد = یک موجودیت):
87
- ✅ «80 درصد» → percent-01 ❌ «percent-01 درصد»
88
- ✅ «14%» → percent-01
89
- ✅ «منفی 345 درصد» → percent-01 ❌ «منفی percent-01»
90
- ✅ «37 درصدی» → percent-01
91
-
92
- ### بازه‌ها (یک توکن برای کل بازه):
93
- ✅ «50 الی 70 درصد» → percent-01 ❌ «percent-01 الی percent-02»
94
- ✅ «40–60٪» → percent-01 ❌ «percent-01–percent-02»
95
- ✅ «12 تا 18 ماه» → amount-01 ❌ «amount-01 تا amount-02»
96
- ✅ «یک تا 1.5 میلیون تن» → amount-01
97
-
98
- ### موارد که باید حفظ شوند (ناشناس نشوند):
99
- - تاریخ: «30 آذر 1403»، «1403/04/12»، «1404/04/29»
100
- - مکان: تهران، اصفهان، ایران، خوزستان، عسلویه
101
- - میدان‌های نفتی/گازی: پارس جنوبی، فرزاد ب، آزادگان
102
- - زمان: «راس ساعت 10:00»، «روز سه شنبه»، «مردادماه»
103
- - دوره زمانی: «۹ ماهه»، «سال مالی منتهی به»، «سه‌ماهه نخست»
104
- - عناوین شغلی: مدیرعامل، رئیس کل، بازرس قانونی، حسابرس، دادستان، وزیر
105
- - مجازات قضایی: سال حبس، ضربه شلاق، سال انفصال
106
- - نماد بورسی → با company-XX جایگزین شود اگر نام شرکت مربوطه در متن نیست؛ در غیر این صورت همان توکن شرکت
107
-
108
- ### فرمت خروجی نهایی (بعد از thinking):
109
- {
110
- "anonymized": "متن ناشناس شده اینجا",
111
- "mapping": {"company-01": "نام کامل", "amount-01": "عدد+واحد", ...}
112
- }"""
113
-
114
-
115
- # ─────────────────────────────────────────────────────────────
116
- # few-shot examples — از باگ‌های واقعی شناسایی‌شده
117
- # ─────────────────────────────────────────────────────────────
118
- FEW_SHOT_EXAMPLES = """
119
- === EXAMPLES ===
120
-
121
- EXAMPLE 1 — نام مختصر + نام در پرانتز + تکرار:
122
- INPUT: شرکت گروه توسعه مالی مهر آیندگان (ومهان) رشد 14 درصدی داشت. سرمایه‌گذاری‌های ومهان به 16 هزار و 495 میلیارد تومان رسید.
123
- OUTPUT json:
124
- {"anonymized": "company-01 رشد percent-01 داشت. سرمایه‌گذاری‌های company-01 به amount-01 رسید.", "mapping": {"company-01": "شرکت گروه توسعه مالی مهر آیندگان (ومهان)", "percent-01": "14 درصد", "amount-01": "16 هزار و 495 میلیارد تومان"}}
125
- KEY: «ومهان» = company-01 (same token, NOT company-02)
126
-
127
- EXAMPLE 2 — نام کوتاه متفاوت برای شرکت‌های متفاوت:
128
- INPUT: مجمع شرکت پتروشیمی بوعلی سینا برگزار شد و وانیا نیک تدبیر بازرس شد. هزینه بوعلی 100 میلیون دلار بود. تحلیل شپنا (شرکت پالایش نفت اصفهان) نشان می‌دهد EPS به 936 ریال برسد.
129
- OUTPUT json:
130
- {"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 ریال"}}
131
- KEY: «بوعلی» = company-01. «شپن��» = company-03 (شرکت پالایش نفت اصفهان، موجودیت جداگانه از بوعلی)
132
-
133
- EXAMPLE 3 — کلمات عمومی ناشناس نشوند + بانک‌های مشخص:
134
- INPUT: دو بانک ملت و پاسارگاد سود 157 و 155 هزار میلیارد ریال داشتند. مجموع بانک‌های مورد بررسی زیان 1388 هزار میلیارد ریال داشتند که 10 درصد افزایش یافت. 12 بانک کشور زیان 336 هزار میلیارد تومانی رقم زدند.
135
- OUTPUT json:
136
- {"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 هزار میلیارد تومانی"}}
137
- KEY: «بانک‌های مورد بررسی» و «12 بانک کشور» = generic → ناشناس نشوند
138
-
139
- EXAMPLE 4 — نام چندکلمه‌ای با مکان + بازه درصد:
140
- INPUT: شرکت فولاد مبارکه اصفهان با شرکت ملی نفت ایران قرارداد امضا کرد. شرکت فاما سرمایه را از 8,700 میلیارد ریال به 12,500 میلیارد ریال افزایش می‌دهد. سهم سودهای ارزی 40 الی 60 درصد است.
141
- OUTPUT json:
142
- {"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 درصد"}}
143
- KEY: «اصفهان» داخل company-01. «شرکت فاما» = company-03. بازه «40 الی 60 درصد» = یک توکن percent-01
144
-
145
- EXAMPLE 5 — چند شرکت هم‌نام + مبالغ کوچک + درصد با %:
146
- INPUT: شرکت بیمه پارسیان از شرکت سرمایه گذاری پارسیان 1,429,349 میلیون ریال سود شناسایی کرد که 89 ریال برای هر سهم است. جواد شکرخواه مدیرعامل بانک پارسیان گفت سود 41.5 همت شد و 99.99 درصد سهام در اختیار است.
147
- OUTPUT json:
148
- {"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 درصد"}}
149
- KEY: واحد داخل توکن. مبالغ کوچک ریال هم ناشناس می‌شوند.
150
-
151
- EXAMPLE 6 — نام مختصر + پادرو + تیپیکو + شپنا:
152
- INPUT: شرکت سرمایه‌گذاری دارویی تأمین (تیپیکو) درآمد 681,667 میلیارد ریال داشت. صورت‌های مالی شرکت آسان پادرو 6 میلیارد تومان زیان نشان داد. پادرو 30 میلیارد تومان درآمد کسب کرد. شپنا EPS 936 ریال گزارش داد.
153
- OUTPUT json:
154
- {"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 ریال"}}
155
- KEY: «تیپیکو» = company-01. «پادرو» = company-02 (همان شرکت آسان پادرو). «شپنا» = company-03 (شرکت پالایش نفت اصفهان)
156
-
157
- EXAMPLE 7 — میدان نفتی/گازی = مکان نه شرکت + این شرکت = همان توکن:
158
- INPUT: قرارداد توسعه میدان گازی پارس جنوبی میان شرکت ملی نفت ایران و گروه پتروپارس به ارزش 20 میلیارد دلار امضا شد. این شرکت تا پایان سال تولید را افزایش خواهد داد.
159
- OUTPUT json:
160
- {"anonymized": "قرارداد توسعه میدان گازی پارس جنوبی میان company-01 و company-02 به ارزش amount-01 امضا شد. company-02 تا پایان سال تولید را افزایش خواهد داد.", "mapping": {"company-01": "شرکت ملی نفت ایران", "company-02": "گروه پتروپارس", "amount-01": "20 میلیارد دلار"}}
161
- KEY: «پارس جنوبی» = میدان گازی (مکان) → ناشناس نشود. «این شرکت» = company-02 (همان گروه پتروپارس). وزارت نفت اگر ذکر شود = ناشناس نشود.
162
-
163
- EXAMPLE 8 — توصیف بدون نام خاص + واحد داخلی + حسابرس بدون نام + مجازات قضایی:
164
- INPUT: طبق گزارش واحد حسابرسی داخلی، این شرکت بازرگانی مبلغ 78 میلیارد تومان اختلاف مالیاتی دارد. حسابرس مستقل تأیید کرد. شورای شهر درخواست بررسی کرد. متهم به 5 سال حبس و 36 ضربه شلاق محکوم شد.
165
- OUTPUT json:
166
- {"anonymized": "طبق گزارش واحد حسابرسی داخلی، این شرکت بازرگانی مبلغ amount-01 اختلاف مالیاتی دارد. حسابرس مستقل تأیید کرد. شورای شهر درخواست بررسی کرد. متهم به 5 سال حبس و 36 ضربه شلاق محکوم شد.", "mapping": {"amount-01": "78 میلیارد تومان"}}
167
- KEY: «واحد حسابرسی داخلی» = بخش داخلی، ناشناس نشود. «این شرکت بازرگانی» = توصیف بدون نام خاص، ناشناس نشود. «حسابرس مستقل» = عنوان شغلی بدون نام. «شورای شهر» = عمومی. «5 سال حبس»، «36 ضربه شلاق» = مجازات، نه مبلغ مالی.
168
-
169
- EXAMPLE 9 — نماد بورسی (وکغدیر) = همان شرکت + «این شرکت» ارجاع:
170
- INPUT: دکتر علی رضایی مدیرعامل هلدینگ صنایع و معادن غدیر گفت سال مالی وکغدیر به پایان رسید و سود خالص این شرکت 5 هزار میلیارد تومان شد که 120 درصد رشد داشت.
171
- OUTPUT json:
172
- {"anonymized": "person-01 مدیرعامل company-01 گفت سال مالی company-01 به پایان رسید و سود خالص company-01 amount-01 شد که percent-01 رشد داشت.", "mapping": {"person-01": "دکتر علی رضایی", "company-01": "هلدینگ صنایع و معادن غدیر", "amount-01": "5 هزار میلیارد تومان", "percent-01": "120 درصد"}}
173
- KEY: «وکغدیر» = نماد بورسی هلدینگ غدیر = company-01 (نه company-02). «این شرکت» = company-01 (ارجاع به همان موجودیت).
174
-
175
- === END EXAMPLES ===
176
- """
177
-
178
-
179
- # ─────────────────────────────────────────────────────────────
180
- # ساخت prompt
181
- # ─────────────────────────────────────────────────────────────
182
-
183
- def build_single_call_prompt(text: str, entities: list) -> str:
184
- """
185
- یک prompt = یک call
186
- Thinking mode فعال — برای دقت بالا (نسخه ۹۰٪+)
187
- """
188
- active = []
189
- if "company" in entities: active.append("company-XX (همه سازمان‌ها)")
190
- if "person" in entities: active.append("person-XX (نام اشخاص)")
191
- if "amount" in entities: active.append("amount-XX (اعداد+واحد)")
192
- if "percent" in entities: active.append("percent-XX (درصدها)")
193
-
194
- mapping_hints = []
195
- if "person" in entities: mapping_hints.append('"person-XX": "نام کامل"')
196
- if "company" in entities: mapping_hints.append('"company-XX": "نام کامل سازمان"')
197
- if "amount" in entities: mapping_hints.append('"amount-XX": "عدد + واحد کامل"')
198
- if "percent" in entities: mapping_hints.append('"percent-XX": "عدد + درصد/% کامل"')
199
-
200
- return f"""{FEW_SHOT_EXAMPLES}
201
-
202
- موجودیت‌های فعال: {' | '.join(active)}
203
-
204
- متن زیر را ناشناس کن. ابتدا در <thinking> تحلیل کن، سپس JSON نهایی بده:
205
-
206
- فرمت خروجی نهایی (بعد از </thinking>):
207
- {{
208
- "anonymized": "متن ناشناس شده",
209
- "mapping": {{ {", ".join(mapping_hints)} }}
210
- }}
211
-
212
- متن:
213
- {text}"""
214
-
215
-
216
- def build_verification_prompt(
217
- original_text: str,
218
- anonymized_text: str,
219
- current_mapping: Dict[str, str],
220
- entities: list
221
- ) -> str:
222
- """
223
- پاس تأیید: متن اصلی و ناشناس‌شده رو مقایسه کن
224
- موجودیت‌های جامانده رو پیدا کن
225
- """
226
- active = []
227
- if "company" in entities: active.append("company-XX (سازمان/شرکت/بانک)")
228
- if "person" in entities: active.append("person-XX (نام اشخاص)")
229
- if "amount" in entities: active.append("amount-XX (عدد+واحد)")
230
- if "percent" in entities: active.append("percent-XX (درصد)")
231
-
232
- # لیست توکن‌های فعلی
233
- existing_tokens = ""
234
- if current_mapping:
235
- existing_lines = [f" {tok} → {val}" for tok, val in sorted(current_mapping.items())]
236
- existing_tokens = "\n".join(existing_lines)
237
-
238
- # بالاترین شماره هر نوع برای ادامه شماره‌گذاری
239
- next_numbers = {}
240
- for etype in entities:
241
- nums = [int(t.split("-")[1]) for t in current_mapping.keys() if t.startswith(f"{etype}-")]
242
- next_numbers[etype] = max(nums) + 1 if nums else 1
243
-
244
- next_info = ", ".join(f"{e}: از {next_numbers[e]:02d}" for e in entities if e in next_numbers)
245
-
246
- return f"""تو یک بازبین ناشناس‌سازی هستی. متن اصلی و نسخه ناشناس‌شده رو مقایسه کن.
247
-
248
- موجودیت‌های فعال: {' | '.join(active)}
249
-
250
- === توکن‌های فعلی ===
251
- {existing_tokens}
252
-
253
- === متن اصلی ===
254
- {original_text}
255
-
256
- === متن ناشناس‌شده فعلی ===
257
- {anonymized_text}
258
-
259
- وظیفه: متن اصلی و ناشناس‌شده رو کلمه‌به‌کلمه مقایسه کن.
260
- - فقط موجودیت‌هایی که ۱۰۰٪ مطمئنی جا مانده‌اند را اضافه کن.
261
- - اگر شک داری، اضافه نکن. بهتره یک موجودیت جا بمونه تا اینکه کلمه عادی ناشناس بشه.
262
- - اگر نام مختصر یکی از توکن‌های موجود است → از همان توکن استفاده کن.
263
- - شماره‌گذاری توکن‌های جدید: {next_info}
264
-
265
- مهم — ناشناس نکن:
266
- - تاریخ، مکان، نام کشور، نام شهر
267
- - عنوان شغلی: مدیرعامل، رئیس، وزیر، معاون
268
- - کلمات عمومی: «بانک‌ها»، «شرکت‌ها»، «این بانک»، «12 بانک کشور»
269
- - واحد مبلغ داخل توکن: ✅ amount-XX (شامل عدد+واحد) ❌ amount-XX ریال
270
- - واحد درصد داخل توکن: ✅ percent-XX (شامل عدد+درصد) ❌ percent-XX درصد
271
-
272
- خروجی: فقط JSON (بدون توضیح):
273
- {{
274
- "fixed_text": "متن ناشناس‌شده اصلاح‌شده با توکن‌های جدید",
275
- "new_mapping": {{"token": "مقدار اصلی"}}
276
- }}
277
-
278
- اگر هیچ موجودیتی جا نمانده یا مطمئن نیستی:
279
- {{
280
- "fixed_text": "",
281
- "new_mapping": {{}}
282
- }}
283
-
284
- مثال — اینها جا نمانده‌اند (ناشناس نکن):
285
- ❌ «تهران» → مکان است، ناشناس نشود
286
- ❌ «مدیرعامل» → عنوان شغلی
287
- ❌ «بانک‌های مورد بررسی» → عمومی
288
- ❌ «12 بانک کشور» → عمومی
289
- ❌ «سال ۱۴۰۳» → تاریخ"""
290
-
291
-
292
- def build_analysis_prompt(anonymized_text: str, analysis_prompt: str, entities: list) -> str:
293
- tokens = []
294
- if "person" in entities: tokens.append("person-XX")
295
- if "company" in entities: tokens.append("company-XX")
296
- if "amount" in entities: tokens.append("amount-XX")
297
- if "percent" in entities: tokens.append("percent-XX")
298
-
299
- return f"""متن ناشناس‌سازی شده:
300
- {anonymized_text}
301
-
302
- دستورات:
303
- {analysis_prompt}
304
-
305
- قوانین:
306
- - فقط از توکن‌های موجود استفاده کن: {', '.join(tokens)}
307
- - هیچ کلمه‌ای قبل/بعد از توکن‌ها اضافه نکن
308
- - توکن جدید ایجاد نکن"""
309
-
310
-
311
- # ─────────────────────────────────────────────────────────────
312
- # توابع کمکی
313
- # ─────────────────────────────────────────────────────────────
314
-
315
- def strip_thinking(text: str) -> str:
316
- """
317
- حذف بلوک‌های think/thinking از خروجی
318
- thinking mode فعال است — برای دقت استفاده می‌شود ولی در خروجی نهایی نمی‌آید
319
- """
320
- if not text:
321
- return text
322
- # تگ‌های Qwen3 thinking
323
- text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
324
- # تگ‌های نسخه قدیمی
325
- text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL)
326
- return text.strip()
327
-
328
-
329
- def parse_json_response(raw: str) -> dict:
330
- """parse JSON مقاوم — thinking block + markdown fence"""
331
- raw = strip_thinking(raw)
332
- raw = re.sub(r"```(?:json)?", "", raw).replace("```", "").strip()
333
- start = raw.find("{")
334
- end = raw.rfind("}") + 1
335
- if start == -1 or end == 0:
336
- raise ValueError("JSON یافت نشد")
337
- return json.loads(raw[start:end])
338
-
339
-
340
- def post_deepinfra(prompt: str, system: str, max_tokens: int = 6000, timeout: int = 60) -> str:
341
- """
342
- DeepInfra Qwen3-14B
343
- Thinking mode فعال — برای دقت بالا
344
- timeout = (connect, read) — سریع شناسایی مشکل اتصال
345
- """
346
- api_key = os.getenv("DEEPINFRA_API_KEY")
347
- if not api_key:
348
- raise ValueError("DEEPINFRA_API_KEY موجود نیست")
349
-
350
- # connect: 10s | read: بقیه timeout
351
- connect_timeout = 10
352
- read_timeout = max(timeout - connect_timeout, 30)
353
-
354
- resp = requests.post(
355
- ANON_API_URL,
356
- headers={
357
- "Authorization": f"Bearer {api_key}",
358
- "Content-Type": "application/json"
359
- },
360
- json={
361
- "model": ANON_MODEL,
362
- "messages": [
363
- {"role": "system", "content": system},
364
- {"role": "user", "content": prompt}
365
- ],
366
- "max_tokens": max_tokens,
367
- "temperature": 0.3,
368
- "top_p": 0.9,
369
- },
370
- timeout=(connect_timeout, read_timeout)
371
- )
372
-
373
- if resp.status_code != 200:
374
- raise Exception(f"DeepInfra {resp.status_code}: {resp.text[:300]}")
375
-
376
- content = resp.json()["choices"][0]["message"]["content"]
377
- if "<think>" in content or "<thinking>" in content:
378
- thinking = re.search(r"<think(?:ing)?>(.*?)</think(?:ing)?>", content, re.DOTALL)
379
- if thinking:
380
- logger.info(f"🧠 Thinking ({len(thinking.group(1))} chars)...")
381
-
382
- return strip_thinking(content)
383
-
384
-
385
- # ─────────────────────────────────────────────────────────────
386
- # تخمین هوشمند max_tokens و Chunking
387
- # ─────────────────────────────────────────────────────────────
388
-
389
- # آستانه‌های chunking
390
- CHUNK_MAX_CHARS = 900 # حداکثر کاراکتر هر chunk
391
- CHUNK_MAX_ENTITIES = 12 # حداکثر موجودیت تخمینی هر chunk
392
- LONG_TEXT_THRESHOLD = 1200 # بالای این → chunking فعال
393
-
394
- # ── حالت batch سریع ──
395
- # True = CSV batch: retry=1، timeout=45s، بدون verification pass → سریع
396
- # False = UI تک متن: retry=2، timeout=60/90s، با verification pass → دقیق
397
- BATCH_FAST_MODE = False
398
-
399
- def estimate_entity_count(text: str) -> int:
400
- """تخمین تعداد موجودیت‌ها بر اساس نشانه‌های متن"""
401
- markers = 0
402
- # شرکت‌ها
403
- markers += len(re.findall(r'(?:شرکت|بانک|سازمان|گروه|هلدینگ|صندوق|بیمه|پتروشیمی|سرمایه\s*گذاری)\s+', text))
404
- # اشخاص (نام و نام خانوادگی فارسی)
405
- markers += len(re.findall(r'(?:آقای|خانم|دکتر|مهندس|حجت\s*الاسلام)\s+', text))
406
- markers += len(re.findall(r'[ء-ی]+\s+[ء-ی]+(?:‌?زاده|‌?پور|‌?نژاد|‌?فر)', text))
407
- # مبالغ
408
- markers += len(re.findall(r'[\d۰-۹][,،\d۰-۹]*(?:\.\d+)?\s*(?:میلیارد|میلیون|هزار|همت|تومان|ریال|دلار|یورو)', text))
409
- markers += len(re.findall(r'[\d۰-۹][,،\d۰-۹]*\s*(?:نفر|تن|دستگاه|واحد|فقره)', text))
410
- # درصدها
411
- markers += len(re.findall(r'[\d۰-۹]+(?:\.\d+)?\s*(?:درصد|%|٪)', text))
412
- return max(markers, 1)
413
-
414
-
415
- def estimate_max_tokens(text: str) -> int:
416
- """محاسبه max_tokens بر اساس طول متن و تعداد موجودیت تخمینی"""
417
- entity_est = estimate_entity_count(text)
418
- text_len = len(text)
419
-
420
- # thinking ≈ 1500-3000 tok + JSON output ≈ text_len*0.8 + mapping ≈ entity*80
421
- base_thinking = 2500
422
- json_output = int(text_len * 0.7)
423
- mapping_space = entity_est * 100
424
- buffer = 1500
425
-
426
- tokens = base_thinking + json_output + mapping_space + buffer
427
- # حداقل 6000، حداکثر 16000
428
- return max(6000, min(tokens, 16000))
429
-
430
-
431
- def split_text_to_chunks(text: str, max_chars: int = CHUNK_MAX_CHARS) -> List[str]:
432
- """
433
- تقسیم متن به chunk‌های کوچکتر بر اساس پاراگراف و جمله
434
- """
435
- # اول تقسیم بر اساس خط جدید
436
- paragraphs = re.split(r'\n+', text)
437
- paragraphs = [p.strip() for p in paragraphs if p.strip()]
438
-
439
- # اگر فقط یک پاراگراف بلند داریم، بر اساس نقطه تقسیم کن
440
- if len(paragraphs) == 1 and len(paragraphs[0]) > max_chars:
441
- sentences = re.split(r'(?<=[.،؛!؟\n])\s+', paragraphs[0])
442
- paragraphs = [s.strip() for s in sentences if s.strip()]
443
-
444
- chunks = []
445
- current_chunk = ""
446
-
447
- for para in paragraphs:
448
- # اگه پاراگراف خودش بلندتر از حد هست
449
- if len(para) > max_chars:
450
- if current_chunk:
451
- chunks.append(current_chunk.strip())
452
- current_chunk = ""
453
- # تقسیم بر اساس جمله
454
- sents = re.split(r'(?<=[.،؛!؟])\s+', para)
455
- sub_chunk = ""
456
- for s in sents:
457
- if len(sub_chunk) + len(s) + 1 <= max_chars:
458
- sub_chunk += (" " if sub_chunk else "") + s
459
- else:
460
- if sub_chunk:
461
- chunks.append(sub_chunk.strip())
462
- sub_chunk = s
463
- if sub_chunk:
464
- chunks.append(sub_chunk.strip())
465
- elif len(current_chunk) + len(para) + 1 <= max_chars:
466
- current_chunk += ("\n" if current_chunk else "") + para
467
- else:
468
- if current_chunk:
469
- chunks.append(current_chunk.strip())
470
- current_chunk = para
471
-
472
- if current_chunk:
473
- chunks.append(current_chunk.strip())
474
-
475
- # اگر هنوز خالیه، کل متن رو برگردون
476
- if not chunks:
477
- chunks = [text]
478
-
479
- return chunks
480
-
481
-
482
- def merge_chunk_mappings(
483
- chunk_results: List[Tuple[str, Dict]],
484
- entities: List[str]
485
- ) -> Tuple[str, Dict]:
486
- """
487
- ادغام نتایج chunk‌ها + بازشماره‌گذاری توکن‌ها به صورت سراسری
488
- """
489
- merged_text = ""
490
- merged_mapping = {}
491
- global_counters = {e: 0 for e in entities}
492
-
493
- for chunk_text, chunk_mapping in chunk_results:
494
- if not chunk_mapping:
495
- merged_text += ("\n" if merged_text else "") + chunk_text
496
- continue
497
-
498
- # نگاشت توکن‌های محلی chunk به شماره سراسری
499
- local_to_global = {}
500
-
501
- # مرتب‌سازی بر اساس ترتیب ظاهر شدن در متن
502
- sorted_tokens = sorted(
503
- chunk_mapping.keys(),
504
- key=lambda t: (t.split("-")[0], int(t.split("-")[1]))
505
- )
506
-
507
- for local_token in sorted_tokens:
508
- original_value = chunk_mapping[local_token]
509
- etype = local_token.split("-")[0]
510
-
511
- # بررسی آیا این مقدار قبلاً در mapping سراسری هست
512
- existing_token = None
513
- for g_token, g_value in merged_mapping.items():
514
- if g_value == original_value and g_token.startswith(etype):
515
- existing_token = g_token
516
- break
517
-
518
- if existing_token:
519
- local_to_global[local_token] = existing_token
520
- else:
521
- global_counters[etype] += 1
522
- new_token = f"{etype}-{global_counters[etype]:02d}"
523
- local_to_global[local_token] = new_token
524
- merged_mapping[new_token] = original_value
525
-
526
- # جایگزینی توکن‌های محلی با سراسری در متن chunk
527
- renamed_text = chunk_text
528
- # از بلندترین شروع کن تا تداخل نباشه
529
- for local_token in sorted(local_to_global.keys(), key=len, reverse=True):
530
- global_token = local_to_global[local_token]
531
- if local_token != global_token:
532
- renamed_text = renamed_text.replace(local_token, global_token)
533
-
534
- merged_text += ("\n" if merged_text else "") + renamed_text
535
-
536
- return merged_text, merged_mapping
537
-
538
-
539
- # ─────────────────────────────────────────────────────────────
540
- # کلاس اصلی
541
- # ─────────────────────────────────────────────────────────────
542
- class AnonymizerAdvanced:
543
-
544
- def __init__(
545
- self,
546
- llm_provider: str = "chatgpt",
547
- llm_model: str = None,
548
- entities_to_anonymize: List[str] = None
549
- ):
550
- self.llm_provider = llm_provider
551
- self.llm_model = llm_model
552
- self.entities_to_anonymize = entities_to_anonymize or ["person", "company", "amount", "percent"]
553
- self.mapping_table: Dict[str, str] = {}
554
- self.reverse_mapping: Dict[str, str] = {}
555
- self._create_llm_sender()
556
- logger.info(f"✅ Anonymizer — {llm_provider}")
557
-
558
- # ── LLM sender (تحلیل) ──────────────────────────────────
559
-
560
- def _create_llm_sender(self):
561
- try:
562
- key_map = {
563
- "chatgpt": os.getenv("OPENAI_API_KEY"),
564
- "grok": os.getenv("XAI_API_KEY"),
565
- "deepinfra": os.getenv("DEEPINFRA_API_KEY"),
566
- }
567
- self.llm_sender = create_llm_sender(
568
- provider=self.llm_provider,
569
- api_key=key_map.get(self.llm_provider),
570
- model=self.llm_model
571
- )
572
- logger.info(f"✅ LLM Sender: {self.llm_provider} — {self.llm_sender.model}")
573
- except Exception as e:
574
- logger.error(f"❌ LLM Sender خطا: {e}")
575
- self.llm_sender = create_llm_sender("chatgpt")
576
-
577
- def set_llm_provider(self, provider: str, model: str = None, entities: List[str] = None):
578
- self.llm_provider = provider
579
- self.llm_model = model
580
- if entities is not None:
581
- self.entities_to_anonymize = entities
582
- self._create_llm_sender()
583
-
584
- # ── ناشناس‌سازی — thinking فعال، retry + chunking + verify ──
585
-
586
- def anonymize(self, text: str) -> Tuple[str, Dict]:
587
- """
588
- ناشناس‌سازی هوشمند:
589
- 1. متن کوتاه → single call
590
- 2. متن طولانی → chunking
591
- 3. هر call با retry
592
- 4. پاس تأیید (verification) برای پیدا کردن موجودیت‌های جامانده
593
- """
594
- if not self.entities_to_anonymize:
595
- return text, {}
596
-
597
- text_len = len(text)
598
- entity_est = estimate_entity_count(text)
599
-
600
- logger.info(f"🧠 ورودی: {text_len} char | ~{entity_est} موجودیت تخمینی")
601
-
602
- # ── مرحله ۱: ناشناس‌سازی اولیه ──
603
- needs_chunking = (
604
- text_len > LONG_TEXT_THRESHOLD or
605
- entity_est > CHUNK_MAX_ENTITIES
606
- )
607
-
608
- if needs_chunking:
609
- anon_text, mapping = self._anonymize_chunked(text)
610
- else:
611
- anon_text, mapping = self._anonymize_single(text)
612
-
613
- # ── مرحله ۲: پاس تأیید ──
614
- if anon_text and anon_text.strip() != text.strip():
615
- anon_text, mapping = self._verification_pass(text, anon_text, mapping)
616
-
617
- return anon_text, mapping
618
-
619
- def _verification_pass(
620
- self,
621
- original_text: str,
622
- anonymized_text: str,
623
- current_mapping: Dict[str, str]
624
- ) -> Tuple[str, Dict]:
625
- """
626
- پاس تأیید محافظه‌کارانه:
627
- فقط وقتی اجرا شو که شواهد قوی از miss وجود داره
628
- """
629
- found_count = len(current_mapping)
630
- entity_est = estimate_entity_count(original_text)
631
-
632
- # ── تصمیم: آیا verify لازمه؟ فقط وقتی شکاف بزرگ باشه ──
633
- # شرط ۱: تعداد تخمینی خیلی بیشتر از یافته‌ها باشه (حداقل ۳ تا شکاف)
634
- # شرط ۲: متن طولانی باشه (بالای ۸۰۰ کاراکتر)
635
- gap = entity_est - found_count
636
- # در حالت batch سریع: verification کاملاً skip می‌شود
637
- needs_verify = (
638
- not BATCH_FAST_MODE and
639
- found_count > 0 and
640
- gap >= 3 and
641
- len(original_text) > 800
642
- )
643
-
644
- if not needs_verify:
645
- logger.info(f" ⏭️ Verify skip: {found_count} found, ~{entity_est} est, gap={gap}")
646
- return anonymized_text, current_mapping
647
-
648
- logger.info(f" 🔍 Verification Pass: {found_count} یافته، ~{entity_est} تخمینی، gap={gap}")
649
-
650
- try:
651
- prompt = build_verification_prompt(
652
- original_text, anonymized_text,
653
- current_mapping, self.entities_to_anonymize
654
- )
655
-
656
- raw = post_deepinfra(
657
- prompt,
658
- "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.",
659
- max_tokens=estimate_max_tokens(original_text),
660
- timeout=60
661
- )
662
-
663
- result = parse_json_response(raw)
664
- fixed_text = result.get("fixed_text", "")
665
- new_mapping = result.get("new_mapping", {})
666
-
667
- # اگه مدل گفت هیچی جا نمونده
668
- if not fixed_text or not new_mapping:
669
- logger.info(f" ✅ Verify: هیچ موجودیت جامانده‌ای یافت نشد")
670
- return anonymized_text, current_mapping
671
-
672
- # ── اعتبارسنجی سختگیرانه ──
673
-
674
- # ۱) توکن‌های قبلی نباید حذف شن
675
- old_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', anonymized_text))
676
- new_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', fixed_text))
677
- lost_tokens = old_tokens - new_tokens
678
- if lost_tokens:
679
- logger.warning(f" ⚠️ Verify reject: {len(lost_tokens)} توکن قبلی حذف شده: {lost_tokens}")
680
- return anonymized_text, current_mapping
681
-
682
- # ۲) تعداد توکن‌های جدید نباید خیلی زیاد باشه (حداکثر ۲ برابر gap)
683
- added_tokens = new_tokens - old_tokens
684
- max_allowed_new = min(gap * 2, 10)
685
- if len(added_tokens) > max_allowed_new:
686
- logger.warning(f" ⚠️ Verify reject: {len(added_tokens)} توکن جدید > حد مجاز {max_allowed_new}")
687
- return anonymized_text, current_mapping
688
-
689
- # ۳) new_mapping فقط شامل توکن‌های معتبر باشه
690
- valid_new_mapping = {}
691
- for token, value in new_mapping.items():
692
- if not re.match(r'(company|person|amount|percent)-\d+', token):
693
- continue
694
- if token in current_mapping:
695
- continue # تکراری
696
- if token not in new_tokens:
697
- continue # در متن نیست
698
- # مقدار نباید خیلی کوتاه باشه
699
- if len(str(value).strip()) < 2:
700
- continue
701
- valid_new_mapping[token] = value
702
-
703
- if not valid_new_mapping:
704
- logger.info(f" ✅ Verify: mapping معتبر جدیدی نیست")
705
- return anonymized_text, current_mapping
706
-
707
- # ادغام mapping
708
- merged_mapping = dict(current_mapping)
709
- merged_mapping.update(valid_new_mapping)
710
-
711
- # پاک‌سازی نهایی
712
- final_tokens = set(re.findall(r'(?:company|person|amount|percent)-\d+', fixed_text))
713
- merged_mapping = {k: v for k, v in merged_mapping.items() if k in final_tokens}
714
-
715
- self.mapping_table = merged_mapping
716
- self.reverse_mapping = {v: k for k, v in merged_mapping.items()}
717
-
718
- logger.info(f" ✅ Verify: +{len(valid_new_mapping)} موجودیت | کل: {len(merged_mapping)}")
719
- return fixed_text, merged_mapping
720
-
721
- except Exception as e:
722
- logger.warning(f" ⚠️ Verify fail: {e} — نتیجه اولیه حفظ می‌شود")
723
- return anonymized_text, current_mapping
724
-
725
- def _anonymize_single(self, text: str, max_retries: int = 2, is_chunk: bool = False) -> Tuple[str, Dict]:
726
- """
727
- ناشناس‌سازی تک‌تکه با retry سریع
728
- is_chunk=True → بدون fallback به chunking (جلوگیری از حلقه بازگشتی)
729
- """
730
- base_max_tokens = estimate_max_tokens(text)
731
-
732
- # حالت سریع: retry=1 و timeout=45s
733
- if BATCH_FAST_MODE:
734
- max_retries = 1
735
- base_timeout = 45
736
- else:
737
- base_timeout = 60
738
-
739
- for attempt in range(max_retries):
740
- current_max_tokens = min(base_max_tokens + (attempt * 1500), 16000)
741
- current_timeout = base_timeout + (attempt * 30) # fast:45s | normal:60s,90s
742
-
743
- logger.info(f" 🔄 تلاش {attempt+1}/{max_retries} | max_tokens={current_max_tokens} | timeout={current_timeout}s")
744
-
745
- prompt = build_single_call_prompt(text, self.entities_to_anonymize)
746
-
747
- try:
748
- raw = post_deepinfra(
749
- prompt, ANON_SYSTEM_PROMPT,
750
- max_tokens=current_max_tokens,
751
- timeout=current_timeout
752
- )
753
- logger.info(f" ✅ پاسخ: {len(raw)} کاراکتر")
754
-
755
- result = parse_json_response(raw)
756
- anonymized_text = result.get("anonymized", "")
757
- self.mapping_table = result.get("mapping", {})
758
-
759
- # ── بررسی کیفیت: آیا واقعاً ناشناس شده؟ ──
760
- if anonymized_text.strip() == text.strip():
761
- logger.warning(f" ⚠️ متن تغییر نکرده! (attempt {attempt+1})")
762
- if attempt < max_retries - 1:
763
- time.sleep(0.5)
764
- continue
765
- # آخرین تلاش fail شد
766
- if not is_chunk:
767
- logger.warning(" ⚠️ retry fail → chunking")
768
- return self._anonymize_chunked(text)
769
- else:
770
- return text, {} # در حالت chunk: متن اصلی برگرده
771
-
772
- # آیا mapping خالی هست ولی توکن در متن هست؟
773
- tokens_in_text = re.findall(r'(?:company|person|amount|percent)-\d+', anonymized_text)
774
- if tokens_in_text and not self.mapping_table:
775
- logger.warning(f" ⚠️ mapping خالی ولی {len(tokens_in_text)} توکن در متن!")
776
- if attempt < max_retries - 1:
777
- time.sleep(0.5)
778
- continue
779
-
780
- self._clean_orphan_tokens(anonymized_text)
781
- self._fix_mapping()
782
- self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
783
-
784
- for etype in self.entities_to_anonymize:
785
- found = sorted(set(re.findall(rf'{etype}-\d+', anonymized_text)))
786
- if found:
787
- logger.info(f" {etype}: {found}")
788
-
789
- logger.info(f" ✅ mapping: {len(self.mapping_table)} موجودیت")
790
- return anonymized_text, self.mapping_table
791
-
792
- except json.JSONDecodeError as e:
793
- logger.warning(f" ⚠️ JSON خطا (attempt {attempt+1}): {e}")
794
- if attempt < max_retries - 1:
795
- time.sleep(0.5)
796
- continue
797
- # آخرین تلاش: fallback
798
- if not is_chunk:
799
- logger.info(" 🔄 fallback دو-call...")
800
- try:
801
- return self._anonymize_fallback(text)
802
- except Exception:
803
- return self._anonymize_chunked(text)
804
- return text, {}
805
-
806
- except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
807
- logger.warning(f" ⏳ اتصال/Timeout (attempt {attempt+1}): {type(e).__name__}")
808
- if attempt < max_retries - 1:
809
- time.sleep(1)
810
- continue
811
- # timeout → chunking فقط اگه chunk نیستیم
812
- if not is_chunk:
813
- return self._anonymize_chunked(text)
814
- return text, {}
815
-
816
- except Exception as e:
817
- logger.error(f" ❌ Exception (attempt {attempt+1}): {e}")
818
- if attempt < max_retries - 1:
819
- time.sleep(0.5)
820
- continue
821
- if is_chunk:
822
- return text, {}
823
- raise
824
-
825
- # اگه هیچ‌کدوم جواب نداد
826
- if not is_chunk:
827
- logger.warning(" ⚠️ همه retry‌ها fail → chunking")
828
- return self._anonymize_chunked(text)
829
- return text, {}
830
-
831
- def _anonymize_chunked(self, text: str) -> Tuple[str, Dict]:
832
- """
833
- تقسیم متن به chunk و ناشناس‌سازی جداگانه + ادغام
834
- هر chunk فقط ۱ retry و بدون بازگشت به chunking
835
- """
836
- chunks = split_text_to_chunks(text, max_chars=CHUNK_MAX_CHARS)
837
- logger.info(f" 📦 Chunking: {len(chunks)} قطعه از {len(text)} کاراکتر")
838
-
839
- chunk_results = []
840
- for i, chunk in enumerate(chunks):
841
- logger.info(f" 📦 Chunk {i+1}/{len(chunks)}: {len(chunk)} char")
842
-
843
- # ریست mapping برای هر chunk
844
- self.mapping_table = {}
845
- self.reverse_mapping = {}
846
-
847
- try:
848
- # is_chunk=True → بدون fallback بازگشتی به chunking
849
- anon_chunk, mapping = self._anonymize_single(chunk, max_retries=2, is_chunk=True)
850
- chunk_results.append((anon_chunk, mapping))
851
- except Exception as e:
852
- logger.error(f" ❌ Chunk {i+1} fail: {e} — متن اصلی حفظ می‌شود")
853
- chunk_results.append((chunk, {}))
854
-
855
- time.sleep(0.3)
856
-
857
- # ادغام chunk‌ها
858
- merged_text, merged_mapping = merge_chunk_mappings(
859
- chunk_results, self.entities_to_anonymize
860
- )
861
-
862
- self.mapping_table = merged_mapping
863
- self.reverse_mapping = {v: k for k, v in merged_mapping.items()}
864
-
865
- logger.info(f" ✅ Chunked: {len(merged_mapping)} موجودیت از {len(chunks)} chunk")
866
- return merged_text, merged_mapping
867
-
868
- def _anonymize_fallback(self, text: str) -> Tuple[str, Dict]:
869
- """Fallback: دو call — اگر JSON parse شکست خورد"""
870
- logger.info("🔄 fallback: دو call...")
871
-
872
- rules_fa = (
873
- "متن زیر را ناشناس کن.\n"
874
- "- company-XX: نام کامل سازمان (بانک/شرکت/بیمه/پتروشیمی/...) — نام مختصر = همان توکن\n"
875
- "- person-XX: نام کامل اشخاص\n"
876
- "- amount-XX: عدد + واحد با هم\n"
877
- "- percent-XX: عدد + درصد با هم (بازه هم یک توکن)\n"
878
- "کلمات عمومی را دست نزن. فقط متن ناشناس شده."
879
- )
880
-
881
- prompt1 = f"{FEW_SHOT_EXAMPLES}\n{rules_fa}\n\nمتن:\n{text}"
882
- anonymized_text = post_deepinfra(prompt1, ANON_SYSTEM_PROMPT, max_tokens=4096, timeout=60)
883
-
884
- hints = []
885
- if "person" in self.entities_to_anonymize: hints.append('"person-XX": "نام کامل"')
886
- if "company" in self.entities_to_anonymize: hints.append('"company-XX": "نام کامل سازمان"')
887
- if "amount" in self.entities_to_anonymize: hints.append('"amount-XX": "عدد+واحد"')
888
- if "percent" in self.entities_to_anonymize: hints.append('"percent-XX": "عدد+درصد"')
889
-
890
- prompt2 = (
891
- f"متن اصلی: {text}\n"
892
- f"متن ناشناس: {anonymized_text}\n\n"
893
- f"فقط JSON mapping:\n{{ {', '.join(hints)} }}"
894
- )
895
-
896
- try:
897
- raw2 = post_deepinfra(prompt2, "Output ONLY valid JSON. No explanation.", max_tokens=2048, timeout=45)
898
- self.mapping_table = parse_json_response(raw2)
899
- except Exception:
900
- self._extract_mapping_fallback(text, anonymized_text)
901
-
902
- self._clean_orphan_tokens(anonymized_text)
903
- self._fix_mapping()
904
- self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
905
- return anonymized_text, self.mapping_table
906
-
907
- # ── پاک‌سازی mapping ──────��─────────────────────────────
908
-
909
- def _clean_orphan_tokens(self, anonymized_text: str):
910
- to_remove = [t for t in self.mapping_table if t not in anonymized_text]
911
- for t in to_remove:
912
- logger.info(f" 🗑️ توکن اضافی: {t}")
913
- del self.mapping_table[t]
914
-
915
- def _fix_mapping(self):
916
- """اطمینان از صحت مقادیر — فقط percent بدون واحد"""
917
- for token, value in list(self.mapping_table.items()):
918
- val = str(value).strip()
919
- if token.startswith("percent-") and not re.search(r"(درصد|%|٪|درصدی)", val):
920
- self.mapping_table[token] = f"{val} درصد"
921
- logger.info(f" اصلاح {token}: '{val}' → '{val} درصد'")
922
-
923
- # ── fallback mapping با regex ────────────────────────────
924
-
925
- def _extract_mapping_fallback(self, original: str, anonymized: str):
926
- pats: Dict[str, str] = {}
927
- if "person" in self.entities_to_anonymize:
928
- pats["person"] = r'(?<![ء-یa-zA-Z])[ء-ی]+\s+[ء-ی]+(?:\s+[ء-ی]+)*(?![ء-یa-zA-Z])'
929
- if "company" in self.entities_to_anonymize:
930
- pats["company"] = (
931
- r'(?:(?:شرکت|بانک|سازمان|گروه|هلدینگ|صندوق|بیمه|پتروشیمی|ملی|سرمایه\s*گذاری)\s+)'
932
- r'[ء-ی][ء-ی\s]+(?:\([ء-یa-zA-Z۰-۹]+\))?'
933
- )
934
- if "amount" in self.entities_to_anonymize:
935
- pats["amount"] = r'[\d۰-۹][,،\d۰-۹]*(?:\.\d+)?\s*(?:هزار\s+و\s+\d+|هزار|میلیون|میلیارد|همت)?\s*(?:میلیارد|میلیون|هزار|تومان|ریال|دلار|دستگاه|تن)?'
936
- if "percent" in self.entities_to_anonymize:
937
- pats["percent"] = r'[\d۰-۹]+(?:\.\d+)?\s*(?:الی|تا)?\s*(?:[\d۰-۹]+(?:\.\d+)?\s*)?(?:درصد|%|٪|درصدی)'
938
-
939
- orig_entities = {
940
- etype: [m.strip() for m in re.findall(pat, original) if m.strip()]
941
- for etype, pat in pats.items()
942
- }
943
-
944
- for etype in self.entities_to_anonymize:
945
- tokens = sorted(set(re.findall(rf'{etype}-(\d+)', anonymized)), key=int)
946
- values = orig_entities.get(etype, [])
947
- for tok_num in tokens:
948
- token = f"{etype}-{tok_num}"
949
- idx = int(tok_num) - 1
950
- self.mapping_table[token] = values[idx] if idx < len(values) else (values[-1] if values else token)
951
-
952
- self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
953
- logger.info(f"✅ Fallback mapping: {len(self.mapping_table)} موجودیت")
954
-
955
- # ── تحلیل LLM ───────────────────────────────────────────
956
-
957
- def analyze_with_llm(self, anonymized_text: str, analysis_prompt: str = None) -> str:
958
- logger.info(f"🤖 {self.llm_provider.upper()} تحلیل...")
959
-
960
- if not analysis_prompt or not analysis_prompt.strip():
961
- return "⚠️ هیچ دستور تحلیل داده نشده است"
962
-
963
- prompt = build_analysis_prompt(anonymized_text, analysis_prompt, self.entities_to_anonymize)
964
- try:
965
- response = self.llm_sender.send(prompt, lang="fa", temperature=0.2, max_tokens=2000)
966
- logger.info(f"✅ {self.llm_provider.upper()}: {len(response)} کاراکتر")
967
- return response
968
- except Exception as e:
969
- logger.error(f"❌ {self.llm_provider.upper()} Exception: {e}")
970
- return f"❌ خطا: {str(e)}"
971
-
972
- # ── بازگردانی ────────────────────────────────────────────
973
-
974
- def restore_text(self, anonymized_text: str) -> str:
975
- logger.info("🔄 بازگردانی...")
976
-
977
- if not self.mapping_table:
978
- return anonymized_text
979
-
980
- restored = self._normalize_tokens(anonymized_text)
981
- count = 0
982
-
983
- for placeholder, original in sorted(
984
- self.mapping_table.items(), key=lambda x: len(x[0]), reverse=True
985
- ):
986
- if placeholder in restored:
987
- restored = restored.replace(placeholder, original)
988
- count += 1
989
- logger.info(f" ✅ {placeholder} → {original[:40]}")
990
- else:
991
- logger.warning(f" ⚠️ {placeholder} یافت نشد")
992
-
993
- logger.info(f"✅ {count}/{len(self.mapping_table)} بازگردانی شد")
994
-
995
- if count < len(self.mapping_table):
996
- restored = self._restore_with_regex(restored)
997
-
998
- return restored
999
-
1000
- def _normalize_tokens(self, text: str) -> str:
1001
- normalized = text
1002
- unicode_hyphens = r'[\u2010\u2011\u2012\u2013\u2014\u2212]'
1003
- for etype in self.entities_to_anonymize:
1004
- normalized = re.sub(rf'{etype}{unicode_hyphens}(\d+)', rf'{etype}-\1', normalized)
1005
- normalized = re.sub(rf'{etype}\s+-\s+(\d+)', rf'{etype}-\1', normalized)
1006
- normalized = re.sub(rf'({etype}-\d+)([ء-ی])', r'\1 \2', normalized)
1007
- normalized = re.sub(rf'({etype}-\d+)([،؛:.!?])', r'\1 \2', normalized)
1008
- return normalized
1009
-
1010
- def _restore_with_regex(self, text: str) -> str:
1011
- restored = text
1012
- for placeholder, original in self.mapping_table.items():
1013
- if placeholder not in restored:
1014
- continue
1015
- etype, num = placeholder.split("-")
1016
- if re.search(rf'{etype}\s*-\s*{num}', restored):
1017
- restored = re.sub(rf'{etype}\s*-\s*{num}', original, restored)
1018
- logger.info(f" ✅ regex: {placeholder} → {original[:40]}")
1019
- return restored
1020
-
1021
- def get_mapping_table_md(self) -> str:
1022
- if not self.mapping_table:
1023
- return "### 📋 جدول نگاشت\n\nهیچ موجودیتی شناسایی نشد"
1024
- table = "### 📋 جدول نگاشت\n\n| شناسه | متن اصلی |\n|-------|----------|\n"
1025
- for token, original in sorted(self.mapping_table.items()):
1026
- table += f"| **{token}** | {original} |\n"
1027
- return table
1028
-
1029
-
1030
- # ─────────────────────────────────────────────────────────────
1031
- # متغیر سراسری
1032
- # ─────────────────────────────────────────────────────────────
1033
- anonymizer: Optional[AnonymizerAdvanced] = None
1034
-
1035
-
1036
- # ─────────────────────────────────────────────────────────────
1037
- # تابع اصلی
1038
- # ─────────────────────────────────────────────────────────────
1039
- def process(
1040
- input_text: str,
1041
- analysis_prompt: str,
1042
- llm_provider: str,
1043
- llm_model: str,
1044
- anonymize_all: bool,
1045
- anonymize_person: bool,
1046
- anonymize_company: bool,
1047
- anonymize_amount: bool,
1048
- anonymize_percent: bool
1049
- ):
1050
- global BATCH_FAST_MODE
1051
- BATCH_FAST_MODE = False
1052
- global anonymizer
1053
-
1054
- if not input_text.strip():
1055
- return "", "", "", ""
1056
-
1057
- entities = ["person", "company", "amount", "percent"] if anonymize_all else [
1058
- e for e, flag in [
1059
- ("person", anonymize_person),
1060
- ("company", anonymize_company),
1061
- ("amount", anonymize_amount),
1062
- ("percent", anonymize_percent),
1063
- ] if flag
1064
- ]
1065
-
1066
- if not entities:
1067
- return "", "❌ لطفاً حداقل یک موجودیت انتخاب کنید", "", ""
1068
-
1069
- if not anonymizer:
1070
- anonymizer = AnonymizerAdvanced(
1071
- llm_provider=llm_provider,
1072
- llm_model=llm_model,
1073
- entities_to_anonymize=entities
1074
- )
1075
- else:
1076
- anonymizer.set_llm_provider(llm_provider, llm_model, entities)
1077
- anonymizer.mapping_table = {}
1078
- anonymizer.reverse_mapping = {}
1079
-
1080
- try:
1081
- logger.info("=" * 60)
1082
- logger.info(f"🧠 Qwen3-14B (thinking ON | single-call)")
1083
- logger.info(f"🤖 تحلیل: {llm_provider} ({llm_model})")
1084
- logger.info(f"🎯 موجودیت‌ها: {entities}")
1085
- logger.info("=" * 60)
1086
-
1087
- anon_text, _ = anonymizer.anonymize(input_text)
1088
-
1089
- has_analysis = bool(analysis_prompt and analysis_prompt.strip())
1090
- llm_response = anonymizer.analyze_with_llm(anon_text, analysis_prompt) if has_analysis \
1091
- else "⚠️ هیچ دستور تحلیل داده نشده است"
1092
-
1093
- source = llm_response if has_analysis else anon_text
1094
- restored = anonymizer.restore_text(source)
1095
-
1096
- return restored, llm_response, anon_text, anonymizer.get_mapping_table_md()
1097
-
1098
- except Exception as e:
1099
- logger.error(f"❌ خطا: {e}", exc_info=True)
1100
- return "", f"❌ خطا: {str(e)}", "", ""
1101
-
1102
-
1103
- def clear_all():
1104
- return "", "", "", "", "", "", True, False, False, False, False
1105
-
1106
-
1107
- # ─────────────────────────────────────────────────────────────
1108
- # توابع CSV — پردازش دسته‌ای
1109
- # ─────────────────────────────────────────────────────────────
1110
-
1111
- def read_csv_columns(file):
1112
- """خواندن نام ستون‌ها و تعداد ردیف‌های CSV"""
1113
- if file is None:
1114
- return gr.update(choices=[], value=[]), "⚠️ فایلی آپلود نشده", gr.update(value=1), gr.update(value=1, maximum=1)
1115
-
1116
- file_path = file if isinstance(file, str) else file.name
1117
-
1118
- try:
1119
- with open(file_path, "r", encoding="utf-8-sig") as f:
1120
- reader = csv.reader(f)
1121
- headers = next(reader)
1122
- headers = [h.strip() for h in headers if h.strip()]
1123
- row_count = sum(1 for _ in reader)
1124
-
1125
- if not headers:
1126
- return gr.update(choices=[], value=[]), "❌ ستونی یافت نشد", gr.update(value=1), gr.update(value=1, maximum=1)
1127
-
1128
- return (
1129
- gr.update(choices=headers, value=headers),
1130
- f"✅ {len(headers)} ستون | {row_count} ردیف — ستون‌ها: {' | '.join(headers)}",
1131
- gr.update(value=1, minimum=1, maximum=row_count),
1132
- gr.update(value=min(row_count, 50), minimum=1, maximum=row_count),
1133
- )
1134
- except Exception as e:
1135
- return gr.update(choices=[], value=[]), f"❌ خطا: {str(e)}", gr.update(value=1), gr.update(value=1, maximum=1)
1136
-
1137
-
1138
- def process_csv(
1139
- file,
1140
- selected_columns: list,
1141
- csv_anonymize_all: bool,
1142
- csv_anonymize_person: bool,
1143
- csv_anonymize_company: bool,
1144
- csv_anonymize_amount: bool,
1145
- csv_anonymize_percent: bool,
1146
- row_start: int,
1147
- row_end: int,
1148
- batch_size: int,
1149
- delay_between_batches: float,
1150
- progress=gr.Progress()
1151
- ):
1152
- """ناشناس‌سازی دسته‌ای ستون‌های انتخاب‌شده فایل CSV"""
1153
- global BATCH_FAST_MODE
1154
- BATCH_FAST_MODE = True
1155
- if file is None:
1156
- return None, "❌ فایلی آپلود نشده", ""
1157
-
1158
- file_path = file if isinstance(file, str) else file.name
1159
-
1160
- if not selected_columns:
1161
- return None, "❌ حداقل یک ستون انتخاب کنید", ""
1162
-
1163
- # تعیین موجودیت‌ها
1164
- entities = ["person", "company", "amount", "percent"] if csv_anonymize_all else [
1165
- e for e, flag in [
1166
- ("person", csv_anonymize_person),
1167
- ("company", csv_anonymize_company),
1168
- ("amount", csv_anonymize_amount),
1169
- ("percent", csv_anonymize_percent),
1170
- ] if flag
1171
- ]
1172
-
1173
- if not entities:
1174
- return None, "❌ حداقل یک نوع موجودیت انتخاب کنید", ""
1175
-
1176
- try:
1177
- # خواندن CSV
1178
- with open(file_path, "r", encoding="utf-8-sig") as f:
1179
- reader = csv.DictReader(f)
1180
- headers = reader.fieldnames
1181
- all_rows = list(reader)
1182
-
1183
- total_rows = len(all_rows)
1184
- if total_rows == 0:
1185
- return None, "❌ فایل خالی است", ""
1186
-
1187
- # تنظیم بازه ردیف‌ها (1-indexed → 0-indexed)
1188
- start_idx = max(0, int(row_start) - 1)
1189
- end_idx = min(total_rows, int(row_end))
1190
- if start_idx >= end_idx:
1191
- return None, f"❌ بازه نامعتبر: ردیف {row_start} تا {row_end} (کل: {total_rows})", ""
1192
-
1193
- selected_rows = all_rows[start_idx:end_idx]
1194
- num_selected = len(selected_rows)
1195
- batch_sz = max(1, int(batch_size))
1196
-
1197
- logger.info("=" * 60)
1198
- logger.info(f"📄 CSV: {total_rows} ردیف کل | بازه: {start_idx+1}-{end_idx} ({num_selected} ردیف)")
1199
- logger.info(f"📦 دسته: {batch_sz} ردیف | ستون‌ها: {selected_columns}")
1200
- logger.info(f"🎯 موجودیت‌ها: {entities}")
1201
- logger.info("=" * 60)
1202
-
1203
- # ساخت anonymizer
1204
- csv_anon = AnonymizerAdvanced(
1205
- llm_provider="deepinfra",
1206
- llm_model=ANON_MODEL,
1207
- entities_to_anonymize=entities
1208
- )
1209
-
1210
- # ساخت ستون‌های جدید برای خروجی ناشناس‌شده
1211
- # هر ستون انتخابی → ستون جدید با پسوند _Anonymized
1212
- anon_col_map = {}
1213
- output_headers = list(headers)
1214
- for col in selected_columns:
1215
- if len(selected_columns) == 1:
1216
- new_col = "Anonymization_text"
1217
- else:
1218
- new_col = f"{col}_Anonymized"
1219
- anon_col_map[col] = new_col
1220
- if new_col not in output_headers:
1221
- output_headers.append(new_col)
1222
-
1223
- logger.info(f"📝 ستون‌های خروجی: {anon_col_map}")
1224
-
1225
- # جدول نگاشت کلی
1226
- global_mapping = {}
1227
- processed_rows = []
1228
- errors = []
1229
- total_cells = 0
1230
- start_time = time.time()
1231
-
1232
- # تعداد دسته‌ها
1233
- num_batches = (num_selected + batch_sz - 1) // batch_sz
1234
-
1235
- for batch_num in range(num_batches):
1236
- batch_start = batch_num * batch_sz
1237
- batch_end = min(batch_start + batch_sz, num_selected)
1238
- batch_rows = selected_rows[batch_start:batch_end]
1239
-
1240
- actual_start = start_idx + batch_start + 1
1241
- actual_end = start_idx + batch_end
1242
-
1243
- progress(
1244
- (batch_num + 1) / num_batches,
1245
- desc=f"دسته {batch_num+1}/{num_batches} | ردیف {actual_start}-{actual_end}"
1246
- )
1247
-
1248
- logger.info(f"📦 دسته {batch_num+1}/{num_batches}: ردیف {actual_start}-{actual_end}")
1249
-
1250
- for j, row in enumerate(batch_rows):
1251
- row_idx = actual_start + j
1252
- new_row = dict(row)
1253
-
1254
- # مقدار پیش‌فرض خالی برای ستون‌های جدید
1255
- for col in selected_columns:
1256
- new_row[anon_col_map[col]] = ""
1257
-
1258
- for col in selected_columns:
1259
- if col not in row or not row[col].strip():
1260
- new_row[anon_col_map[col]] = row.get(col, "")
1261
- continue
1262
-
1263
- cell_text = row[col].strip()
1264
- total_cells += 1
1265
-
1266
- # ریست mapping برای هر سلول
1267
- csv_anon.mapping_table = {}
1268
- csv_anon.reverse_mapping = {}
1269
-
1270
- try:
1271
- anon_text, mapping = csv_anon.anonymize(cell_text)
1272
- # متن اصلی دست‌نخورده باقی می‌ماند، ناشناس‌شده در ستون جدید
1273
- new_row[anon_col_map[col]] = anon_text
1274
-
1275
- for token, original in mapping.items():
1276
- if token not in global_mapping:
1277
- global_mapping[token] = original
1278
-
1279
- logger.info(f" ✅ ردیف {row_idx}, '{col}' → '{anon_col_map[col]}': {len(mapping)} موجودیت")
1280
-
1281
- except Exception as e:
1282
- logger.error(f" ❌ ردیف {row_idx}, '{col}': {e}")
1283
- errors.append(f"ردیف {row_idx}, ستون '{col}': {str(e)}")
1284
- # در صورت خطا متن اصلی در ستون جدید قرار می‌گیرد
1285
- new_row[anon_col_map[col]] = cell_text
1286
-
1287
- # تاخیر کوتاه بین سلول‌ها
1288
- time.sleep(0.2)
1289
-
1290
- processed_rows.append(new_row)
1291
-
1292
- # تاخیر بین دسته‌ها
1293
- if batch_num < num_batches - 1:
1294
- pause = max(0.5, float(delay_between_batches))
1295
- logger.info(f" ⏸️ مکث {pause} ثانیه بین دسته‌ها...")
1296
- time.sleep(pause)
1297
-
1298
- elapsed = time.time() - start_time
1299
-
1300
- # نوشتن CSV خروجی
1301
- output_dir = tempfile.gettempdir()
1302
- output_path = os.path.join(output_dir, f"anonymized_{int(time.time())}.csv")
1303
- with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
1304
- writer = csv.DictWriter(f, fieldnames=output_headers)
1305
- writer.writeheader()
1306
- writer.writerows(processed_rows)
1307
-
1308
- # ساخت گزارش
1309
- mins, secs = divmod(int(elapsed), 60)
1310
- time_str = f"{mins} دقیقه و {secs} ثانیه" if mins > 0 else f"{secs} ثانیه"
1311
- avg_time = elapsed / total_cells if total_cells > 0 else 0
1312
-
1313
- anon_cols_str = " | ".join(f"{c} → {anon_col_map[c]}" for c in selected_columns)
1314
- status_parts = [
1315
- f"✅ **{len(processed_rows)}** ردیف پردازش شد (ردیف {start_idx+1} تا {end_idx} از {total_rows})",
1316
- f"📋 **{len(global_mapping)}** موجودیت یکتا ناشناس‌سازی شد",
1317
- f"📦 **{num_batches}** دسته × **{batch_sz}** ردیف",
1318
- f"🔢 **{total_cells}** سلول پردازش شد",
1319
- f"📝 ستون‌ها: {anon_cols_str}",
1320
- f"⏱️ زمان: {time_str} (میانگین {avg_time:.1f} ثانیه/سلول)",
1321
- ]
1322
- if errors:
1323
- status_parts.append(f"\n⚠️ **{len(errors)}** خطا:")
1324
- for err in errors[:15]:
1325
- status_parts.append(f" - {err}")
1326
- if len(errors) > 15:
1327
- status_parts.append(f" - ... و {len(errors)-15} خطای دیگر")
1328
-
1329
- status = "\n".join(status_parts)
1330
-
1331
- # جدول نگاشت
1332
- if global_mapping:
1333
- mapping_md = "### 📋 جدول نگاشت کلی\n\n| شناسه | متن اصلی |\n|-------|----------|\n"
1334
- for token, original in sorted(global_mapping.items()):
1335
- mapping_md += f"| **{token}** | {original} |\n"
1336
- else:
1337
- mapping_md = "### 📋 جدول نگاشت\n\nهیچ موجودیتی شناسایی نشد"
1338
-
1339
- logger.info(f"✅ CSV کامل شد: {len(processed_rows)} ردیف | {time_str}")
1340
- return output_path, status, mapping_md
1341
-
1342
- except Exception as e:
1343
- logger.error(f"❌ خطای CSV: {e}", exc_info=True)
1344
- return None, f"❌ خطا: {str(e)}", ""
1345
-
1346
-
1347
- def clear_csv():
1348
- return (None, gr.update(choices=[], value=[]), "", None,
1349
- "⏳ هنوز پردازشی انجام نشده",
1350
- "### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده",
1351
- True, False, False, False, False,
1352
- 1, 50, 10, 1.0)
1353
-
1354
-
1355
- # ───────────────────��─────────────────────────────────────────
1356
- # رابط کاربری Gradio
1357
- # ─────────────────────────────────────────────────────────────
1358
- css_rtl = """
1359
- .textbox textarea { direction: rtl; text-align: right; font-family: 'Tahoma', serif; }
1360
- .input-box { direction: rtl; text-align: right; }
1361
- .compact-checkbox label { padding: 5px 10px !important; font-size: 0.95em !important; }
1362
- """
1363
-
1364
- with gr.Blocks(title="سیستم ناشناس‌سازی متون", theme=gr.themes.Soft(), css=css_rtl) as app:
1365
-
1366
- gr.Markdown(
1367
- "# 🔐 پلتفرم ناشناس‌سازی متون فارسی\n"
1368
- "> 🧠 **Qwen3-14B** با thinking mode — دقت بالا (بنچمارک ۹۰٪+)",
1369
- elem_classes="input-box"
1370
- )
1371
-
1372
- # ═══════════════════════════════════════════════════════════
1373
- # تب‌ها
1374
- # ═══════════════════════════════════════════════════════════
1375
- with gr.Tabs():
1376
-
1377
- # ─── تب ۱: ناشناس‌سازی متن ────────────────────────────
1378
- with gr.TabItem("📝 ناشناس‌سازی متن"):
1379
-
1380
- with gr.Row():
1381
- with gr.Column(scale=1):
1382
- with gr.Group():
1383
- gr.Markdown("### ⚙️ مدل تحلیل", elem_classes="input-box")
1384
- llm_provider = gr.Dropdown(
1385
- choices=["chatgpt", "grok", "deepinfra"],
1386
- value="chatgpt", label="🤖 مدل زبانی تحلیل", interactive=True
1387
- )
1388
- llm_model = gr.Dropdown(
1389
- choices=AVAILABLE_MODELS["chatgpt"],
1390
- value="gpt-4o-mini", label="📦 نسخه مدل", interactive=True
1391
- )
1392
-
1393
- with gr.Column(scale=1):
1394
- with gr.Group():
1395
- gr.Markdown("### 🎯 موجودیت‌های ناشناس‌سازی", elem_classes="input-box")
1396
- anonymize_all = gr.Checkbox(label="✅ همه", value=True, elem_classes="compact-checkbox")
1397
- anonymize_person = gr.Checkbox(label="👤 اشخاص", value=False, elem_classes="compact-checkbox")
1398
- anonymize_company = gr.Checkbox(label="🏢 سازمان‌ها", value=False, elem_classes="compact-checkbox")
1399
- anonymize_amount = gr.Checkbox(label="💰 ارقام مالی", value=False, elem_classes="compact-checkbox")
1400
- anonymize_percent = gr.Checkbox(label="📊 درصدها", value=False, elem_classes="compact-checkbox")
1401
-
1402
- gr.Markdown("---")
1403
-
1404
- with gr.Row():
1405
- with gr.Column(scale=1):
1406
- gr.Markdown("### 📋 دستورات تحلیل (اختیاری)", elem_classes="input-box")
1407
- analysis_prompt = gr.Textbox(
1408
- lines=20, placeholder="مثال: این متن را خلاصه کن",
1409
- label="", elem_classes="textbox"
1410
- )
1411
- with gr.Column(scale=1):
1412
- gr.Markdown("### 📝 متن ورودی", elem_classes="input-box")
1413
- input_text = gr.Textbox(
1414
- lines=20, placeholder="متن فارسی را وارد کنید...",
1415
- label="", elem_classes="textbox"
1416
- )
1417
-
1418
- with gr.Row():
1419
- process_btn = gr.Button("▶️ پردازش", variant="primary", size="lg", scale=2)
1420
- clear_btn = gr.Button("🗑️ پاک کردن", variant="stop", size="lg", scale=1)
1421
-
1422
- gr.Markdown("## 📊 نتایج", elem_classes="input-box")
1423
-
1424
- with gr.Row():
1425
- restored_text = gr.Textbox(lines=12, label="✅ متن بازگردانی شده", interactive=False, elem_classes="textbox")
1426
- llm_analysis = gr.Textbox(lines=12, label="🤖 تحلیل LLM", interactive=False, elem_classes="textbox")
1427
- anonymized_output = gr.Textbox(lines=12, label="🔒 متن ناشناس‌شده", interactive=False, elem_classes="textbox")
1428
-
1429
- mapping_table = gr.Markdown("### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده", elem_classes="input-box")
1430
-
1431
- # ── رویدادهای تب متن ──
1432
- def handle_provider_change(provider):
1433
- models = AVAILABLE_MODELS.get(provider, [])
1434
- return gr.update(choices=models, value=models[0] if models else None)
1435
-
1436
- llm_provider.change(fn=handle_provider_change, inputs=[llm_provider], outputs=[llm_model])
1437
-
1438
- def handle_select_all(select_all):
1439
- s = gr.update(value=False, interactive=not select_all)
1440
- return s, s, s, s
1441
-
1442
- anonymize_all.change(
1443
- fn=handle_select_all, inputs=[anonymize_all],
1444
- outputs=[anonymize_person, anonymize_company, anonymize_amount, anonymize_percent]
1445
- )
1446
-
1447
- process_btn.click(
1448
- fn=process,
1449
- inputs=[
1450
- input_text, analysis_prompt, llm_provider, llm_model,
1451
- anonymize_all, anonymize_person, anonymize_company, anonymize_amount, anonymize_percent
1452
- ],
1453
- outputs=[restored_text, llm_analysis, anonymized_output, mapping_table]
1454
- )
1455
-
1456
- clear_btn.click(
1457
- fn=clear_all,
1458
- outputs=[
1459
- input_text, analysis_prompt, restored_text, llm_analysis,
1460
- anonymized_output, mapping_table,
1461
- anonymize_all, anonymize_person, anonymize_company, anonymize_amount, anonymize_percent
1462
- ]
1463
- )
1464
-
1465
- # ─── تب ۲: ناشناس‌سازی CSV ────────────────────────────
1466
- with gr.TabItem("📄 ناشناس‌سازی CSV"):
1467
-
1468
- gr.Markdown(
1469
- "### 📄 ناشناس‌سازی دسته‌ای فایل CSV\n"
1470
- "> فایل CSV را آپلود کنید، بازه ردیف‌ها و اندازه دسته را تنظیم کنید و ناشناس‌سازی را اجرا کنید.",
1471
- elem_classes="input-box"
1472
- )
1473
-
1474
- with gr.Row():
1475
- # ── ستون ۱: فایل و ستون‌ها ──
1476
- with gr.Column(scale=1):
1477
- with gr.Group():
1478
- gr.Markdown("### 📂 فایل ورودی", elem_classes="input-box")
1479
- csv_file = gr.File(
1480
- label="فایل CSV را آپلود کنید",
1481
- file_types=[".csv"],
1482
- type="filepath"
1483
- )
1484
- csv_column_status = gr.Textbox(
1485
- label="وضعیت", interactive=False,
1486
- value="⏳ فایل آپلود نشده", elem_classes="textbox"
1487
- )
1488
- with gr.Group():
1489
- gr.Markdown("### 📋 ستون‌های قابل ناشناس‌سازی", elem_classes="input-box")
1490
- csv_columns = gr.CheckboxGroup(
1491
- choices=[], value=[],
1492
- label="ستون‌هایی که می‌خواهید ناشناس شوند",
1493
- interactive=True
1494
- )
1495
-
1496
- # ── ستون ۲: بازه ردیف‌ها و دسته‌بندی ──
1497
- with gr.Column(scale=1):
1498
- with gr.Group():
1499
- gr.Markdown("### 📐 بازه ردیف‌ها", elem_classes="input-box")
1500
- with gr.Row():
1501
- csv_row_start = gr.Number(
1502
- label="از ردیف", value=1, minimum=1, maximum=99999,
1503
- precision=0, interactive=True
1504
- )
1505
- csv_row_end = gr.Number(
1506
- label="تا ردیف", value=50, minimum=1, maximum=99999,
1507
- precision=0, interactive=True
1508
- )
1509
- with gr.Group():
1510
- gr.Markdown("### 📦 تنظیمات دسته‌ای", elem_classes="input-box")
1511
- csv_batch_size = gr.Slider(
1512
- label="اندازه هر دسته (تعداد ردیف)",
1513
- minimum=1, maximum=50, value=10, step=1,
1514
- interactive=True
1515
- )
1516
- csv_delay = gr.Slider(
1517
- label="مکث بین دسته‌ها (ثانیه)",
1518
- minimum=0.5, maximum=10.0, value=1.0, step=0.5,
1519
- interactive=True
1520
- )
1521
- gr.Markdown(
1522
- "> 💡 **راهنما:** دسته کوچکتر = پایدارتر ولی کندتر | "
1523
- "مکث بیشتر = جلوگیری از rate limit",
1524
- elem_classes="input-box"
1525
- )
1526
-
1527
- # ── ستون ۳: موجودیت‌ها ──
1528
- with gr.Column(scale=1):
1529
- with gr.Group():
1530
- gr.Markdown("### 🎯 موجودیت‌ها", elem_classes="input-box")
1531
- csv_anonymize_all = gr.Checkbox(label="✅ همه", value=True, elem_classes="compact-checkbox")
1532
- csv_anonymize_person = gr.Checkbox(label="👤 اشخاص", value=False, elem_classes="compact-checkbox")
1533
- csv_anonymize_company = gr.Checkbox(label="🏢 سازمان‌ها", value=False, elem_classes="compact-checkbox")
1534
- csv_anonymize_amount = gr.Checkbox(label="💰 ارقام مالی", value=False, elem_classes="compact-checkbox")
1535
- csv_anonymize_percent = gr.Checkbox(label="📊 درصدها", value=False, elem_classes="compact-checkbox")
1536
-
1537
- with gr.Row():
1538
- csv_process_btn = gr.Button("▶️ ناشناس‌سازی CSV", variant="primary", size="lg", scale=2)
1539
- csv_clear_btn = gr.Button("🗑️ پاک کردن", variant="stop", size="lg", scale=1)
1540
-
1541
- gr.Markdown("## 📊 نتایج CSV", elem_classes="input-box")
1542
-
1543
- with gr.Row():
1544
- with gr.Column(scale=1):
1545
- csv_output_file = gr.File(label="📥 دانلود فایل ناشناس‌شده")
1546
- with gr.Column(scale=2):
1547
- csv_status = gr.Markdown("⏳ هنوز پردازشی انجام نشده", elem_classes="input-box")
1548
-
1549
- csv_mapping_table = gr.Markdown("### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده", elem_classes="input-box")
1550
-
1551
- # ── رویدادهای تب CSV ──
1552
-
1553
- csv_file.change(
1554
- fn=read_csv_columns,
1555
- inputs=[csv_file],
1556
- outputs=[csv_columns, csv_column_status, csv_row_start, csv_row_end]
1557
- )
1558
-
1559
- def handle_csv_select_all(select_all):
1560
- s = gr.update(value=False, interactive=not select_all)
1561
- return s, s, s, s
1562
-
1563
- csv_anonymize_all.change(
1564
- fn=handle_csv_select_all, inputs=[csv_anonymize_all],
1565
- outputs=[csv_anonymize_person, csv_anonymize_company, csv_anonymize_amount, csv_anonymize_percent]
1566
- )
1567
-
1568
- csv_process_btn.click(
1569
- fn=process_csv,
1570
- inputs=[
1571
- csv_file, csv_columns,
1572
- csv_anonymize_all, csv_anonymize_person,
1573
- csv_anonymize_company, csv_anonymize_amount, csv_anonymize_percent,
1574
- csv_row_start, csv_row_end, csv_batch_size, csv_delay
1575
- ],
1576
- outputs=[csv_output_file, csv_status, csv_mapping_table]
1577
- )
1578
-
1579
- csv_clear_btn.click(
1580
- fn=clear_csv,
1581
- outputs=[
1582
- csv_file, csv_columns, csv_column_status, csv_output_file,
1583
- csv_status, csv_mapping_table,
1584
- csv_anonymize_all, csv_anonymize_person, csv_anonymize_company,
1585
- csv_anonymize_amount, csv_anonymize_percent,
1586
- csv_row_start, csv_row_end, csv_batch_size, csv_delay
1587
- ]
1588
- )
1589
-
1590
-
1591
- if __name__ == "__main__":
1592
- print("=" * 60)
1593
- print("🧠 Qwen3-14B | thinking ON | retry+chunking | بنچمارک ۹۰٪+")
1594
- print("=" * 60)
1595
- app.queue(default_concurrency_limit=1)
1596
- app.launch(server_name="0.0.0.0", server_port=7860, share=False, show_error=True)