KashefTech commited on
Commit
2f5127e
·
verified ·
1 Parent(s): f015907

Delete app.py

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