KashefTech commited on
Commit
9e796b2
·
verified ·
1 Parent(s): 132c923

Delete app.py

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