KashefTech commited on
Commit
9e68552
·
verified ·
1 Parent(s): b8a6e5b

Upload app (62).py

Browse files

رفعع باگهای برنامه 2 اسفند

Files changed (1) hide show
  1. app (62).py +768 -0
app (62).py ADDED
@@ -0,0 +1,768 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import re
3
+ import os
4
+ import requests
5
+ import json
6
+ import logging
7
+ from typing import Dict, List, Tuple, Optional
8
+ from llm_sender_unified import create_llm_sender
9
+
10
+ logging.basicConfig(level=logging.INFO)
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # ─────────────────────────────────────────────────────────────
14
+ # مدل‌های موجود
15
+ # ─────────────────────────────────────────────────────────────
16
+ AVAILABLE_MODELS = {
17
+ "chatgpt": [
18
+ "gpt-5.1",
19
+ "gpt-5",
20
+ "gpt-4.1",
21
+ "gpt-4o",
22
+ "gpt-4o-mini",
23
+ "gpt-4-turbo",
24
+ ],
25
+ "grok": [
26
+ "grok-4-0709",
27
+ "grok-3",
28
+ "grok-3-mini",
29
+ "grok-2-1212",
30
+ ],
31
+ "deepinfra": [
32
+ "Qwen/Qwen3-14B",
33
+ "Qwen/Qwen3-32B",
34
+ "Qwen/Qwen3-30B-A3B",
35
+ "Qwen/Qwen2.5-72B-Instruct",
36
+ "Qwen/Qwen2.5-14B-Instruct",
37
+ ]
38
+ }
39
+
40
+ # ─────────────────────────────────────────────────────────────
41
+ # SYSTEM PROMPT — فشرده، رفع باگ bank-XX و شرکت-01
42
+ # ─────────────────────────────────────────────────────────────
43
+ DEEPINFRA_SYSTEM_PROMPT = """You are a Persian text anonymizer. Output ONLY anonymized text with NO explanation.
44
+
45
+ TOKENS: company-01/02/03... | person-01/02/03... | amount-01/02/03... | percent-01/02/03...
46
+
47
+ RULES:
48
+ - ALL organizations → company-XX (banks, funds, auditors, any named entity)
49
+ - NEVER: bank-01, org-01, شرکت-01, بانک-01 (only company-XX in English)
50
+ - Same entity = same token. New entity = new token. Sequential numbering.
51
+ - Keep non-sensitive words exactly as-is."""
52
+
53
+
54
+ # ─────────────────────────────────────────────────────────────
55
+ # توابع ساخت prompt
56
+ # ─────────────────────────────────────────────────────────────
57
+
58
+ def build_anonymization_prompt(text: str, entities: list) -> str:
59
+ """
60
+ ساخت prompt ناشناس‌سازی — dynamic و فشرده
61
+ باگ‌های رفع‌شده:
62
+ - نام‌های بدون پیشوند (ایران خودرو، تیپیکو)
63
+ - bank-XX / شرکت-01
64
+ - اعداد خالص (P/E=4، 3.2 واحد)
65
+ - درصد range → دو توکن جداگانه
66
+ - واحد پولی در متن حفظ می‌شود
67
+ """
68
+ lines = []
69
+
70
+ if "company" in entities:
71
+ lines.append(
72
+ "company-XX → ALL org names (شرکت/بانک/صندوق/هلدینگ/حسابرس)\n"
73
+ " • نام‌های بدون پیشوند هم ناشناس شوند: ایران خودرو→company-01, تیپیکو→company-02"
74
+ )
75
+
76
+ if "person" in entities:
77
+ lines.append("person-XX → نام و نام‌خانوادگی اشخاص حقیقی")
78
+
79
+ if "amount" in entities:
80
+ lines.append(
81
+ "amount-XX → عدد + واحد پولی/اندازه‌گیری را کاملاً با هم جایگزین کن\n"
82
+ " • «۳۵۰۰ هزار میلیارد ریال» → amount-01 (واحد داخل توکن)\n"
83
+ " • «۷ میلیارد تومان» → amount-02 (واحد داخل توکن)\n"
84
+ " • «۵۳۷ هزار و ۷۳۶ دستگاه» → amount-03 (واحد داخل توکن)\n"
85
+ " • «P/E به 4 رسید» → P/E به amount-04 رسید (عدد بدون واحد)\n"
86
+ " ❌ غلط: «amount-01 میلیارد ریال» — واحد نباید بیرون توکن بماند"
87
+ )
88
+
89
+ if "percent" in entities:
90
+ lines.append(
91
+ "percent-XX → عدد + کلمه درصد/٪ را کاملاً با هم جایگزین کن\n"
92
+ " • «۴.۵۸ درصد» → percent-01 (کلمه درصد داخل توکن)\n"
93
+ " • «۱۳۲۳ درصد» → percent-02\n"
94
+ " • «50 الی 70 درصد» → percent-03 الی percent-04 ← دو توکن مجزا\n"
95
+ " • «40–60٪» → percent-05–percent-06 ← دو توکن مجزا\n"
96
+ " • «منفی 345 درصد» → منفی percent-07\n"
97
+ " ❌ غلط: «percent-01 درصد» — کلمه درصد نباید بیرون توکن بماند"
98
+ )
99
+
100
+ lines.append("اعداد سال (1402، 1403) و اعداد ترتیبی (12 بانک، سه شرکت) را ناشناس نکن")
101
+
102
+ rules = "\n".join(lines)
103
+
104
+ return f"""متن زیر را طبق قوانین ناشناس کن. فقط متن ناشناس شده را بده، بدون توضیح.
105
+
106
+ {rules}
107
+
108
+ متن:
109
+ {text}"""
110
+
111
+
112
+ def build_mapping_prompt(original: str, anonymized: str, entities: list) -> str:
113
+ """ساخت prompt mapping — فشرده"""
114
+ type_hints = []
115
+ if "person" in entities: type_hints.append('person-XX: "نام کامل"')
116
+ if "company" in entities: type_hints.append('company-XX: "نام کامل سازمان"')
117
+ if "amount" in entities: type_hints.append('amount-XX: "عدد + واحد کامل مثل ۳۵۰۰ هزار میلیارد ریال"')
118
+ if "percent" in entities: type_hints.append('percent-XX: "عدد + درصد مثل ۴.۵۸ درصد"')
119
+
120
+ hints = " | ".join(type_hints)
121
+
122
+ return f"""متن اصلی: {original}
123
+ متن ناشناس: {anonymized}
124
+
125
+ JSON mapping (فقط JSON، بدون توضیح):
126
+ فرمت: {{ "token": "مقدار_اصلی" }}
127
+ راهنما: {hints}"""
128
+
129
+
130
+ def build_analysis_prompt(anonymized_text: str, analysis_prompt: str, entities: list) -> str:
131
+ """ساخت prompt تحلیل LLM — یکپارچه برای همه مدل‌ها"""
132
+ tokens = []
133
+ if "person" in entities: tokens.append("person-XX")
134
+ if "company" in entities: tokens.append("company-XX")
135
+ if "amount" in entities: tokens.append("amount-XX")
136
+ if "percent" in entities: tokens.append("percent-XX")
137
+ tokens_str = ", ".join(tokens)
138
+
139
+ return f"""متن ناشناس‌سازی شده:
140
+ {anonymized_text}
141
+
142
+ دستورات:
143
+ {analysis_prompt}
144
+
145
+ قوانین:
146
+ - فقط از توکن‌های موجود استفاده کن: {tokens_str}
147
+ - هیچ کلمه‌ای قبل/بعد از توکن‌ها اضافه نکن
148
+ - توکن جدید ایجاد نکن"""
149
+
150
+
151
+ # ─────────────────────────────────────────────────────────────
152
+ # تابع کمکی: حذف بلوک‌های think از Qwen3
153
+ # ─────────────────────────────────────────────────────────────
154
+ def strip_thinking(text: str) -> str:
155
+ if not text:
156
+ return text
157
+ cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
158
+ return cleaned.strip()
159
+
160
+
161
+ # ─────────────────────────────────────────────────────────────
162
+ # کلاس اصلی
163
+ # ─────────────────────────────────────────────────────────────
164
+ class AnonymizerAdvanced:
165
+
166
+ def __init__(
167
+ self,
168
+ deepinfra_key: str = None,
169
+ llm_provider: str = "chatgpt",
170
+ llm_model: str = None,
171
+ entities_to_anonymize: List[str] = None
172
+ ):
173
+ self.deepinfra_key = deepinfra_key or os.getenv("DEEPINFRA_API_KEY")
174
+ self.llm_provider = llm_provider
175
+ self.llm_model = llm_model
176
+ self.entities_to_anonymize = entities_to_anonymize or ["person", "company", "amount", "percent"]
177
+ self.mapping_table: Dict[str, str] = {}
178
+ self.reverse_mapping: Dict[str, str] = {}
179
+ self._create_llm_sender()
180
+ logger.info(f"✅ Anonymizer مقداردهی شد — {llm_provider}")
181
+
182
+ # ── LLM sender ──────────────────────────────────────────
183
+
184
+ def _create_llm_sender(self):
185
+ try:
186
+ key_map = {
187
+ "chatgpt": os.getenv("OPENAI_API_KEY"),
188
+ "grok": os.getenv("XAI_API_KEY"),
189
+ "deepinfra": os.getenv("DEEPINFRA_API_KEY"),
190
+ }
191
+ api_key = key_map.get(self.llm_provider)
192
+ self.llm_sender = create_llm_sender(
193
+ provider=self.llm_provider,
194
+ api_key=api_key,
195
+ model=self.llm_model
196
+ )
197
+ logger.info(f"✅ LLM Sender: {self.llm_provider} — {self.llm_sender.model}")
198
+ except Exception as e:
199
+ logger.error(f"❌ خطا در ایجاد LLM Sender: {e}")
200
+ self.llm_sender = create_llm_sender("chatgpt")
201
+
202
+ def set_llm_provider(self, provider: str, model: str = None, entities: List[str] = None):
203
+ self.llm_provider = provider
204
+ self.llm_model = model
205
+ if entities is not None:
206
+ self.entities_to_anonymize = entities
207
+ self._create_llm_sender()
208
+ logger.info(f"✅ LLM تغییر یافت: {provider} — {model}")
209
+
210
+ # ── ناشناس‌سازی با DeepInfra ──────────────────────────
211
+
212
+ def anonymize_with_deepinfra(self, text: str) -> Tuple[str, Dict]:
213
+ logger.info("🧠 ناشناس‌سازی با DeepInfra...")
214
+
215
+ if not self.deepinfra_key:
216
+ raise ValueError("DeepInfra API Key مورد نیاز است")
217
+
218
+ if not self.entities_to_anonymize:
219
+ logger.warning("⚠️ هیچ موجودیتی انتخاب نشده")
220
+ return text, {}
221
+
222
+ headers = {
223
+ "Authorization": f"Bearer {self.deepinfra_key}",
224
+ "Content-Type": "application/json"
225
+ }
226
+
227
+ try:
228
+ # ── مرحله ۱: ناشناس‌سازی ────────────────────────
229
+ prompt1 = build_anonymization_prompt(text, self.entities_to_anonymize)
230
+
231
+ resp1 = requests.post(
232
+ "https://api.deepinfra.com/v1/openai/chat/completions",
233
+ headers=headers,
234
+ json={
235
+ "model": "Qwen/Qwen3-14B",
236
+ "messages": [
237
+ {"role": "system", "content": DEEPINFRA_SYSTEM_PROMPT},
238
+ {"role": "user", "content": prompt1}
239
+ ],
240
+ "max_tokens": 4096,
241
+ "temperature": 0.1
242
+ },
243
+ timeout=90
244
+ )
245
+
246
+ if resp1.status_code != 200:
247
+ raise Exception(f"DeepInfra API Error: {resp1.status_code} — {resp1.text[:200]}")
248
+
249
+ anonymized_text = strip_thinking(
250
+ resp1.json()["choices"][0]["message"]["content"]
251
+ )
252
+ logger.info("✅ ناشناس‌سازی موفق")
253
+
254
+ # debug: توکن‌های موجود
255
+ for etype in self.entities_to_anonymize:
256
+ found = sorted(set(re.findall(rf'{etype}-\d+', anonymized_text)))
257
+ logger.info(f" {etype}: {found}")
258
+
259
+ # ── مرحله ۲: استخراج mapping ────────────────────
260
+ prompt2 = build_mapping_prompt(text, anonymized_text, self.entities_to_anonymize)
261
+
262
+ resp2 = requests.post(
263
+ "https://api.deepinfra.com/v1/openai/chat/completions",
264
+ headers=headers,
265
+ json={
266
+ "model": "Qwen/Qwen3-14B",
267
+ "messages": [
268
+ {"role": "system", "content": DEEPINFRA_SYSTEM_PROMPT},
269
+ {"role": "user", "content": prompt2}
270
+ ],
271
+ "max_tokens": 2048,
272
+ "temperature": 0.1
273
+ },
274
+ timeout=60
275
+ )
276
+
277
+ if resp2.status_code == 200:
278
+ raw = strip_thinking(resp2.json()["choices"][0]["message"]["content"])
279
+ # پاک‌سازی fence های markdown
280
+ raw = re.sub(r"```(?:json)?", "", raw).replace("```", "").strip()
281
+ try:
282
+ self.mapping_table = json.loads(raw)
283
+ self._fix_mapping()
284
+ self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
285
+ logger.info(f"✅ Mapping: {len(self.mapping_table)} موجودیت")
286
+ except json.JSONDecodeError:
287
+ logger.warning("⚠️ JSON mapping خطا — fallback")
288
+ self._extract_mapping_fallback(text, anonymized_text)
289
+ else:
290
+ logger.warning("⚠️ mapping API خطا — fallback")
291
+ self._extract_mapping_fallback(text, anonymized_text)
292
+
293
+ return anonymized_text, self.mapping_table
294
+
295
+ except Exception as e:
296
+ logger.error(f"❌ DeepInfra Exception: {e}")
297
+ raise
298
+
299
+ # ── اصلاح mapping ────────────────────────────────────────
300
+
301
+ def _fix_mapping(self):
302
+ """اطمینان از کامل بودن مقادیر percent در mapping"""
303
+ for token, value in list(self.mapping_table.items()):
304
+ val = str(value).strip()
305
+ if token.startswith("percent-") and not re.search(r"(درصد|%|درصدی)", val):
306
+ self.mapping_table[token] = f"{val} درصد"
307
+ logger.info(f" اصلاح {token}: '{val}' → '{val} درصد'")
308
+
309
+ # ── fallback mapping با regex ────────────────────────────
310
+
311
+ def _extract_mapping_fallback(self, original: str, anonymized: str):
312
+ """
313
+ استخراج mapping با regex — وقتی JSON mapping شکست خورد
314
+ باگ رفع‌شده: \b برای فارسی کار نمی‌کند → از lookahead/lookbehind استفاده شد
315
+ """
316
+ # الگوهای موجودیت
317
+ patterns: Dict[str, str] = {}
318
+ if "person" in self.entities_to_anonymize:
319
+ # lookahead/lookbehind به جای \b برای فارسی
320
+ patterns["person"] = r'(?<![ء-یa-zA-Z])[ء-ی]+\s+[ء-ی]+(?:\s+[ء-ی]+)*(?![ء-یa-zA-Z])'
321
+ if "company" in self.entities_to_anonymize:
322
+ patterns["company"] = (
323
+ r'(?:(?:شرکت|بانک|سازمان|گروه|هلدینگ|صندوق)\s+)?'
324
+ r'[ء-ی][ء-ی\s]+(?:تومان|ریال|دلار)?\s*(?=[\s،؛.!?]|$)'
325
+ )
326
+ if "amount" in self.entities_to_anonymize:
327
+ patterns["amount"] = (
328
+ r'[\d۰-۹][,،\d۰-۹]*(?:\.\d+)?'
329
+ r'\s*(?:هزار\s+و\s+\d+|هزار|میلیون|میلیارد|همت|تن|دستگاه)?'
330
+ r'\s*(?:میلیارد|میلیون|هزار|تومان|ریال|دلار|یورو|دستگاه|تن|واحد)?'
331
+ )
332
+ if "percent" in self.entities_to_anonymize:
333
+ patterns["percent"] = r'[\d۰-۹]+(?:\.\d+)?\s*(?:درصد|%|درصدی)'
334
+
335
+ # استخراج موجودیت‌های اصلی
336
+ original_entities: Dict[str, List[str]] = {}
337
+ for etype, pat in patterns.items():
338
+ matches = re.findall(pat, original)
339
+ original_entities[etype] = [m.strip() for m in matches if m.strip()]
340
+
341
+ # ربط توکن → مقدار اصلی
342
+ for etype in self.entities_to_anonymize:
343
+ tokens = sorted(
344
+ set(re.findall(rf'{etype}-(\d+)', anonymized)),
345
+ key=lambda x: int(x)
346
+ )
347
+ values = original_entities.get(etype, [])
348
+ for tok_num in tokens:
349
+ token = f"{etype}-{tok_num}"
350
+ idx = int(tok_num) - 1
351
+ if idx < len(values):
352
+ self.mapping_table[token] = values[idx]
353
+ elif values:
354
+ self.mapping_table[token] = values[-1]
355
+
356
+ self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
357
+ logger.info(f"✅ Fallback mapping: {len(self.mapping_table)} موجودیت")
358
+
359
+ # ── تحلیل با LLM ────────────────────────────────────────
360
+
361
+ def analyze_with_llm(self, anonymized_text: str, analysis_prompt: str = None) -> str:
362
+ logger.info(f"🤖 {self.llm_provider.upper()} تحلیل...")
363
+
364
+ if not analysis_prompt or not analysis_prompt.strip():
365
+ return "⚠️ هیچ دستور تحلیل داده نشده است"
366
+
367
+ prompt = build_analysis_prompt(
368
+ anonymized_text, analysis_prompt, self.entities_to_anonymize
369
+ )
370
+
371
+ try:
372
+ response = self.llm_sender.send(
373
+ prompt,
374
+ lang="fa",
375
+ temperature=0.2,
376
+ max_tokens=2000
377
+ )
378
+ logger.info(f"✅ {self.llm_provider.upper()}: {len(response)} کاراکتر")
379
+ return response
380
+
381
+ except Exception as e:
382
+ logger.error(f"❌ {self.llm_provider.upper()} Exception: {e}")
383
+ return f"❌ خطا در ارتباط با {self.llm_provider.upper()}: {str(e)}"
384
+
385
+ # ── بازگردانی متن ────────────────────────────────────────
386
+
387
+ def restore_text(self, anonymized_text: str) -> str:
388
+ """
389
+ بازگردانی متن
390
+ باگ‌های رفع‌شده:
391
+ - حذف _clean_for_restore مخرب
392
+ - اصلاح _restore_with_regex (text → restored)
393
+ """
394
+ logger.info("🔄 بازگردانی متن...")
395
+
396
+ if not self.mapping_table:
397
+ logger.warning("⚠️ جدول نگاشت خالی")
398
+ return anonymized_text
399
+
400
+ # STEP 1: normalize توکن‌ها
401
+ restored = self._normalize_tokens(anonymized_text)
402
+
403
+ # STEP 2: جایگزینی طولانی‌ترین توکن‌ها اول (greedy)
404
+ count = 0
405
+ for placeholder, original in sorted(
406
+ self.mapping_table.items(),
407
+ key=lambda x: len(x[0]),
408
+ reverse=True
409
+ ):
410
+ if placeholder in restored:
411
+ restored = restored.replace(placeholder, original)
412
+ count += 1
413
+ logger.info(f" ✅ {placeholder} → {original[:40]}")
414
+ else:
415
+ logger.warning(f" ⚠️ {placeholder} یافت نشد")
416
+
417
+ logger.info(f"✅ {count}/{len(self.mapping_table)} توکن بازگردانی شد")
418
+
419
+ # STEP 3: fallback regex برای توکن‌های باقی‌مانده
420
+ remaining = [p for p in self.mapping_table if p in restored]
421
+ if remaining:
422
+ logger.info(f"🔍 fallback برای {len(remaining)} توکن باقی‌مانده...")
423
+ restored = self._restore_with_regex(restored)
424
+
425
+ return restored
426
+
427
+ def _normalize_tokens(self, text: str) -> str:
428
+ """
429
+ نرمال‌سازی توکن‌ها:
430
+ - hyphen های یونیکد → hyphen معمولی
431
+ - فاصله داخل توکن حذف می‌شود
432
+ - کلمات فارسی چسبیده به توکن جدا می‌شوند
433
+ """
434
+ normalized = text
435
+ unicode_hyphens = r'[\u2010\u2011\u2012\u2013\u2014\u2212]'
436
+
437
+ for etype in self.entities_to_anonymize:
438
+ # hyphen های یونیکد
439
+ normalized = re.sub(
440
+ rf'{etype}{unicode_hyphens}(\d+)',
441
+ rf'{etype}-\1',
442
+ normalized
443
+ )
444
+ # فاصله اضافی داخل توکن
445
+ normalized = re.sub(
446
+ rf'{etype}\s+-\s+(\d+)',
447
+ rf'{etype}-\1',
448
+ normalized
449
+ )
450
+
451
+ # کلمه فارسی چسبیده به توکن
452
+ for etype in self.entities_to_anonymize:
453
+ normalized = re.sub(
454
+ rf'({etype}-\d+)([ء-ی])',
455
+ r'\1 \2',
456
+ normalized
457
+ )
458
+ # نشانه‌گذاری چسبیده
459
+ normalized = re.sub(
460
+ rf'({etype}-\d+)([،؛:.!?])',
461
+ r'\1 \2',
462
+ normalized
463
+ )
464
+
465
+ return normalized
466
+
467
+ def _restore_with_regex(self, text: str) -> str:
468
+ """
469
+ fallback برای توکن‌هایی که با replace ساده پیدا نشدند
470
+ باگ رفع‌شده:
471
+ - بررسی روی `restored` (نه `text` ثابت)
472
+ - فقط توکن داخل match جایگزین می‌شود نه کل عبارت
473
+ """
474
+ restored = text
475
+
476
+ for placeholder, original in self.mapping_table.items():
477
+ if placeholder not in restored:
478
+ continue # قبلاً restore شده یا نیست
479
+
480
+ etype = placeholder.split("-")[0]
481
+ num = placeholder.split("-")[1]
482
+
483
+ # فقط فاصله اضافی داخل توکن را پوشش می‌دهیم
484
+ pattern = rf'{etype}\s*-\s*{num}'
485
+ if re.search(pattern, restored):
486
+ restored = re.sub(pattern, original, restored)
487
+ logger.info(f" ✅ regex restore: {placeholder} → {original[:40]}")
488
+
489
+ return restored
490
+
491
+ # ── جدول نگاشت Markdown ──────────────────────────────────
492
+
493
+ def get_mapping_table_md(self) -> str:
494
+ if not self.mapping_table:
495
+ return "### 📋 جدول نگاشت\n\nهیچ موجودیتی شناسایی نشد"
496
+
497
+ table = "### 📋 جدول نگاشت\n\n"
498
+ table += "| شناسه | متن اصلی |\n"
499
+ table += "|-------|----------|\n"
500
+ for token, original in sorted(self.mapping_table.items()):
501
+ table += f"| **{token}** | {original} |\n"
502
+ return table
503
+
504
+
505
+ # ─────────────────────────────────────────────────────────────
506
+ # متغیر سراسری
507
+ # ─────────────────────────────────────────────────────────────
508
+ anonymizer: Optional[AnonymizerAdvanced] = None
509
+
510
+
511
+ # ─────────────────────────────────────────────────────────────
512
+ # تابع اصلی پردازش
513
+ # ─────────────────────────────────────────────────────────────
514
+ def process(
515
+ input_text: str,
516
+ analysis_prompt: str,
517
+ llm_provider: str,
518
+ llm_model: str,
519
+ anonymize_all: bool,
520
+ anonymize_person: bool,
521
+ anonymize_company: bool,
522
+ anonymize_amount: bool,
523
+ anonymize_percent: bool
524
+ ):
525
+ global anonymizer
526
+
527
+ if not input_text.strip():
528
+ return "", "", "", ""
529
+
530
+ # ساخت لیست موجودیت‌ها
531
+ if anonymize_all:
532
+ entities = ["person", "company", "amount", "percent"]
533
+ else:
534
+ entities = []
535
+ if anonymize_person: entities.append("person")
536
+ if anonymize_company: entities.append("company")
537
+ if anonymize_amount: entities.append("amount")
538
+ if anonymize_percent: entities.append("percent")
539
+
540
+ if not entities:
541
+ return "", "❌ لطفاً حداقل یک موجودیت انتخاب کنید", "", ""
542
+
543
+ deepinfra_key = os.getenv("DEEPINFRA_API_KEY")
544
+
545
+ # ایجاد یا آپدیت anonymizer
546
+ if not anonymizer:
547
+ anonymizer = AnonymizerAdvanced(
548
+ deepinfra_key,
549
+ llm_provider=llm_provider,
550
+ llm_model=llm_model,
551
+ entities_to_anonymize=entities
552
+ )
553
+ else:
554
+ anonymizer.set_llm_provider(llm_provider, llm_model, entities)
555
+ anonymizer.mapping_table = {}
556
+ anonymizer.reverse_mapping = {}
557
+
558
+ try:
559
+ logger.info("=" * 60)
560
+ logger.info(f"🚀 پردازش — {llm_provider} ({llm_model})")
561
+ logger.info(f"🎯 موجودیت‌ها: {entities}")
562
+ logger.info("=" * 60)
563
+
564
+ # مرحله ۱: ناشناس‌سازی
565
+ logger.info("🔐 مرحله ۱: ناشناس‌سازی...")
566
+ anon_text, _ = anonymizer.anonymize_with_deepinfra(input_text)
567
+
568
+ # مرحله ۲: تحلیل LLM
569
+ has_analysis = bool(analysis_prompt and analysis_prompt.strip())
570
+
571
+ if has_analysis:
572
+ logger.info(f"🤖 مرحله ۲: تحلیل {llm_provider.upper()}...")
573
+ llm_response = anonymizer.analyze_with_llm(anon_text, analysis_prompt)
574
+ else:
575
+ llm_response = "⚠️ هیچ دستور تحلیل داده نشده است"
576
+
577
+ # مرحله ۳: بازگردانی
578
+ logger.info("🔄 مرحله ۳: بازگردانی...")
579
+ source_for_restore = llm_response if has_analysis else anon_text
580
+ restored = anonymizer.restore_text(source_for_restore)
581
+
582
+ # مرحله ۴: جدول نگاشت
583
+ mapping_str = anonymizer.get_mapping_table_md()
584
+
585
+ logger.info("✅ پردازش کامل")
586
+ return restored, llm_response, anon_text, mapping_str
587
+
588
+ except Exception as e:
589
+ logger.error(f"❌ خطا: {e}", exc_info=True)
590
+ return "", f"❌ خطا: {str(e)}", "", ""
591
+
592
+
593
+ def clear_all():
594
+ return "", "", "", "", "", "", True, False, False, False, False
595
+
596
+
597
+ # ─────────────────────────────────────────────────────────────
598
+ # رابط کاربری Gradio
599
+ # ─────────────────────────────────────────────────────────────
600
+ css_rtl = """
601
+ .input-box { direction: rtl; text-align: right; }
602
+ .textbox textarea {
603
+ direction: rtl;
604
+ text-align: right;
605
+ font-family: 'Tahoma', serif;
606
+ }
607
+ .thick-divider { border-top: 2px solid #333; margin: 10px 0; }
608
+ .compact-checkbox label {
609
+ padding: 5px 10px !important;
610
+ margin: 3px 0 !important;
611
+ font-size: 0.95em !important;
612
+ }
613
+ """
614
+
615
+ with gr.Blocks(title="سیستم ناشناس‌سازی متون", theme=gr.themes.Soft(), css=css_rtl) as app:
616
+
617
+ gr.Markdown("# 🔐 پلتفرم امن چت با مدل‌های متنوع و ناشناس‌سازی داده‌ها",
618
+ elem_classes="input-box")
619
+
620
+ # ── ردیف اول: تنظیمات ──────────────────────────────────
621
+ with gr.Row():
622
+ with gr.Column(scale=1):
623
+ with gr.Group():
624
+ gr.Markdown("### ⚙️ تنظیمات مدل", elem_classes="input-box")
625
+ llm_provider = gr.Dropdown(
626
+ choices=["chatgpt", "grok", "deepinfra"],
627
+ value="chatgpt",
628
+ label="🤖 انتخاب مدل زبانی",
629
+ interactive=True
630
+ )
631
+ llm_model = gr.Dropdown(
632
+ choices=AVAILABLE_MODELS["chatgpt"],
633
+ value="gpt-4o-mini",
634
+ label="📦 انتخاب نسخه مدل",
635
+ interactive=True
636
+ )
637
+
638
+ with gr.Column(scale=1):
639
+ with gr.Group():
640
+ gr.Markdown("### 🎯 انتخاب موجودیت‌ها", elem_classes="input-box")
641
+ anonymize_all = gr.Checkbox(
642
+ label="✅ همه موجودیت‌ها", value=True,
643
+ elem_classes="input-box compact-checkbox"
644
+ )
645
+ anonymize_person = gr.Checkbox(
646
+ label="👤 اسامی اشخاص", value=False,
647
+ elem_classes="input-box compact-checkbox"
648
+ )
649
+ anonymize_company = gr.Checkbox(
650
+ label="🏢 نام شرکت‌ها", value=False,
651
+ elem_classes="input-box compact-checkbox"
652
+ )
653
+ anonymize_amount = gr.Checkbox(
654
+ label="💰 ارقام مالی", value=False,
655
+ elem_classes="input-box compact-checkbox"
656
+ )
657
+ anonymize_percent = gr.Checkbox(
658
+ label="📊 درصدها", value=False,
659
+ elem_classes="input-box compact-checkbox"
660
+ )
661
+
662
+ gr.Markdown("---", elem_classes="thick-divider")
663
+
664
+ # ── ردیف دوم: ورودی ────────────────────────────────────
665
+ with gr.Row():
666
+ with gr.Column(scale=1):
667
+ gr.Markdown("### 📋 دستورات پردازش", elem_classes="input-box")
668
+ analysis_prompt = gr.Textbox(
669
+ lines=22,
670
+ placeholder="مثال: این متن را خلاصه کن\nیا: نکات کلیدی را استخراج کن",
671
+ label="📋 دستورات LLM (اختیاری)",
672
+ elem_classes="textbox"
673
+ )
674
+
675
+ with gr.Column(scale=1):
676
+ gr.Markdown("### 📝 متن ورودی", elem_classes="input-box")
677
+ input_text = gr.Textbox(
678
+ lines=22,
679
+ placeholder="متن مالی/خبری را وارد کنید...",
680
+ label="",
681
+ elem_classes="textbox"
682
+ )
683
+
684
+ # ── دکمه‌ها ─────────────────────────────────────────────
685
+ with gr.Row():
686
+ process_btn = gr.Button("▶️ پردازش", variant="primary", size="lg", scale=2)
687
+ clear_btn = gr.Button("🗑️ پاک کردن", variant="stop", size="lg", scale=1)
688
+
689
+ # ── نتایج ────────────────────────────────────────────────
690
+ gr.Markdown("## 📊 نتایج پردازش", elem_classes="input-box")
691
+
692
+ with gr.Row():
693
+ with gr.Column(scale=1):
694
+ restored_text = gr.Textbox(
695
+ lines=12, label="✅ متن بازگردانی شده",
696
+ interactive=False, elem_classes="textbox"
697
+ )
698
+ with gr.Column(scale=1):
699
+ llm_analysis = gr.Textbox(
700
+ lines=12, label="🤖 تحلیل LLM",
701
+ interactive=False, elem_classes="textbox"
702
+ )
703
+ with gr.Column(scale=1):
704
+ anonymized_output = gr.Textbox(
705
+ lines=12, label="🔒 متن ناشناس‌شده",
706
+ interactive=False, elem_classes="textbox"
707
+ )
708
+
709
+ mapping_table = gr.Markdown(
710
+ value="### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده",
711
+ label="📋 جدول نگاشت",
712
+ elem_classes="input-box"
713
+ )
714
+
715
+ # ── Event handlers ────────────────────────────────────────
716
+
717
+ def handle_provider_change(provider):
718
+ models = AVAILABLE_MODELS.get(provider, [])
719
+ return gr.update(choices=models, value=models[0] if models else None)
720
+
721
+ llm_provider.change(
722
+ fn=handle_provider_change,
723
+ inputs=[llm_provider],
724
+ outputs=[llm_model]
725
+ )
726
+
727
+ def handle_select_all(select_all):
728
+ state = gr.update(value=False, interactive=not select_all)
729
+ return state, state, state, state
730
+
731
+ anonymize_all.change(
732
+ fn=handle_select_all,
733
+ inputs=[anonymize_all],
734
+ outputs=[anonymize_person, anonymize_company, anonymize_amount, anonymize_percent]
735
+ )
736
+
737
+ process_btn.click(
738
+ fn=process,
739
+ inputs=[
740
+ input_text, analysis_prompt, llm_provider, llm_model,
741
+ anonymize_all, anonymize_person, anonymize_company,
742
+ anonymize_amount, anonymize_percent
743
+ ],
744
+ outputs=[restored_text, llm_analysis, anonymized_output, mapping_table]
745
+ )
746
+
747
+ clear_btn.click(
748
+ fn=clear_all,
749
+ outputs=[
750
+ input_text, analysis_prompt, restored_text, llm_analysis,
751
+ anonymized_output, mapping_table,
752
+ anonymize_all, anonymize_person, anonymize_company,
753
+ anonymize_amount, anonymize_percent
754
+ ]
755
+ )
756
+
757
+
758
+ # ─────────────────────────────────────────────────────────────
759
+ if __name__ == "__main__":
760
+ print("=" * 70)
761
+ print("🚀 سیستم ناشناس‌سازی در حال راه‌اندازی...")
762
+ print("=" * 70)
763
+ app.launch(
764
+ server_name="0.0.0.0",
765
+ server_port=7860,
766
+ share=False,
767
+ show_error=True
768
+ )