KashefTech commited on
Commit
8f235ca
·
verified ·
1 Parent(s): 5878029

Delete app.py

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