KashefTech commited on
Commit
b490e72
·
verified ·
1 Parent(s): a36fbc7

Delete app.py

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