leilaghomashchi commited on
Commit
c2813a8
·
verified ·
1 Parent(s): e1c8f70

Upload app_multi_api_with_retry.py

Browse files
Files changed (1) hide show
  1. app_multi_api_with_retry.py +854 -0
app_multi_api_with_retry.py ADDED
@@ -0,0 +1,854 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+ import gradio as gr
4
+ from typing import Dict, Any, List, Generator, Optional
5
+ import os
6
+ from dataclasses import dataclass
7
+ import re
8
+ import pandas as pd
9
+ import time
10
+ from datetime import datetime
11
+ import threading
12
+ from queue import Queue
13
+ import io
14
+ from requests.adapters import HTTPAdapter
15
+ from urllib3.util.retry import Retry
16
+ from enum import Enum
17
+
18
+ class APIProvider(Enum):
19
+ """پرووایدرهای API پشتیبانی شده"""
20
+ CEREBRAS = "cerebras"
21
+ GROQ = "groq"
22
+ OPENAI = "openai"
23
+
24
+ @dataclass
25
+ class APIConfig:
26
+ """تنظیمات API"""
27
+ provider: APIProvider
28
+ api_key: str
29
+ base_url: str
30
+ model: str
31
+ max_tokens: int = 2000
32
+ temperature: float = 0.1
33
+
34
+ # تنظیمات پیش‌فرض برای هر پرووایدر
35
+ API_CONFIGS = {
36
+ APIProvider.CEREBRAS: {
37
+ "base_url": "https://api.cerebras.ai/v1",
38
+ "models": ["qwen-3-32b", "llama-4-scout-17b-16e-instruct", "llama3.1-8b"],
39
+ "default_model": "qwen-3-32b"
40
+ },
41
+ APIProvider.GROQ: {
42
+ "base_url": "https://api.groq.com/openai/v1",
43
+ "models": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"],
44
+ "default_model": "llama-3.3-70b-versatile"
45
+ },
46
+ APIProvider.OPENAI: {
47
+ "base_url": "https://api.openai.com/v1",
48
+ "models": ["gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
49
+ "default_model": "gpt-4o-mini"
50
+ }
51
+ }
52
+
53
+ @dataclass
54
+ class RateLimitConfig:
55
+ """تنظیمات محدودیت نرخ درخواست"""
56
+ requests_per_minute: int = 30
57
+ min_delay_between_requests: float = 2.0
58
+ max_retries: int = 5
59
+ initial_backoff: float = 5.0
60
+ max_backoff: float = 60.0
61
+ backoff_multiplier: float = 2.0
62
+ recovery_window: float = 60.0
63
+
64
+ class RateLimiter:
65
+ """مدیریت محدودیت نرخ درخواست"""
66
+
67
+ def __init__(self, config: RateLimitConfig):
68
+ self.config = config
69
+ self.request_times: List[float] = []
70
+ self.lock = threading.Lock()
71
+ self.consecutive_failures = 0
72
+ self.last_failure_time = 0
73
+ self.last_success_time = time.time()
74
+ self.total_429_errors = 0
75
+
76
+ def wait_if_needed(self) -> float:
77
+ """انتظار تا زمان مجاز ارسال درخواست بعدی"""
78
+ with self.lock:
79
+ now = time.time()
80
+
81
+ # پاک کردن درخواست‌های قدیمی
82
+ self.request_times = [t for t in self.request_times if now - t < 60]
83
+
84
+ # بازیابی از خطاهای قبلی
85
+ if self.consecutive_failures > 0 and (now - self.last_failure_time) > self.config.recovery_window:
86
+ self.consecutive_failures = max(0, self.consecutive_failures - 1)
87
+
88
+ wait_time = 0.0
89
+
90
+ # محدودیت درخواست در دقیقه
91
+ if len(self.request_times) >= self.config.requests_per_minute:
92
+ oldest_request = min(self.request_times)
93
+ wait_time = max(wait_time, 60 - (now - oldest_request) + 1)
94
+
95
+ # حداقل تأخیر
96
+ if self.request_times:
97
+ time_since_last = now - max(self.request_times)
98
+ if time_since_last < self.config.min_delay_between_requests:
99
+ wait_time = max(wait_time, self.config.min_delay_between_requests - time_since_last)
100
+
101
+ # backoff برای خطاها
102
+ if self.consecutive_failures > 0:
103
+ failure_wait = min(
104
+ self.config.initial_backoff * (self.config.backoff_multiplier ** min(self.consecutive_failures, 4)),
105
+ self.config.max_backoff
106
+ )
107
+ wait_time = max(wait_time, failure_wait)
108
+
109
+ if wait_time > 0:
110
+ time.sleep(wait_time)
111
+
112
+ self.request_times.append(time.time())
113
+ return wait_time
114
+
115
+ def report_success(self):
116
+ with self.lock:
117
+ self.last_success_time = time.time()
118
+ self.consecutive_failures = 0
119
+
120
+ def report_failure(self, is_rate_limit: bool = False):
121
+ with self.lock:
122
+ self.last_failure_time = time.time()
123
+ if is_rate_limit:
124
+ self.consecutive_failures += 1
125
+ self.total_429_errors += 1
126
+ else:
127
+ self.consecutive_failures = min(self.consecutive_failures + 0.5, 3)
128
+
129
+ def get_estimated_wait_time(self) -> float:
130
+ with self.lock:
131
+ now = time.time()
132
+ self.request_times = [t for t in self.request_times if now - t < 60]
133
+ if len(self.request_times) >= self.config.requests_per_minute:
134
+ return max(0, 60 - (now - min(self.request_times)) + 1)
135
+ return self.config.min_delay_between_requests
136
+
137
+ def get_status(self) -> Dict[str, Any]:
138
+ with self.lock:
139
+ return {
140
+ "consecutive_failures": self.consecutive_failures,
141
+ "total_429_errors": self.total_429_errors,
142
+ "requests_in_last_minute": len(self.request_times)
143
+ }
144
+
145
+
146
+ class MultiProviderAnonymizer:
147
+ """سیستم ناشناس‌سازی با پشتیبانی از چند API"""
148
+
149
+ def __init__(
150
+ self,
151
+ provider: APIProvider,
152
+ api_key: str,
153
+ model: str = None,
154
+ rate_limit_config: RateLimitConfig = None
155
+ ):
156
+ self.provider = provider
157
+ self.api_key = api_key
158
+
159
+ provider_config = API_CONFIGS[provider]
160
+ self.base_url = provider_config["base_url"]
161
+ self.model = model or provider_config["default_model"]
162
+
163
+ self.rate_limit_config = rate_limit_config or RateLimitConfig()
164
+ self.rate_limiter = RateLimiter(self.rate_limit_config)
165
+ self.system_prompt = self._create_system_prompt()
166
+ self.session = self._create_session()
167
+
168
+ def _create_session(self) -> requests.Session:
169
+ """ایجاد session با تنظیمات بهینه"""
170
+ session = requests.Session()
171
+ retry_strategy = Retry(
172
+ total=3,
173
+ backoff_factor=1,
174
+ status_forcelist=[500, 502, 503, 504],
175
+ allowed_methods=["POST"]
176
+ )
177
+ adapter = HTTPAdapter(max_retries=retry_strategy, pool_connections=10, pool_maxsize=10)
178
+ session.mount("https://", adapter)
179
+ session.mount("http://", adapter)
180
+ return session
181
+
182
+ def _remove_thinking_tags(self, text: str) -> str:
183
+ """حذف تگ‌های thinking از خروجی مدل‌های reasoning مثل Qwen"""
184
+ if not text:
185
+ return text
186
+
187
+ # حذف تگ‌های <think>...</think> و <thinking>...</thinking>
188
+ # با پشتیبانی از حروف بزرگ/کوچک و چند خطی
189
+ patterns = [
190
+ r'<think>.*?</think>',
191
+ r'<thinking>.*?</thinking>',
192
+ r'<Think>.*?</Think>',
193
+ r'<Thinking>.*?</Thinking>',
194
+ ]
195
+
196
+ for pattern in patterns:
197
+ text = re.sub(pattern, '', text, flags=re.DOTALL)
198
+
199
+ # اگر تگ باز بود ولی بسته نشد، همه چیز بعدش را حذف کن
200
+ for tag in ['<think>', '<Think>', '<thinking>', '<Thinking>']:
201
+ if tag.lower() in text.lower():
202
+ # پیدا کردن موقعیت تگ و حذف از آنجا تا انتها یا تا تگ بسته
203
+ idx = text.lower().find(tag.lower())
204
+ if idx != -1:
205
+ # چک کن آیا تگ بسته شدن وجود داره
206
+ close_tag = tag.replace('<', '</')
207
+ close_idx = text.lower().find(close_tag.lower(), idx)
208
+ if close_idx != -1:
209
+ # حذف از تگ باز تا بعد از تگ بسته
210
+ text = text[:idx] + text[close_idx + len(close_tag):]
211
+ else:
212
+ # تگ بسته نشده، حذف از تگ باز تا انتها
213
+ text = text[:idx]
214
+
215
+ return text.strip()
216
+
217
+ def _create_system_prompt(self) -> str:
218
+ """دستورالعمل سیستمی"""
219
+ return """شما یک «ناشناس‌ساز متون مالی/خبری فارسی» هستید. وظیفه‌تان جایگزینی اسامی خاص و مقادیر عددی با شناسه‌های بی‌معناست.
220
+
221
+ ## **مهم: بدون توضیح**
222
+ - فقط متن ناشناس‌شده را برگردان
223
+ - هیچ توضیحی ندهید
224
+ - از تگ‌های <think> یا <thinking> استفاده نکنید
225
+ - فقط خروجی نهایی
226
+
227
+ ## **قوانین اندیس‌گذاری**
228
+ - شرکت‌ها: company-01, company-02, ... (پیوسته)
229
+ - اشخاص: person-01, person-02, ... (پیوسته)
230
+ - مبالغ: amount-01, amount-02, ... (پیوسته)
231
+ - درصدها: percent-01, percent-02, ... (پیوسته)
232
+
233
+ ## **ثبات شناسه‌ها:**
234
+ - اگر "همراه اول" اول‌بار company-01 شد، در تمام متن همان باشد
235
+
236
+ ## **قانون مهم برای مبالغ:**
237
+ - **عدد + واحد = یک amount**: عدد به همراه واحد پولی/مقداری باید با هم یک توکن amount شوند
238
+ - مثال‌ها:
239
+ - 230 هزارمیلیارد → amount-01
240
+ - 500 میلیون → amount-02
241
+ - 45 هزارمیلیون → amount-03
242
+ - 120 میلیارد تومان → amount-04
243
+ - 80 همت → amount-05
244
+ - 2.5 تریلیون → amount-06
245
+ - واحدهای پولی شامل: میلیون، میلیارد، هزارمیلیارد، هزارمیلیون، تریلیون، همت، تومان، ریال
246
+
247
+ ## **تشخیص صحیح:**
248
+ - **شرکت/سازمان:** همراه اول، بانک ملی، ایران‌خودرو، سایپا، بانک مرکزی
249
+ - **گروه‌ها:** "گروه همراه اول" → company-XX (نه group-XX)
250
+ - **کلمات عمومی:** "سه شرکت"، "چند بانک" → حفظ شوند
251
+ - **شخص:** مهدی اخوان بهابادی، محمدرضا فرزین
252
+ - **درصد:** 37 درصدی، 15%
253
+
254
+ ## **مثال:**
255
+ **ورودی:** مهدی اخوان، مدیرعامل همراه اول، اعلام کرد درآمد با رشد 37 درصدی به 70 میلیارد تومان رسید.
256
+ **خروجی:** person-01، مدیرعامل company-01، اعلام کرد درآمد با رشد percent-01 به amount-01 رسید.
257
+
258
+ **ورودی:** سود خالص از 230 هزارمیلیارد به 500 میلیارد افزایش یافت.
259
+ **خروجی:** سود خالص از amount-01 به amount-02 افزایش یافت.
260
+
261
+ ## **حفظ شود:**
262
+ - تاریخ‌ها: 1404/04/23
263
+ - دوره‌های زمانی: ۹ ماهه، ۵ ماهه سال
264
+ - مکان‌ها: تهران، اصفهان
265
+
266
+ **فقط متن ناشناس‌شده را برگردان - بدون توضیح اضافی.**
267
+ """
268
+
269
+ def _make_api_request(self, text: str) -> Dict[str, Any]:
270
+ """ارسال درخواست به API"""
271
+ headers = {
272
+ "Authorization": f"Bearer {self.api_key}",
273
+ "Content-Type": "application/json"
274
+ }
275
+
276
+ payload = {
277
+ "messages": [
278
+ {"role": "system", "content": self.system_prompt},
279
+ {"role": "user", "content": text}
280
+ ],
281
+ "model": self.model,
282
+ "temperature": 0.1,
283
+ "max_tokens": 2000
284
+ }
285
+
286
+ # اضافه کردن پارامترهای خاص هر پرووایدر
287
+ if self.provider == APIProvider.GROQ:
288
+ # Groq نیاز به این پارامتر نداره
289
+ pass
290
+
291
+ last_error = None
292
+
293
+ for attempt in range(self.rate_limit_config.max_retries):
294
+ self.rate_limiter.wait_if_needed()
295
+
296
+ try:
297
+ response = self.session.post(
298
+ f"{self.base_url}/chat/completions",
299
+ headers=headers,
300
+ json=payload,
301
+ timeout=90
302
+ )
303
+
304
+ if response.status_code == 429:
305
+ self.rate_limiter.report_failure(is_rate_limit=True)
306
+ retry_after = response.headers.get('Retry-After')
307
+ wait_seconds = int(retry_after) if retry_after else min(
308
+ self.rate_limit_config.initial_backoff * (self.rate_limit_config.backoff_multiplier ** attempt),
309
+ 60.0
310
+ )
311
+ last_error = f"Rate limit (429). تلاش {attempt + 1}/{self.rate_limit_config.max_retries}"
312
+ print(f"🚫 {last_error} - انتظار {wait_seconds}s")
313
+ time.sleep(wait_seconds)
314
+ continue
315
+
316
+ if response.status_code == 401:
317
+ raise Exception(f"❌ کلید API نامعتبر است برای {self.provider.value}")
318
+
319
+ if response.status_code == 503:
320
+ self.rate_limiter.report_failure(is_rate_limit=False)
321
+ last_error = f"سرویس در دسترس نیست (503)"
322
+ time.sleep(10)
323
+ continue
324
+
325
+ response.raise_for_status()
326
+ self.rate_limiter.report_success()
327
+ return response.json()
328
+
329
+ except requests.exceptions.Timeout:
330
+ self.rate_limiter.report_failure(is_rate_limit=False)
331
+ last_error = f"Timeout"
332
+ time.sleep(5)
333
+
334
+ except requests.exceptions.ConnectionError as e:
335
+ self.rate_limiter.report_failure(is_rate_limit=False)
336
+ last_error = f"خطای اتصال: {str(e)[:50]}"
337
+ time.sleep(10)
338
+
339
+ except requests.exceptions.RequestException as e:
340
+ self.rate_limiter.report_failure(is_rate_limit=False)
341
+ last_error = f"خطای شبکه: {str(e)[:50]}"
342
+ time.sleep(5)
343
+
344
+ raise Exception(f"ناموفق پس از {self.rate_limit_config.max_retries} تلاش: {last_error}")
345
+
346
+ def anonymize_text(self, text: str) -> Dict[str, Any]:
347
+ """ناشناس‌سازی متن"""
348
+ if not text or not text.strip():
349
+ return {"success": False, "error": "متن ورودی خالی است", "anonymized_text": ""}
350
+
351
+ try:
352
+ response = self._make_api_request(text)
353
+
354
+ if "choices" not in response or not response["choices"]:
355
+ return {"success": False, "error": "پاسخ نامعتبر", "anonymized_text": ""}
356
+
357
+ content = response["choices"][0]["message"]["content"]
358
+
359
+ # پاک کردن markdown
360
+ if "```" in content:
361
+ lines = content.split('\n')
362
+ clean_lines = []
363
+ skip = False
364
+ for line in lines:
365
+ if line.strip().startswith('```'):
366
+ skip = not skip
367
+ continue
368
+ if not skip:
369
+ clean_lines.append(line)
370
+ content = '\n'.join(clean_lines)
371
+
372
+ content = content.strip()
373
+
374
+ # پاک کردن تگ‌های thinking (برای مدل‌های Qwen و مشابه)
375
+ content = self._remove_thinking_tags(content)
376
+ content = content.strip()
377
+
378
+ # تحلیل نتایج
379
+ companies = re.findall(r'company-(\d+)', content)
380
+ persons = re.findall(r'person-(\d+)', content)
381
+ amounts = re.findall(r'amount-(\d+)', content)
382
+ percents = re.findall(r'percent-(\d+)', content)
383
+
384
+ return {
385
+ "success": True,
386
+ "anonymized_text": content,
387
+ "statistics": {
388
+ "company": len(set(companies)),
389
+ "person": len(set(persons)),
390
+ "amount": len(set(amounts)),
391
+ "percent": len(set(percents))
392
+ },
393
+ "usage": response.get("usage", {})
394
+ }
395
+
396
+ except Exception as e:
397
+ return {"success": False, "error": f"خطا: {str(e)}", "anonymized_text": ""}
398
+
399
+
400
+ class BatchProcessor:
401
+ """پردازشگر دسته‌ای با قابلیت retry"""
402
+
403
+ def __init__(
404
+ self,
405
+ provider: APIProvider,
406
+ api_key: str,
407
+ model: str = None,
408
+ rate_limit_config: RateLimitConfig = None,
409
+ max_retries_per_row: int = 3
410
+ ):
411
+ self.provider = provider
412
+ self.api_key = api_key
413
+ self.model = model
414
+ self.rate_limit_config = rate_limit_config or RateLimitConfig()
415
+ self.anonymizer = None
416
+ self.is_cancelled = False
417
+ self.current_progress = 0
418
+ self.total_rows = 0
419
+ self.processed_rows = 0
420
+ self.failed_rows = 0
421
+ self.start_time = None
422
+ self.consecutive_api_failures = 0
423
+ self.max_consecutive_failures = 10
424
+ self.max_retries_per_row = max_retries_per_row
425
+ self.failed_indices = [] # لیست ردیف‌های ناموفق برای retry
426
+
427
+ def cancel(self):
428
+ self.is_cancelled = True
429
+
430
+ def reset(self):
431
+ self.is_cancelled = False
432
+ self.current_progress = 0
433
+ self.total_rows = 0
434
+ self.processed_rows = 0
435
+ self.failed_rows = 0
436
+ self.start_time = None
437
+ self.consecutive_api_failures = 0
438
+ self.failed_indices = []
439
+
440
+ def _process_single_row(self, df, idx, text_column: str, output_column: str) -> Dict[str, Any]:
441
+ """پردازش یک ردیف و برگرداندن نتیجه"""
442
+ text = str(df.at[idx, text_column]) if pd.notna(df.at[idx, text_column]) else ""
443
+
444
+ if not text.strip():
445
+ df.at[idx, output_column] = ""
446
+ df.at[idx, "anonymization_status"] = "خالی"
447
+ return {"success": True, "skipped": True}
448
+
449
+ result = self.anonymizer.anonymize_text(text)
450
+
451
+ if result["success"]:
452
+ df.at[idx, output_column] = result["anonymized_text"]
453
+ df.at[idx, "anonymization_status"] = "موفق"
454
+ stats = result.get("statistics", {})
455
+ df.at[idx, "entities_found"] = f"C:{stats.get('company',0)}|P:{stats.get('person',0)}|A:{stats.get('amount',0)}|%:{stats.get('percent',0)}"
456
+ return {"success": True, "result": result}
457
+ else:
458
+ error_msg = result.get('error', 'خطای نامشخص')
459
+ df.at[idx, output_column] = ""
460
+ df.at[idx, "anonymization_status"] = f"ناموفق: {error_msg[:50]}"
461
+ return {"success": False, "error": error_msg, "result": result}
462
+
463
+ def process_csv(
464
+ self,
465
+ file_path: str,
466
+ text_column: str,
467
+ output_column: str = "anonymized_text"
468
+ ) -> Generator[Dict[str, Any], None, None]:
469
+ """پردازش فایل CSV با قابلیت retry خودکار"""
470
+
471
+ self.reset()
472
+ self.start_time = time.time()
473
+
474
+ # خواندن فایل
475
+ try:
476
+ df = pd.read_csv(file_path, encoding='utf-8')
477
+ except UnicodeDecodeError:
478
+ try:
479
+ df = pd.read_csv(file_path, encoding='utf-8-sig')
480
+ except:
481
+ df = pd.read_csv(file_path, encoding='cp1256')
482
+
483
+ if text_column not in df.columns:
484
+ yield {"type": "error", "message": f"ستون '{text_column}' یافت نشد"}
485
+ return
486
+
487
+ self.total_rows = len(df)
488
+
489
+ # ایجاد anonymizer
490
+ self.anonymizer = MultiProviderAnonymizer(
491
+ provider=self.provider,
492
+ api_key=self.api_key,
493
+ model=self.model,
494
+ rate_limit_config=self.rate_limit_config
495
+ )
496
+
497
+ df[output_column] = ""
498
+ df["anonymization_status"] = ""
499
+ df["entities_found"] = ""
500
+ df["retry_count"] = 0
501
+
502
+ yield {
503
+ "type": "info",
504
+ "message": f"🚀 شروع پردازش {self.total_rows} ردیف با {self.provider.value}..."
505
+ }
506
+
507
+ # ========== مرحله ۱: پردازش اولیه ==========
508
+ for idx, row in df.iterrows():
509
+ if self.is_cancelled:
510
+ yield {
511
+ "type": "cancelled",
512
+ "message": "لغو شد",
513
+ "processed": self.processed_rows,
514
+ "failed": self.failed_rows
515
+ }
516
+ break
517
+
518
+ if self.consecutive_api_failures >= self.max_consecutive_failures:
519
+ yield {
520
+ "type": "info",
521
+ "message": f"⚠️ {self.max_consecutive_failures} خطای متوالی - انتظار ۳۰ ثانیه..."
522
+ }
523
+ time.sleep(30)
524
+ self.consecutive_api_failures = 0
525
+
526
+ process_result = self._process_single_row(df, idx, text_column, output_column)
527
+
528
+ if process_result["success"]:
529
+ self.processed_rows += 1
530
+ self.consecutive_api_failures = 0
531
+ else:
532
+ self.failed_rows += 1
533
+ self.failed_indices.append(idx)
534
+ error = process_result.get("error", "")
535
+ if "rate limit" in error.lower() or "429" in error or "timeout" in error.lower():
536
+ self.consecutive_api_failures += 1
537
+
538
+ # پیشرفت
539
+ self.current_progress = (idx + 1) / self.total_rows * 100
540
+ elapsed = time.time() - self.start_time
541
+
542
+ yield {
543
+ "type": "progress",
544
+ "current": idx + 1,
545
+ "total": self.total_rows,
546
+ "progress": self.current_progress,
547
+ "processed": self.processed_rows,
548
+ "failed": self.failed_rows,
549
+ "elapsed": elapsed,
550
+ "estimated_remaining": (elapsed / (idx + 1)) * (self.total_rows - idx - 1),
551
+ "next_wait": self.anonymizer.rate_limiter.get_estimated_wait_time(),
552
+ "last_result": process_result.get("result", {}),
553
+ "rate_status": self.anonymizer.rate_limiter.get_status()
554
+ }
555
+
556
+ # Checkpoint هر 100 ردیف
557
+ if (idx + 1) % 100 == 0:
558
+ checkpoint_path = file_path.replace('.csv', f'_checkpoint_{idx+1}.csv')
559
+ df.to_csv(checkpoint_path, index=False, encoding='utf-8-sig')
560
+ yield {"type": "info", "message": f"💾 Checkpoint: {checkpoint_path}"}
561
+
562
+ # ========== مرحله ۲: Retry ردیف‌های ناموفق ==========
563
+ if self.failed_indices and not self.is_cancelled:
564
+ yield {
565
+ "type": "info",
566
+ "message": f"🔄 شروع Retry برای {len(self.failed_indices)} ردیف ناموفق..."
567
+ }
568
+
569
+ # انتظار قبل از retry
570
+ time.sleep(10)
571
+
572
+ for retry_round in range(1, self.max_retries_per_row + 1):
573
+ if not self.failed_indices or self.is_cancelled:
574
+ break
575
+
576
+ yield {
577
+ "type": "info",
578
+ "message": f"🔄 Retry دور {retry_round}/{self.max_retries_per_row} - {len(self.failed_indices)} ردیف باقیمانده"
579
+ }
580
+
581
+ # کپی لیست برای iterate
582
+ indices_to_retry = self.failed_indices.copy()
583
+ self.failed_indices = []
584
+
585
+ for idx in indices_to_retry:
586
+ if self.is_cancelled:
587
+ break
588
+
589
+ # افزایش تأخیر برای retry
590
+ time.sleep(self.rate_limit_config.min_delay_between_requests * 1.5)
591
+
592
+ df.at[idx, "retry_count"] = retry_round
593
+ process_result = self._process_single_row(df, idx, text_column, output_column)
594
+
595
+ if process_result["success"]:
596
+ self.processed_rows += 1
597
+ self.failed_rows -= 1
598
+ yield {
599
+ "type": "info",
600
+ "message": f"✅ ردیف {idx + 1} در تلاش {retry_round} موفق شد"
601
+ }
602
+ else:
603
+ self.failed_indices.append(idx)
604
+ yield {
605
+ "type": "info",
606
+ "message": f"❌ ردیف {idx + 1} در تلاش {retry_round} ناموفق: {process_result.get('error', '')[:30]}"
607
+ }
608
+
609
+ # انتظار بین دورهای retry
610
+ if self.failed_indices and retry_round < self.max_retries_per_row:
611
+ wait_time = 15 * retry_round
612
+ yield {
613
+ "type": "info",
614
+ "message": f"⏳ انتظار {wait_time} ثانیه قبل از دور بعدی retry..."
615
+ }
616
+ time.sleep(wait_time)
617
+
618
+ # ========== ذخیره نهایی ==========
619
+ if not self.is_cancelled:
620
+ output_path = file_path.replace('.csv', '_anonymized.csv')
621
+ if output_path == file_path:
622
+ output_path = file_path + '_anonymized.csv'
623
+
624
+ df.to_csv(output_path, index=False, encoding='utf-8-sig')
625
+
626
+ final_failed = len(self.failed_indices)
627
+
628
+ yield {
629
+ "type": "complete",
630
+ "message": f"✅ تکمیل شد! موفق: {self.processed_rows} | ناموفق نهایی: {final_failed}",
631
+ "output_path": output_path,
632
+ "total": self.total_rows,
633
+ "processed": self.processed_rows,
634
+ "failed": final_failed,
635
+ "total_time": time.time() - self.start_time,
636
+ "failed_indices": self.failed_indices
637
+ }
638
+
639
+
640
+ def create_interface():
641
+ """ایجاد رابط کاربری"""
642
+
643
+ custom_css = """
644
+ .rtl-text { direction: rtl; text-align: right; font-family: 'Vazirmatn', 'Tahoma', sans-serif; }
645
+ """
646
+
647
+ with gr.Blocks(css=custom_css, title="ناشناس‌ساز چند-API", theme=gr.themes.Soft()) as interface:
648
+ gr.Markdown("""
649
+ # 🔒 سیستم ناشناس‌سازی متون فارسی
650
+ ### پشتیبانی از Cerebras، Groq و OpenAI
651
+ """, elem_classes=["rtl-text"])
652
+
653
+ batch_processor = {"instance": None}
654
+
655
+ with gr.Tabs():
656
+ # تب پردازش تکی
657
+ with gr.Tab("پردازش تکی"):
658
+ with gr.Row():
659
+ with gr.Column():
660
+ single_provider = gr.Dropdown(
661
+ label="🌐 پرووایدر API",
662
+ choices=["cerebras", "groq", "openai"],
663
+ value="groq",
664
+ elem_classes=["rtl-text"]
665
+ )
666
+ single_api_key = gr.Textbox(
667
+ label="🔑 کلید API",
668
+ placeholder="کلید API خود را وارد کنید",
669
+ type="password"
670
+ )
671
+ single_model = gr.Dropdown(
672
+ label="🤖 مدل",
673
+ choices=["llama-3.3-70b-versatile", "llama-3.1-8b-instant"],
674
+ value="llama-3.3-70b-versatile"
675
+ )
676
+ single_input = gr.Textbox(
677
+ label="متن ورودی",
678
+ lines=5,
679
+ elem_classes=["rtl-text"]
680
+ )
681
+ single_btn = gr.Button("🔄 ناشناس‌سازی", variant="primary")
682
+
683
+ with gr.Column():
684
+ single_output = gr.Textbox(label="متن ناشناس‌شده", lines=5, elem_classes=["rtl-text"])
685
+ single_stats = gr.Textbox(label="آمار", lines=2)
686
+
687
+ # تب پردازش دسته‌ای
688
+ with gr.Tab("پردازش دسته‌ای"):
689
+ with gr.Row():
690
+ with gr.Column():
691
+ batch_provider = gr.Dropdown(
692
+ label="🌐 پرووایدر API",
693
+ choices=["cerebras", "groq", "openai"],
694
+ value="groq"
695
+ )
696
+ batch_api_key = gr.Textbox(
697
+ label="🔑 کلید API",
698
+ type="password"
699
+ )
700
+ batch_model = gr.Dropdown(
701
+ label="🤖 مدل",
702
+ choices=["llama-3.3-70b-versatile", "qwen-3-32b", "gpt-4o-mini"],
703
+ value="llama-3.3-70b-versatile"
704
+ )
705
+ csv_file = gr.File(label="📁 فایل CSV", file_types=[".csv"])
706
+ text_column = gr.Dropdown(label="ستون متن", choices=[], allow_custom_value=True)
707
+ output_column = gr.Textbox(label="نام ستون خروجی", value="anonymized_text")
708
+
709
+ with gr.Column():
710
+ delay_slider = gr.Slider(1, 30, 3, step=0.5, label="تأخیر (ثانیه)")
711
+ rpm_slider = gr.Slider(5, 30, 20, step=1, label="حداکثر درخواست/دقیقه")
712
+ retries_slider = gr.Slider(1, 10, 5, step=1, label="تلاش مجدد")
713
+
714
+ with gr.Row():
715
+ start_btn = gr.Button("🚀 شروع", variant="primary", size="lg")
716
+ cancel_btn = gr.Button("⏹️ لغو", variant="stop", size="lg")
717
+
718
+ progress_bar = gr.Slider(0, 100, 0, label="پیشرفت", interactive=False)
719
+ progress_text = gr.Markdown("در انتظار...")
720
+ time_stats = gr.Markdown("")
721
+ process_log = gr.Textbox(lines=10, label="لاگ")
722
+ preview_table = gr.Dataframe(headers=["متن", "ناشناس‌شده", "وضعیت"])
723
+ output_file = gr.File(label="دانلود", visible=False)
724
+
725
+ # توابع
726
+ def update_models(provider):
727
+ if provider == "cerebras":
728
+ return gr.update(choices=["qwen-3-32b", "llama-4-scout-17b-16e-instruct"], value="qwen-3-32b")
729
+ elif provider == "groq":
730
+ return gr.update(choices=["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"], value="llama-3.3-70b-versatile")
731
+ else:
732
+ return gr.update(choices=["gpt-4o-mini", "gpt-4o"], value="gpt-4o-mini")
733
+
734
+ def update_columns(file):
735
+ if file is None:
736
+ return gr.update(choices=[], value=None)
737
+ try:
738
+ df = pd.read_csv(file.name, nrows=0, encoding='utf-8')
739
+ except:
740
+ try:
741
+ df = pd.read_csv(file.name, nrows=0, encoding='utf-8-sig')
742
+ except:
743
+ df = pd.read_csv(file.name, nrows=0, encoding='cp1256')
744
+ columns = list(df.columns)
745
+ return gr.update(choices=columns, value=columns[0] if columns else None)
746
+
747
+ def process_single(provider, api_key, model, text):
748
+ if not text.strip():
749
+ return "", "❌ متن خالی"
750
+ if not api_key:
751
+ return "", "❌ کلید API وارد نشده"
752
+
753
+ try:
754
+ prov = APIProvider(provider)
755
+ anonymizer = MultiProviderAnonymizer(provider=prov, api_key=api_key, model=model)
756
+ result = anonymizer.anonymize_text(text)
757
+
758
+ if result["success"]:
759
+ stats = result.get("statistics", {})
760
+ return result["anonymized_text"], f"✅ C:{stats.get('company',0)} | P:{stats.get('person',0)} | A:{stats.get('amount',0)} | %:{stats.get('percent',0)}"
761
+ else:
762
+ return "", f"❌ {result.get('error', '')}"
763
+ except Exception as e:
764
+ return "", f"❌ {str(e)}"
765
+
766
+ def start_batch(file, text_col, output_col, delay, rpm, retries, provider, api_key, model):
767
+ if file is None:
768
+ yield (0, "❌ فایل انتخاب نشده", "", "", None, gr.update(visible=False))
769
+ return
770
+ if not api_key:
771
+ yield (0, "❌ کلید API وارد نشده", "", "", None, gr.update(visible=False))
772
+ return
773
+
774
+ prov = APIProvider(provider)
775
+ rate_config = RateLimitConfig(
776
+ requests_per_minute=int(rpm),
777
+ min_delay_between_requests=float(delay),
778
+ max_retries=int(retries)
779
+ )
780
+
781
+ processor = BatchProcessor(provider=prov, api_key=api_key, model=model, rate_limit_config=rate_config)
782
+ batch_processor["instance"] = processor
783
+
784
+ log_lines = []
785
+ preview_data = []
786
+
787
+ for update in processor.process_csv(file.name, text_col, output_col):
788
+ update_type = update.get("type")
789
+
790
+ if update_type == "error":
791
+ log_lines.append(f"❌ {update['message']}")
792
+ yield (0, f"❌ {update['message']}", "", "\n".join(log_lines), None, gr.update(visible=False))
793
+ return
794
+
795
+ elif update_type == "info":
796
+ log_lines.append(f"ℹ️ {update['message']}")
797
+
798
+ elif update_type == "progress":
799
+ progress = update["progress"]
800
+ current = update["current"]
801
+ total = update["total"]
802
+ elapsed = update["elapsed"]
803
+ remaining = update["estimated_remaining"]
804
+
805
+ progress_md = f"**{current}/{total}** ({progress:.1f}%) | ✅ {update['processed']} | ❌ {update['failed']}"
806
+ time_md = f"⏱️ {elapsed/60:.1f}m | باقیمانده: {remaining/60:.1f}m"
807
+
808
+ if current % 10 == 0:
809
+ log_lines.append(f"📊 {current}/{total}")
810
+
811
+ last_result = update.get("last_result", {})
812
+ if last_result.get("success"):
813
+ preview_data.append(["...", last_result.get("anonymized_text", "")[:80] + "...", "✅"])
814
+ if len(preview_data) > 5:
815
+ preview_data = preview_data[-5:]
816
+
817
+ yield (progress, progress_md, time_md, "\n".join(log_lines[-15:]), preview_data if preview_data else None, gr.update(visible=False))
818
+
819
+ elif update_type == "cancelled":
820
+ log_lines.append("⏹️ لغو شد")
821
+ yield (0, "⏹️ لغو شد", "", "\n".join(log_lines), preview_data if preview_data else None, gr.update(visible=False))
822
+ return
823
+
824
+ elif update_type == "complete":
825
+ log_lines.append(f"✅ {update['message']}")
826
+ progress_md = f"✅ تکمیل! {update['processed']}/{update['total']} موفق | {update['total_time']/60:.1f} دقیقه"
827
+ yield (100, progress_md, "", "\n".join(log_lines), preview_data if preview_data else None, gr.update(value=update['output_path'], visible=True))
828
+
829
+ def cancel():
830
+ if batch_processor["instance"]:
831
+ batch_processor["instance"].cancel()
832
+ return "⏹️ لغو شد..."
833
+
834
+ # اتصالات
835
+ single_provider.change(update_models, [single_provider], [single_model])
836
+ batch_provider.change(update_models, [batch_provider], [batch_model])
837
+ csv_file.change(update_columns, [csv_file], [text_column])
838
+
839
+ single_btn.click(process_single, [single_provider, single_api_key, single_model, single_input], [single_output, single_stats])
840
+
841
+ start_btn.click(
842
+ start_batch,
843
+ [csv_file, text_column, output_column, delay_slider, rpm_slider, retries_slider, batch_provider, batch_api_key, batch_model],
844
+ [progress_bar, progress_text, time_stats, process_log, preview_table, output_file]
845
+ )
846
+
847
+ cancel_btn.click(cancel, outputs=[process_log])
848
+
849
+ return interface
850
+
851
+
852
+ if __name__ == "__main__":
853
+ interface = create_interface()
854
+ interface.launch(server_name="0.0.0.0", server_port=7860, share=True, show_error=True)