leilaghomashchi commited on
Commit
9db7e09
·
verified ·
1 Parent(s): dfbf6c3

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +386 -9
  2. app_updated.py +1252 -0
  3. config.py +232 -0
  4. test_modules.py +287 -0
README.md CHANGED
@@ -1,12 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Chunking Test
3
- emoji: 🐨
4
- colorFrom: purple
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.5.1
8
- app_file: app.py
9
- pinned: false
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # 🔐 Persian Anonymizer - Intelligent Chunking & Merge
2
+
3
+ سیستم پیشرفته ناشناس‌سازی متون فارسی با قابلیت پردازش متن‌های بلند
4
+
5
+ ## 📋 فهرست
6
+
7
+ - [ویژگی‌ها](#ویژگی‌ها)
8
+ - [نصب و راه‌اندازی](#نصب-و-راه‌اندازی)
9
+ - [ساختار پروژه](#ساختار-پروژه)
10
+ - [نحوه استفاده](#نحوه-استفاده)
11
+ - [معماری سیستم](#معماری-سیستم)
12
+ - [تنظیمات](#تنظیمات)
13
+ - [تست](#تست)
14
+
15
+ ---
16
+
17
+ ## ✨ ویژگی‌ها
18
+
19
+ ### ✅ Chunking هوشمند
20
+ - تقسیم خودکار متن‌های بلند به chunks کوچک‌تر
21
+ - Overlap بین chunks برای جلوگیری از split شدن entities
22
+ - تقسیم بر اساس جملات (حفظ معنا)
23
+
24
+ ### ✅ Intelligent Merge
25
+ - **Exact Matching**: تطابق دقیق entities تکراری
26
+ - **Fuzzy Matching**: تطابق تقریبی برای شرکت‌ها (مثلاً "شرکت ملی نفت" vs "شرکت ملی نفت ایران")
27
+ - **Normalization**: نرمال‌سازی پیشرفته برای دقت بالا
28
+
29
+ ### ✅ Entity Types
30
+ - 👤 Person (اسامی اشخاص)
31
+ - 🏢 Company (نام شرکت‌ها)
32
+ - 💰 Amount (مبالغ مالی)
33
+ - 📊 Percent (درصدها)
34
+
35
+ ### ✅ Multi-LLM Support
36
+ - ChatGPT (GPT-4, GPT-5)
37
+ - Grok (xAI)
38
+
39
+ ---
40
+
41
+ ## 🚀 نصب و راه‌اندازی
42
+
43
+ ### پیش‌نیازها
44
+
45
+ ```bash
46
+ pip install gradio requests
47
+ ```
48
+
49
+ اختیاری (برای token counting دقیق):
50
+ ```bash
51
+ pip install tiktoken
52
+ ```
53
+
54
+ ### نصب
55
+
56
+ ```bash
57
+ # کلون پروژه
58
+ git clone <repository-url>
59
+ cd persian-anonymizer
60
+
61
+ # تست ماژول‌ها
62
+ python test_modules.py
63
+ ```
64
+
65
+ ### تنظیم API Keys
66
+
67
+ #### روش 1: متغیرهای محیطی
68
+
69
+ ```bash
70
+ export CEREBRAS_API_KEY="your-cerebras-key"
71
+ export OPENAI_API_KEY="your-openai-key"
72
+ export XAI_API_KEY="your-xai-key"
73
+ ```
74
+
75
+ #### روش 2: Hugging Face Secrets
76
+
77
+ در Hugging Face Spaces:
78
+ ```
79
+ Settings → Secrets → Add Secret
80
+ ```
81
+
82
+ ---
83
+
84
+ ## 📁 ساختار پروژه
85
+
86
+ ```
87
+ persian-anonymizer/
88
+
89
+ ├── modules/ # 📦 ماژول‌های اصلی
90
+ │ ├── __init__.py # Package initialization
91
+ │ ├── utils.py # توابع کمکی
92
+ │ ├── chunker.py # Chunking logic
93
+ │ ├── normalizer.py # Entity normalization
94
+ │ └── merger.py # Intelligent merge
95
+
96
+ ├── config.py # ⚙️ تنظیمات
97
+ ├── test_modules.py # 🧪 تست‌ها
98
+ ├── app.py # 🖥️ برنامه اصلی Gradio
99
+ ├── llm_sender_unified.py # 🤖 LLM integration
100
+ └── README.md # 📖 این فایل
101
+ ```
102
+
103
+ ---
104
+
105
+ ## 💡 نحوه استفاده
106
+
107
+ ### استفاده پایه
108
+
109
+ ```python
110
+ from modules import TextChunker, EntityMerger, should_use_chunking
111
+
112
+ # بررسی نیاز به chunking
113
+ text = "متن بلند شما..."
114
+ if should_use_chunking(text):
115
+ print("نیاز به chunking دارد")
116
+ ```
117
+
118
+ ### Chunking
119
+
120
+ ```python
121
+ from modules import TextChunker
122
+
123
+ chunker = TextChunker(chunk_size=1000, overlap=150)
124
+ chunks = chunker.create_chunks(text)
125
+
126
+ for chunk in chunks:
127
+ print(f"{chunk['chunk_id']}: {chunk['tokens']} tokens")
128
+ ```
129
+
130
+ ### Normalization
131
+
132
+ ```python
133
+ from modules import EntityNormalizer
134
+
135
+ normalizer = EntityNormalizer()
136
+
137
+ # نرمال‌سازی اسم شخص
138
+ person = normalizer.normalize_person("آقای علی احمدی")
139
+ # Result: "علی احمدی"
140
+
141
+ # نرمال‌سازی شرکت
142
+ company = normalizer.normalize_company("ش. ملی نفت ایران")
143
+ # Result: "شرکت ملی نفت"
144
+ ```
145
+
146
+ ### Merge
147
+
148
+ ```python
149
+ from modules import EntityMerger
150
+
151
+ merger = EntityMerger(fuzzy_threshold=0.75)
152
+
153
+ # دو mapping table
154
+ tables = [
155
+ {
156
+ "chunk_id": "chunk_01",
157
+ "mapping": {"company-01": "شرکت پارس", ...}
158
+ },
159
+ {
160
+ "chunk_id": "chunk_02",
161
+ "mapping": {"company-01": "شرکت پارس", ...} # فاصله اضافی
162
+ }
163
+ ]
164
+
165
+ result = merger.merge_mappings(tables)
166
+ # company-01 در chunk_02 به company-01 در global map می‌شود
167
+ ```
168
+
169
+ ---
170
+
171
+ ## 🏗️ معماری سیستم
172
+
173
+ ### جریان کار کلی
174
+
175
+ ```
176
+ 1️⃣ ورودی متن
177
+
178
+ 2️⃣ تشخیص سایز (> threshold?)
179
+ ├─ NO → روش ساده (بدون chunking)
180
+ └─ YES → روش chunking
181
+
182
+ 3️⃣ Chunking با overlap
183
+
184
+ 4️⃣ ناشناس‌سازی هر chunk (Cerebras)
185
+
186
+ 5️⃣ Intelligent Merge
187
+ ├─ Normalization
188
+ ├─ Exact Matching
189
+ ├─ Fuzzy Matching (company only)
190
+ └─ Re-numbering
191
+
192
+ 6️⃣ Global Mapping یکپارچه
193
+
194
+ 7️⃣ ارسال به LLM (ChatGPT/Grok)
195
+
196
+ 8️⃣ Restore با global mapping
197
+ ```
198
+
199
+ ### Merge Algorithm
200
+
201
+ ```
202
+ برای هر entity در chunk جدی��:
203
+
204
+ 1. Normalize کن
205
+
206
+ 2. Exact Match چک کن
207
+ → اگر match → از placeholder قبلی استفاده کن
208
+
209
+ 3. Fuzzy Match (فقط company)
210
+ → اگر similarity > threshold → merge
211
+
212
+ 4. اگر match نشد → placeholder جدید بساز
213
+ ```
214
+
215
+ ---
216
+
217
+ ## ⚙️ تنظیمات
218
+
219
+ فایل `config.py` را ویرایش کنید:
220
+
221
+ ### Chunking
222
+
223
+ ```python
224
+ CHUNK_SIZE = 1000 # حداکثر سایز chunk (tokens)
225
+ CHUNK_OVERLAP = 150 # overlap بین chunks
226
+ CHUNKING_THRESHOLD = 6000 # حد آستانه استفاده از chunking
227
+ ```
228
+
229
+ ### Merge
230
+
231
+ ```python
232
+ FUZZY_MATCH_THRESHOLD = 0.75 # حد آستانه fuzzy matching
233
+ ENABLE_FUZZY_MATCHING = True # فعال/غیرفعال
234
+ ```
235
+
236
+ ### توصیه‌ها:
237
+
238
+ - **0.85**: بسیار محافظه‌کارانه (کمتر match)
239
+ - **0.75**: پیش‌فرض (توصیه می‌شود)
240
+ - **0.65**: ریسکی (بیشتر match - احتمال false positive)
241
+
242
+ ---
243
+
244
+ ## 🧪 تست
245
+
246
+ ### اجرای تمام تست‌ها:
247
+
248
+ ```bash
249
+ python test_modules.py
250
+ ```
251
+
252
+ ### تست جداگانه هر ماژول:
253
+
254
+ ```bash
255
+ # Utils
256
+ python modules/utils.py
257
+
258
+ # Normalizer
259
+ python modules/normalizer.py
260
+
261
+ # Chunker
262
+ python modules/chunker.py
263
+
264
+ # Merger
265
+ python modules/merger.py
266
+ ```
267
+
268
+ ### خروجی مورد انتظار:
269
+
270
+ ```
271
+ 🚀🚀🚀 Starting Complete Test Suite 🚀🚀🚀
272
+
273
+ 🧪 Test 1: Utils Module
274
+ ✅ Token Counting
275
+ ✅ Chunking Decision
276
+ ✅ Utils tests passed!
277
+
278
+ 🧪 Test 2: Normalizer Module
279
+ ✅ Person Normalization
280
+ ✅ Company Normalization
281
+ ✅ Amount Normalization
282
+ ✅ Percent Normalization
283
+ ✅ Normalizer tests passed!
284
+
285
+ ...
286
+
287
+ ✅✅✅ ALL TESTS PASSED! ✅✅✅
288
+ ```
289
+
290
+ ---
291
+
292
+ ## 📊 مثال‌های عملی
293
+
294
+ ### مثال 1: متن کوتاه (بدون chunking)
295
+
296
+ ```python
297
+ text = "شرکت پارس فروش 50 میلیارد ریال داشت."
298
+ # → روش ساده (مستقیم به Cerebras)
299
+ ```
300
+
301
+ ### مثال 2: متن بلند (با chunking)
302
+
303
+ ```python
304
+ text = "..." * 1000 # متن 1000 جمله‌ای
305
+ # → Chunking → Merge → Global Mapping
306
+ ```
307
+
308
+ ### مثال 3: Entity تکراری در chunks مختلف
309
+
310
+ ```
311
+ Chunk 1: "شرکت پارس فروش داشت"
312
+ → company-01 = "شرکت پارس"
313
+
314
+ Chunk 2: "شرکت پارس رشد کرد" (فاصله اضافی)
315
+ → Exact Match → company-01 (همون!)
316
+ ```
317
+
318
+ ---
319
+
320
+ ## 🐛 عیب‌یابی
321
+
322
+ ### مشکل: "No module named modules"
323
+
324
+ ```bash
325
+ # اطمینان از اجرا از root directory
326
+ cd persian-anonymizer
327
+ python test_modules.py
328
+ ```
329
+
330
+ ### مشکل: Cerebras API Error
331
+
332
+ ```bash
333
+ # بررسی API key
334
+ echo $CEREBRAS_API_KEY
335
+
336
+ # تست اتصال
337
+ curl -H "Authorization: Bearer $CEREBRAS_API_KEY" \
338
+ https://api.cerebras.ai/v1/models
339
+ ```
340
+
341
+ ### مشکل: Token counting inaccurate
342
+
343
+ ```bash
344
+ # نصب tiktoken برای دقت بیشتر
345
+ pip install tiktoken
346
+
347
+ # فعال‌سازی در config.py
348
+ USE_ACCURATE_TOKEN_COUNTING = True
349
+ ```
350
+
351
+ ---
352
+
353
+ ## 📈 Performance Tips
354
+
355
+ 1. **Chunk Size**:
356
+ - کوچک‌تر (500) = دقیق‌تر اما کندتر
357
+ - بزرگ‌تر (1500) = سریع‌تر اما کمتر دقیق
358
+
359
+ 2. **Overlap**:
360
+ - 10-15% از chunk_size توصیه می‌شود
361
+
362
+ 3. **Fuzzy Threshold**:
363
+ - بالاتر = کمتر false positive
364
+ - پایین‌تر = بیشتر match (اما ریسک)
365
+
366
+ ---
367
+
368
+ ## 🤝 مشارکت
369
+
370
+ برای گزارش باگ یا پیشنهاد ویژگی جدید، لطفاً Issue ایجاد کنید.
371
+
372
  ---
373
+
374
+ ## 📄 License
375
+
376
+ MIT License
377
+
378
+ ---
379
+
380
+ ## 🙏 تشکر
381
+
382
+ - Cerebras AI برای ناشناس‌سازی
383
+ - OpenAI برای ChatGPT
384
+ - xAI برای Grok
385
+ - Anthropic برای Claude (این README توسط Claude نوشته شده!)
386
+
387
  ---
388
 
389
+ **ساخته شده با ❤️ برای جامعه پردازش زبان فارسی**
app_updated.py ADDED
@@ -0,0 +1,1252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # ✅ اضافه شده: ماژول‌های chunking و merge
11
+ try:
12
+ from modules import (
13
+ TextChunker,
14
+ EntityMerger,
15
+ count_tokens,
16
+ should_use_chunking
17
+ )
18
+ CHUNKING_AVAILABLE = True
19
+ logger.info("✅ ماژول‌های chunking و merge بارگذاری شدند")
20
+ except ImportError as e:
21
+ CHUNKING_AVAILABLE = False
22
+ logger.warning(f"⚠️ ماژول‌های chunking در دسترس نیستند: {e}")
23
+ logger.warning("⚠️ فقط روش ساده (بدون chunking) کار می‌کند")
24
+
25
+
26
+ # ✅ مدل‌های موجود - به‌روزرسانی نوامبر 2024
27
+ AVAILABLE_MODELS = {
28
+ "chatgpt": [
29
+ # GPT-5 Series (جدیدترین)
30
+ "gpt-5.1", # بهترین برای کدنویسی و وظایف agentic
31
+ "gpt-5", # مدل reasoning قبلی
32
+ # GPT-4 Series
33
+ "gpt-4.1", # هوشمندترین non-reasoning
34
+ "gpt-4o", # قدرتمند
35
+ "gpt-4o-mini", # سریع و ارزان
36
+ "gpt-4-turbo", # سریع‌تر از GPT-4
37
+ ],
38
+ "grok": [
39
+ # Grok-4 Series (جدیدترین)
40
+ "grok-4-fast-reasoning", # سریع با reasoning
41
+ "grok-4-fast-non-reasoning", # سریع بدون reasoning
42
+ "grok-4-0709", # نسخه پایدار
43
+ # Grok-3 Series
44
+ "grok-3", # قدرتمند
45
+ "grok-3-mini", # سبک
46
+ # Grok-2 Series
47
+ "grok-2-vision-1212", # با قابلیت بینایی
48
+ "grok-2-1212", # نسخه پایدار
49
+ "grok-2" # نسخه قدیمی
50
+ ]
51
+ }
52
+
53
+ logging.basicConfig(level=logging.INFO)
54
+ logger = logging.getLogger(__name__)
55
+
56
+ class AnonymizerAdvanced:
57
+ """ناشناس‌ساز پیشرفته با روش‌های متعدد"""
58
+
59
+ def __init__(
60
+ self,
61
+ cerebras_key: str = None,
62
+ llm_provider: str = "chatgpt",
63
+ llm_model: str = None,
64
+ entities_to_anonymize: List[str] = None
65
+ ):
66
+ self.cerebras_key = cerebras_key or os.getenv("CEREBRAS_API_KEY")
67
+ self.llm_provider = llm_provider
68
+ self.llm_model = llm_model
69
+ self.entities_to_anonymize = entities_to_anonymize or ["person", "company", "amount", "percent"]
70
+ self.mapping_table = {}
71
+ self.reverse_mapping = {}
72
+
73
+ # ایجاد LLM sender
74
+
75
+ # ✅ اضافه شده: ایجاد Chunker و Merger
76
+ if CHUNKING_AVAILABLE:
77
+ self.chunker = TextChunker(chunk_size=1000, overlap=150)
78
+ self.merger = EntityMerger(fuzzy_threshold=0.75)
79
+ logger.info("✅ Chunker و Merger مقداردهی شدند")
80
+ else:
81
+ self.chunker = None
82
+ self.merger = None
83
+
84
+ self._create_llm_sender()
85
+
86
+ logger.info(f"✅ Anonymizer Advanced مقداردهی شد با {llm_provider}")
87
+
88
+ def _create_llm_sender(self):
89
+ """ایجاد LLM sender مناسب"""
90
+ try:
91
+ # ✅ همیشه از Hugging Face Secrets استفاده کن
92
+ if self.llm_provider == "chatgpt":
93
+ api_key = os.getenv("OPENAI_API_KEY")
94
+ logger.info("🔑 استفاده از OPENAI_API_KEY از Secrets")
95
+ elif self.llm_provider == "grok":
96
+ api_key = os.getenv("XAI_API_KEY")
97
+ logger.info("🔑 استفاده از XAI_API_KEY از Secrets")
98
+ else:
99
+ api_key = None
100
+ logger.warning("⚠️ Provider ناشناخته")
101
+
102
+ # ایجاد sender
103
+ self.llm_sender = create_llm_sender(
104
+ provider=self.llm_provider,
105
+ api_key=api_key,
106
+ model=self.llm_model
107
+ )
108
+
109
+ logger.info(f"✅ LLM Sender ایجاد شد: {self.llm_provider} - {self.llm_sender.model}")
110
+
111
+ except Exception as e:
112
+ logger.error(f"❌ خطا در ایجاد LLM Sender: {e}")
113
+ # fallback to ChatGPT
114
+ self.llm_sender = create_llm_sender("chatgpt")
115
+
116
+ def set_llm_provider(self, provider: str, model: str = None, entities: List[str] = None):
117
+ """تغییر provider و مدل LLM و موجودیت‌های ناشناس‌سازی"""
118
+ self.llm_provider = provider
119
+ self.llm_model = model
120
+ if entities is not None:
121
+ self.entities_to_anonymize = entities
122
+ self._create_llm_sender()
123
+ logger.info(f"✅ LLM تغییر یافت به: {provider} - {model}")
124
+ logger.info(f"✅ موجودیت‌های ناشناس‌سازی: {self.entities_to_anonymize}")
125
+
126
+ def anonymize_with_cerebras(self, text: str) -> Tuple[str, Dict]:
127
+ """ناشناس‌سازی با Cerebras - بر اساس موجودیت‌های انتخابی"""
128
+ logger.info("🧠 روش Cerebras...")
129
+
130
+ if not self.cerebras_key:
131
+ logger.error("❌ Cerebras API Key موجود نیست")
132
+ raise ValueError("Cerebras API Key مورد نیاز است")
133
+
134
+ # ✅ ساخت دستورات بر اساس موجودیت‌های انتخابی
135
+ instructions = []
136
+ instruction_number = 1
137
+
138
+ if "person" in self.entities_to_anonymize:
139
+ instructions.append(f"{instruction_number}. اسامی اشخاص → person-01, person-02, ...")
140
+ instruction_number += 1
141
+
142
+ if "company" in self.entities_to_anonymize:
143
+ instructions.append(f"{instruction_number}. نام شرکت‌ها/سازمان‌ها → company-01, company-02, ...")
144
+ instruction_number += 1
145
+
146
+ if "amount" in self.entities_to_anonymize:
147
+ instructions.append(f"{instruction_number}. اعداد و ارقام و مبالغ (مثل: 50 میلیارد، 100 هزار، 25.5 میلیون، ۳۰۰ دستگاه) → amount-01, amount-02, ...")
148
+ instruction_number += 1
149
+
150
+ if "percent" in self.entities_to_anonymize:
151
+ instructions.append(f"{instruction_number}. درصدها → percent-01, percent-02, ...")
152
+ instruction_number += 1
153
+
154
+ # اگه هیچی انتخاب نشده، متن رو همون‌طور برگردون
155
+ if not instructions:
156
+ logger.warning("⚠️ هیچ موجودیتی برای ناشناس‌سازی انتخاب نشده!")
157
+ return text, {}
158
+
159
+ instructions_text = "\n".join(instructions)
160
+ instructions_text += f"\n{instruction_number}. فقط این توکن‌ها استفاده کنید"
161
+ instructions_text += f"\n{instruction_number + 1}. شماره‌های نسخه را درست حفظ کنید"
162
+ instructions_text += f"\n{instruction_number + 2}. اگر موجودیت تکرار شود از شماره قدیمی استفاده کنید"
163
+
164
+ try:
165
+ # مرحله 1: ناشناس‌سازی متن
166
+ # ✅ ساخت مثال برای amount (اگر انتخاب شده)
167
+ example_text = ""
168
+ if "amount" in self.entities_to_anonymize:
169
+ example_text = """
170
+ مثال:
171
+ متن اصلی: "فروش 50 میلیارد ریال در سال گذشته بود."
172
+ متن ناشناس: "فروش amount-01 در سال گذشته بود."
173
+ """
174
+
175
+ prompt1 = f"""متن زیر را ناشناس کنید. قوانین:
176
+ {instructions_text}
177
+ {example_text}
178
+ متن:
179
+ {text}
180
+
181
+ خروجی: فقط متن ناشناس شده (بدون توضیح اضافی)"""
182
+
183
+ response1 = requests.post(
184
+ "https://api.cerebras.ai/v1/chat/completions",
185
+ headers={
186
+ "Authorization": f"Bearer {self.cerebras_key}",
187
+ "Content-Type": "application/json"
188
+ },
189
+ json={
190
+ "model": "llama-3.3-70b",
191
+ "messages": [{"role": "user", "content": prompt1}],
192
+ "max_tokens": 4096,
193
+ "temperature": 0.1
194
+ },
195
+ timeout=60
196
+ )
197
+
198
+ if response1.status_code != 200:
199
+ logger.error(f"❌ Cerebras Error: {response1.status_code}")
200
+ raise Exception(f"Cerebras API Error: {response1.status_code}")
201
+
202
+ anonymized_text = response1.json()['choices'][0]['message']['content'].strip()
203
+ logger.info("✅ Cerebras: ناشناس‌سازی موفق")
204
+
205
+ # مرحله 2: استخراج mapping - فقط برای موجودیت‌های انتخابی
206
+ mapping_instructions = []
207
+ json_example = "{\n"
208
+
209
+ if "person" in self.entities_to_anonymize:
210
+ mapping_instructions.append('- برای person-XX: نام کامل شخص (مثلاً "علی احمدی")')
211
+ json_example += ' "person-01": "متن اصلی کامل",\n'
212
+
213
+ if "company" in self.entities_to_anonymize:
214
+ mapping_instructions.append('- برای company-XX: نام کامل شرکت/سازمان (مثلاً "شرکت پتروشیمی")')
215
+ json_example += ' "company-01": "متن اصلی کامل",\n'
216
+
217
+ if "amount" in self.entities_to_anonymize:
218
+ mapping_instructions.append('- برای amount-XX: عدد + واحد (مثلاً "80 هزار تومان" یا "50 میلیارد ریال")')
219
+ json_example += ' "amount-01": "متن اصلی کامل با واحد",\n'
220
+
221
+ if "percent" in self.entities_to_anonymize:
222
+ mapping_instructions.append('- برای percent-XX: عدد + کلمه "درصد" (مثلاً "40 درصد" نه فقط "40")')
223
+ json_example += ' "percent-01": "عدد + درصد",\n'
224
+
225
+ json_example += " ...\n}"
226
+ mapping_instructions_text = "\n".join(mapping_instructions)
227
+
228
+ prompt2 = f"""متن اصلی:
229
+ {text}
230
+
231
+ متن ناشناس شده:
232
+ {anonymized_text}
233
+
234
+ لطفاً یک جدول mapping برای همه توکن‌های ناشناس ایجاد کن.
235
+ برای هر توکن، متن اصلی کامل آن را مشخص کن.
236
+
237
+ **مهم:**
238
+ {mapping_instructions_text}
239
+
240
+ خروجی را به این فرمت JSON بده (فقط JSON، بدون توضیح اضافی):
241
+ {json_example}"""
242
+
243
+ response2 = requests.post(
244
+ "https://api.cerebras.ai/v1/chat/completions",
245
+ headers={
246
+ "Authorization": f"Bearer {self.cerebras_key}",
247
+ "Content-Type": "application/json"
248
+ },
249
+ json={
250
+ "model": "llama-3.3-70b",
251
+ "messages": [{"role": "user", "content": prompt2}],
252
+ "max_tokens": 2048,
253
+ "temperature": 0.1
254
+ },
255
+ timeout=60
256
+ )
257
+
258
+ if response2.status_code == 200:
259
+ mapping_text = response2.json()['choices'][0]['message']['content'].strip()
260
+ mapping_text = mapping_text.replace('```json', '').replace('```', '').strip()
261
+
262
+ try:
263
+ self.mapping_table = json.loads(mapping_text)
264
+ self._fix_percent_mapping()
265
+ self.reverse_mapping = {v: k for k, v in self.mapping_table.items()}
266
+ logger.info(f"✅ Mapping استخراج شد: {len(self.mapping_table)} موجودیت")
267
+ except json.JSONDecodeError:
268
+ logger.warning("⚠️ خطا در parse کردن JSON mapping - استفاده از روش fallback")
269
+ self._extract_mapping_from_text(text, anonymized_text)
270
+ else:
271
+ logger.warning("⚠️ خطا در دریافت mapping - استفاده از روش fallback")
272
+ self._extract_mapping_from_text(text, anonymized_text)
273
+
274
+ return anonymized_text, self.mapping_table
275
+
276
+ def anonymize_with_chunking(self, text: str) -> Tuple[str, Dict]:
277
+ """
278
+ ناشناس‌سازی با استفاده از Chunking (برای متن‌های بلند)
279
+
280
+ این متد برای متن‌های بلند که بیشتر از حد آستانه هستند استفاده می‌شود.
281
+ """
282
+ logger.info("🔪 روش Chunking برای متن بلند...")
283
+
284
+ if not CHUNKING_AVAILABLE:
285
+ logger.error("❌ ماژول‌های chunking در دسترس نیستند!")
286
+ raise ImportError("لطفاً ماژول‌های chunking را نصب کنید")
287
+
288
+ try:
289
+ # 1. تقسیم به chunks
290
+ logger.info("🔪 STEP 1: تقسیم به chunks...")
291
+ chunks = self.chunker.create_chunks(text)
292
+ logger.info(f"✅ تقسیم به {len(chunks)} chunks")
293
+
294
+ # 2. ناشناس‌سازی هر chunk
295
+ logger.info("🔐 STEP 2: ناشناس‌سازی chunks...")
296
+ mapping_tables = []
297
+ anonymized_chunks = []
298
+
299
+ for i, chunk_data in enumerate(chunks, start=1):
300
+ logger.info(f" Processing {chunk_data['chunk_id']} ({i}/{len(chunks)})...")
301
+
302
+ # ناشناس‌سازی این chunk
303
+ anon_text, mapping = self.anonymize_with_cerebras(chunk_data['text'])
304
+
305
+ # ذخیره
306
+ anonymized_chunks.append({
307
+ **chunk_data,
308
+ "anonymized_text": anon_text
309
+ })
310
+
311
+ mapping_tables.append({
312
+ "chunk_id": chunk_data['chunk_id'],
313
+ "mapping": mapping
314
+ })
315
+
316
+ logger.info(f" ✅ {len(mapping)} entities in {chunk_data['chunk_id']}")
317
+
318
+ logger.info(f"✅ تمام chunks ناشناس شدند")
319
+
320
+ # 3. Merge mappings
321
+ logger.info("🔄 STEP 3: Intelligent Merge...")
322
+ merge_result = self.merger.merge_mappings(mapping_tables)
323
+ global_mapping = merge_result['global_mapping']
324
+ remapping_list = merge_result['remapping']
325
+
326
+ logger.info(f"✅ Merged به {len(global_mapping)} global entities")
327
+
328
+ # 4. Re-map chunks
329
+ logger.info("🔄 STEP 4: Re-mapping chunks...")
330
+ for i, chunk_data in enumerate(anonymized_chunks):
331
+ remap = remapping_list[i]['mapping']
332
+
333
+ if remap:
334
+ anon_text = chunk_data['anonymized_text']
335
+
336
+ for old_placeholder, new_placeholder in remap.items():
337
+ anon_text = anon_text.replace(old_placeholder, new_placeholder)
338
+
339
+ chunk_data['anonymized_text'] = anon_text
340
+ logger.info(f" ✅ Re-mapped {chunk_data['chunk_id']}: {len(remap)} changes")
341
+
342
+ # 5. ترکیب chunks
343
+ logger.info("📦 STEP 5: ترکیب chunks...")
344
+ full_anonymized = "\n\n".join([
345
+ c['anonymized_text'] for c in anonymized_chunks
346
+ ])
347
+
348
+ # 6. بازگردانی global mapping
349
+ self.mapping_table = global_mapping
350
+ self.reverse_mapping = {v: k for k, v in global_mapping.items()}
351
+
352
+ logger.info(f"✅ Chunking کامل شد: {len(global_mapping)} entities")
353
+
354
+ return full_anonymized, global_mapping
355
+
356
+ except Exception as e:
357
+ logger.error(f"❌ خطا در chunking: {e}", exc_info=True)
358
+ raise
359
+
360
+
361
+ except Exception as e:
362
+ logger.error(f"❌ Cerebras Exception: {e}")
363
+ raise
364
+
365
+ def _fix_percent_mapping(self):
366
+ """اصلاح mapping برای درصدها"""
367
+ for token, value in self.mapping_table.items():
368
+ value_str = str(value).strip()
369
+
370
+ if token.startswith('percent-'):
371
+ if not re.search(r'(درصد|%|درصدی)', value_str):
372
+ self.mapping_table[token] = f"{value_str} درصد"
373
+ logger.info(f"✅ اصلاح {token}: '{value_str}' → '{value_str} درصد'")
374
+
375
+ elif token.startswith('amount-'):
376
+ if not re.search(r'(میلیارد|میلیون|هزار|تومان|ریال|دلار|یورو|تن)', value_str):
377
+ logger.warning(f"⚠️ {token}: فقط عدد '{value_str}' - واحد مشخص نیست")
378
+
379
+ def _extract_mapping_from_text(self, original: str, anonymized: str):
380
+ """استخراج mapping از متن‌های اصلی و ناشناس شده - فقط برای موجودیت‌های انتخابی"""
381
+
382
+ # ✅ استخراج فقط توکن‌های انتخابی
383
+ all_tokens = []
384
+ for entity_type in self.entities_to_anonymize:
385
+ tokens = re.findall(f'{entity_type}-\\d+', anonymized)
386
+ all_tokens.extend([(t, entity_type) for t in tokens])
387
+
388
+ all_tokens = sorted(set(all_tokens), key=lambda x: (x[1], int(x[0].split('-')[1])))
389
+
390
+ # ✅ الگوهای موجودیت - فقط برای انتخابی‌ها
391
+ patterns = {}
392
+ if "person" in self.entities_to_anonymize:
393
+ patterns['person'] = r'\b[ء-ي]+\s+[ء-ي]+(?:\s+[ء-ي]+)*\b'
394
+ if "company" in self.entities_to_anonymize:
395
+ patterns['company'] = r'(?:شرکت|بانک|سازمان|گروه|هلدینگ)\s+[ء-ي]+(?:\s+[ء-ي]+)*'
396
+ if "amount" in self.entities_to_anonymize:
397
+ patterns['amount'] = r'\d+(?:\.\d+)?\s*(?:میلیارد|میلیون|هزار|تومان|ریال|دلار|یورو|تن)'
398
+ if "percent" in self.entities_to_anonymize:
399
+ patterns['percent'] = r'\d+(?:\.\d+)?\s*(?:درصد|%|درصدی)'
400
+
401
+ original_entities = {}
402
+ for entity_type, pattern in patterns.items():
403
+ matches = list(re.finditer(pattern, original))
404
+ original_entities[entity_type] = [m.group().strip() for m in matches]
405
+
406
+ for token, entity_type in all_tokens:
407
+ if entity_type in original_entities and original_entities[entity_type]:
408
+ token_num = int(token.split('-')[1]) - 1
409
+
410
+ if token_num < len(original_entities[entity_type]):
411
+ original_text = original_entities[entity_type][token_num]
412
+ self.mapping_table[token] = original_text
413
+ self.reverse_mapping[original_text] = token
414
+ else:
415
+ original_text = original_entities[entity_type][-1]
416
+ if token not in self.mapping_table:
417
+ self.mapping_table[token] = original_text
418
+ self.reverse_mapping[original_text] = token
419
+
420
+ def analyze_with_llm(self, anonymized_text: str, analysis_prompt: str = None) -> str:
421
+ """استفاده از LLM یکپارچه"""
422
+ logger.info(f"🤖 {self.llm_provider.upper()} اجرای پرامپت...")
423
+
424
+ if not analysis_prompt or not analysis_prompt.strip():
425
+ logger.info("⚠️ پرامپت خالی - بدون تحلیل")
426
+ return "⚠️ هیچ دستور تحلیل داده نشده است"
427
+
428
+ # ✅ بررسی اینکه آیا مدل GPT-4 است
429
+ is_gpt4 = self.llm_model and any(x in self.llm_model.lower() for x in ['gpt-4', 'gpt4'])
430
+
431
+ if is_gpt4:
432
+ # ✅ پرامپت ویژه GPT-4 با مثال‌های واقعی
433
+ logger.info("🎯 استفاده از پرامپت ویژه GPT-4")
434
+ return self._analyze_with_gpt4_prompt(anonymized_text, analysis_prompt)
435
+ else:
436
+ # پرامپت عادی برای GPT-5 و Grok
437
+ return self._analyze_with_standard_prompt(anonymized_text, analysis_prompt)
438
+
439
+ def _analyze_with_gpt4_prompt(self, anonymized_text: str, analysis_prompt: str) -> str:
440
+ """پرامپت ویژه GPT-4 با few-shot examples"""
441
+
442
+ # ✅ مثال‌های واقعی Few-Shot
443
+ few_shot_examples = """
444
+ EXAMPLE 1 - CORRECT:
445
+ Input: "company-01 فروش amount-01 داشت"
446
+ Your output should be EXACTLY: "company-01 فروش amount-01 داشت"
447
+ NOT: "company-01 فروش مبلغ amount-01 داشت"
448
+
449
+ EXAMPLE 2 - CORRECT:
450
+ Input: "amount-02 به amount-03 رسید"
451
+ Your output should be EXACTLY: "amount-02 به amount-03 رسید"
452
+ NOT: "مبلغ amount-02 به amount-03 رسید"
453
+
454
+ EXAMPLE 3 - CORRECT:
455
+ Input: "company-01 سود percent-01 داشت"
456
+ Your output should be EXACTLY: "company-01 سود percent-01 داشت"
457
+ NOT: "شرکت company-01 سود درصد percent-01 داشت"
458
+ """
459
+
460
+ # لیست توکن‌های انتخابی
461
+ tokens_list = []
462
+ if "person" in self.entities_to_anonymize:
463
+ tokens_list.append("person-XX")
464
+ if "company" in self.entities_to_anonymize:
465
+ tokens_list.append("company-XX")
466
+ if "amount" in self.entities_to_anonymize:
467
+ tokens_list.append("amount-XX")
468
+ if "percent" in self.entities_to_anonymize:
469
+ tokens_list.append("percent-XX")
470
+
471
+ tokens_str = ", ".join(tokens_list)
472
+
473
+ # ✅ پرامپت انگلیسی برای GPT-4 (بهتر کار می‌کند)
474
+ combined_text = f"""You are processing anonymized Persian/Farsi text containing placeholder tokens.
475
+
476
+ ANONYMIZED TEXT:
477
+ {anonymized_text}
478
+
479
+ USER REQUEST:
480
+ {analysis_prompt}
481
+
482
+ CRITICAL RULES:
483
+ 1. Use ONLY these exact tokens: {tokens_str}
484
+ 2. NEVER add words before/after tokens
485
+ 3. Keep the EXACT format: amount-01 (not "مبلغ amount-01" or "amount- 01")
486
+ 4. Do NOT create new tokens
487
+ 5. Preserve the exact structure
488
+
489
+ {few_shot_examples}
490
+
491
+ FORBIDDEN PATTERNS - NEVER USE:
492
+ ❌ "مبلغ amount-01" → ✅ Use: "amount-01"
493
+ ❌ "شرکت company-01" → ✅ Use: "company-01"
494
+ ❌ "فروش به amount-02" → ✅ Use: "فروش amount-02"
495
+ ❌ "درصد percent-01" → ✅ Use: "percent-01"
496
+ ❌ "amount- 01" (space) → ✅ Use: "amount-01"
497
+
498
+ Now process the text following these rules EXACTLY."""
499
+
500
+ try:
501
+ # ✅ temperature خیلی پایین برای GPT-4
502
+ logger.info(f"🌡️ Temperature: 0.05 (GPT-4 ویژه)")
503
+
504
+ response = self.llm_sender.send(
505
+ combined_text,
506
+ lang='en', # انگلیسی برای GPT-4
507
+ temperature=0.05, # خیلی خیلی پایین
508
+ max_tokens=2000
509
+ )
510
+
511
+ # ✅ دیباگ: نمایش خروجی خام LLM
512
+ logger.info("=" * 60)
513
+ logger.info("🔍 DEBUG - خروجی خام GPT-4:")
514
+ logger.info(response[:500] + "..." if len(response) > 500 else response)
515
+ logger.info("=" * 60)
516
+
517
+ # ✅ پاکسازی قوی‌تر
518
+ cleaned_response = self._clean_llm_response(response)
519
+
520
+ # ✅ دیباگ: نمایش خروجی بعد از clean
521
+ logger.info("=" * 60)
522
+ logger.info("🧹 DEBUG - خروجی بعد از clean:")
523
+ logger.info(cleaned_response[:500] + "..." if len(cleaned_response) > 500 else cleaned_response)
524
+ logger.info("=" * 60)
525
+
526
+ logger.info(f"✅ GPT-4: {len(cleaned_response)} کاراکتر")
527
+ return cleaned_response
528
+
529
+ except Exception as e:
530
+ logger.error(f"❌ GPT-4 Exception: {e}")
531
+ return f"❌ خطا در ارتباط با GPT-4: {str(e)}"
532
+
533
+ def _analyze_with_standard_prompt(self, anonymized_text: str, analysis_prompt: str) -> str:
534
+ """پرامپت استاندارد برای GPT-5 و Grok"""
535
+
536
+ tokens_instruction = []
537
+ examples = []
538
+
539
+ if "person" in self.entities_to_anonymize:
540
+ tokens_instruction.append("person-XX")
541
+ examples.append("✅ صحیح: person-01 در جلسه حضور داشت\n❌ غلط: آقای person-01")
542
+
543
+ if "company" in self.entities_to_anonymize:
544
+ tokens_instruction.append("company-XX")
545
+ examples.append("✅ صحیح: company-01 فعالیت کرد\n❌ غلط: شرکت company-01")
546
+
547
+ if "amount" in self.entities_to_anonymize:
548
+ tokens_instruction.append("amount-XX")
549
+ examples.append("✅ صحیح: فروش amount-01 بود\n❌ غلط: فروش مبلغ amount-01")
550
+
551
+ if "percent" in self.entities_to_anonymize:
552
+ tokens_instruction.append("percent-XX")
553
+ examples.append("✅ صحیح: رشد percent-01 داشت\n❌ غلط: رشد درصد percent-01")
554
+
555
+ tokens_str = ", ".join(tokens_instruction)
556
+ examples_str = "\n".join(examples)
557
+
558
+ combined_text = f"""متن ناشناس‌سازی شده:
559
+ {anonymized_text}
560
+
561
+ دستورات:
562
+ {analysis_prompt}
563
+
564
+ ⚠️ قوانین مهم:
565
+ 1. فقط از کدهای ناشناس موجود استفاده کن: {tokens_str}
566
+ 2. هیچ کلمه‌ای قبل یا بعد از این کدها اضافه نکن
567
+ 3. کد جدید ایجاد نکن
568
+ 4. ساختار دقیق متن را حفظ کن
569
+
570
+ مثال‌های صحیح و غلط:
571
+ {examples_str}"""
572
+
573
+ try:
574
+ temp_to_use = 0.2
575
+ logger.info(f"🌡️ Temperature: {temp_to_use}")
576
+
577
+ response = self.llm_sender.send(
578
+ combined_text,
579
+ lang='fa',
580
+ temperature=temp_to_use,
581
+ max_tokens=2000
582
+ )
583
+
584
+ response = self._clean_llm_response(response)
585
+
586
+ logger.info(f"✅ {self.llm_provider.upper()}: {len(response)} کاراکتر")
587
+ return response
588
+
589
+ except Exception as e:
590
+ logger.error(f"❌ {self.llm_provider.upper()} Exception: {e}")
591
+ return f"❌ خطا در ارتباط با {self.llm_provider.upper()}: {str(e)}"
592
+
593
+ def _clean_llm_response(self, text: str) -> str:
594
+ """پاکسازی کلمات اضافی که LLM ممکن است قبل از موجودیت‌ها اضافه کرده باشد"""
595
+ logger.info("🧹 پاکسازی کلمات اضافی...")
596
+
597
+ cleaned = text
598
+ changes_made = 0
599
+
600
+ # الگوهای کلمات اضافی برای هر نوع موجودیت
601
+ patterns = []
602
+
603
+ if "person" in self.entities_to_anonymize:
604
+ patterns.extend([
605
+ (r'(?:آقای|خانم|شخص|فرد)\s+(person-\d+)', r'\1'),
606
+ (r'(person-\d+)\s+(?:نامدار|محترم|عزیز)', r'\1'),
607
+ ])
608
+
609
+ if "company" in self.entities_to_anonymize:
610
+ patterns.extend([
611
+ (r'(?:شرکت|سازمان|گروه|هلدینگ|بانک|موسسه)\s+(company-\d+)', r'\1'),
612
+ (r'(company-\d+)\s+(?:محترم)', r'\1'),
613
+ ])
614
+
615
+ if "amount" in self.entities_to_anonymize:
616
+ patterns.extend([
617
+ # ✅ الگوهای کامل برای amount - تمام حالات ممکن
618
+ # حالت 1: کلمات قبل از amount
619
+ (r'(?:مبلغ|رقم|عدد|قیمت|ارزش|مقدار)\s+(amount-\d+)', r'\1'),
620
+ (r'(?:فروش|درآمد|سود|زیان|هزینه|خرج)\s+(amount-\d+)', r'\1'),
621
+ (r'(?:دارایی|بدهی|سرمایه|پول|وام)\s+(amount-\d+)', r'\1'),
622
+
623
+ # حالت 2: حروف اضافه قبل از amount
624
+ (r'\bبه\s+(amount-\d+)', r'\1'),
625
+ (r'\bبا\s+(amount-\d+)', r'\1'),
626
+ (r'\bاز\s+(amount-\d+)', r'\1'),
627
+ (r'\bتا\s+(amount-\d+)', r'\1'),
628
+ (r'\bدر\s+(amount-\d+)', r'\1'),
629
+ (r'\bبرای\s+(amount-\d+)', r'\1'),
630
+
631
+ # حالت 3: واحدها بعد از amount (اگر نباید باشند)
632
+ (r'(amount-\d+)\s+(?:ریال|تومان|دلار|یورو)', r'\1'),
633
+ (r'(amount-\d+)\s+(?:میلیون|میلیارد|هزار|تریلیون)', r'\1'),
634
+
635
+ # حالت 4: ترکیبات
636
+ (r'(?:به\s+مبلغ)\s+(amount-\d+)', r'\1'),
637
+ (r'(?:با\s+ارزش)\s+(amount-\d+)', r'\1'),
638
+ (r'(?:در\s+حد)\s+(amount-\d+)', r'\1'),
639
+
640
+ # حالت 5: فعل + amount (بدون حرف اضافه)
641
+ (r'(?:رسید|رسیده|می\u200cرسد)\s+(amount-\d+)', r'\1'),
642
+ (r'(?:شد|شده|می\u200cشود)\s+(amount-\d+)', r'\1'),
643
+ (r'(?:بود|بوده|است)\s+(amount-\d+)', r'\1'),
644
+ ])
645
+
646
+ if "percent" in self.entities_to_anonymize:
647
+ patterns.extend([
648
+ (r'(?:درصد|%)\s+(percent-\d+)', r'\1'),
649
+ (r'(percent-\d+)\s+(?:درصد|درصدی|%)', r'\1'),
650
+ ])
651
+
652
+ # اعمال الگوها
653
+ for pattern, replacement in patterns:
654
+ new_text = re.sub(pattern, replacement, cleaned)
655
+ if new_text != cleaned:
656
+ count = len(re.findall(pattern, cleaned))
657
+ changes_made += count
658
+ cleaned = new_text
659
+ logger.info(f" ✅ حذف '{pattern}': {count} مورد")
660
+
661
+ if changes_made > 0:
662
+ logger.info(f"✅ {changes_made} کلمه اضافی حذف شد")
663
+ else:
664
+ logger.info("✅ کلمه اضافی یافت نشد")
665
+
666
+ return cleaned
667
+
668
+ def restore_text(self, anonymized_text: str) -> str:
669
+ """بازگردانی متن با ترتیب بهینه برای amount"""
670
+ logger.info("🔄 بازگردانی متن...")
671
+
672
+ if not self.mapping_table:
673
+ logger.warning("⚠️ جدول نگاشت خالی است")
674
+ return anonymized_text
675
+
676
+ logger.info(f"📋 تعداد موجودیت‌ها در mapping: {len(self.mapping_table)}")
677
+
678
+ # ✅ STEP 1: normalize (hyphen یونیکد و جداسازی کلمات چسبیده)
679
+ restored = self._normalize_tokens(anonymized_text)
680
+
681
+ # ✅ STEP 2: restore قوی مخصوص amount با regex (قبل از clean!)
682
+ # این کلیدی است - باید قبل از clean انجام شود
683
+ logger.info("🔥 بازگردانی amount با regex...")
684
+ amount_restored_count = 0
685
+ for placeholder, original in self.mapping_table.items():
686
+ if placeholder.startswith("amount-"):
687
+ # استخراج شماره
688
+ num = placeholder.split("-")[1]
689
+ # الگوی regex: amount [فاصله اختیاری] - [فاصله اختیاری] شماره
690
+ pattern = rf'amount\s*-\s*{num}'
691
+ matches = re.findall(pattern, restored)
692
+ if matches:
693
+ restored = re.sub(pattern, original, restored)
694
+ amount_restored_count += 1
695
+ logger.info(f"✅ regex: {placeholder} → {original[:30]}...")
696
+
697
+ if amount_restored_count > 0:
698
+ logger.info(f"✅ {amount_restored_count} amount با regex بازگردانی شد")
699
+
700
+ # ✅ STEP 3: clean (حذف کلمات اضافی)
701
+ # حالا که amount ها restore شدن، می‌تونیم clean کنیم
702
+ restored = self._clean_for_restore(restored)
703
+
704
+ # ✅ STEP 4: replace ساده برای بقیه (person, company, percent)
705
+ replacements_count = 0
706
+ for placeholder, original in sorted(self.mapping_table.items(), key=lambda x: len(x[0]), reverse=True):
707
+ # amount ها رو قبلاً restore کردیم
708
+ if placeholder.startswith("amount-"):
709
+ continue
710
+
711
+ if placeholder in restored:
712
+ restored = restored.replace(placeholder, original)
713
+ replacements_count += 1
714
+ logger.info(f"✅ {placeholder} → {original[:30]}...")
715
+ else:
716
+ logger.warning(f"⚠️ {placeholder} در متن یافت نشد!")
717
+
718
+ total_restored = amount_restored_count + replacements_count
719
+ logger.info(f"✅ بازگردانی کامل - {total_restored}/{len(self.mapping_table)} جایگزین شد")
720
+
721
+ # ✅ STEP 5: fallback regex برای توکن‌های باقی‌مانده
722
+ if total_restored < len(self.mapping_table):
723
+ logger.info("🔍 تلاش برای یافتن توکن‌های گم‌شده با regex...")
724
+ restored = self._restore_with_regex(restored)
725
+
726
+ # هشدار در صورت شکست کامل
727
+ if total_restored == 0 and len(self.mapping_table) > 0:
728
+ logger.error("❌ هیچ توکنی جایگزین نشد! متن ورودی احتمالاً متفاوت است.")
729
+
730
+ return restored
731
+
732
+ def _clean_for_restore(self, text: str) -> str:
733
+ """پاکسازی خاص برای بازگردانی (شبیه _clean_llm_response اما سبک‌تر)"""
734
+ logger.info("🧹 پاکسازی قبل از بازگردانی...")
735
+
736
+ cleaned = text
737
+ changes_made = 0
738
+
739
+ patterns = []
740
+
741
+ if "amount" in self.entities_to_anonymize:
742
+ patterns.extend([
743
+ (r'(?:مبلغ|رقم|عدد|قیمت|ارزش|فروش|درآمد|هزینه|سود|زیان)\s+(amount-\d+)', r'\1'),
744
+ (r'\bبه\s+(amount-\d+)', r'\1'),
745
+ (r'\bبا\s+(amount-\d+)', r'\1'),
746
+ (r'\bاز\s+(amount-\d+)', r'\1'),
747
+ (r'\bتا\s+(amount-\d+)', r'\1'),
748
+ ])
749
+
750
+ for pattern, replacement in patterns:
751
+ new_text = re.sub(pattern, replacement, cleaned)
752
+ if new_text != cleaned:
753
+ changes_made += re.subn(pattern, replacement, cleaned)[1]
754
+ cleaned = new_text
755
+
756
+ if changes_made > 0:
757
+ logger.info(f"✅ {changes_made} کلمه اضافی حذف شد")
758
+
759
+ return cleaned
760
+
761
+ def _restore_with_regex(self, text: str) -> str:
762
+ """بازگردانی با استفاده از regex برای پیدا کردن توکن‌های دارای کلمات اضافی"""
763
+ restored = text
764
+
765
+ for placeholder, original in self.mapping_table.items():
766
+ # اگر قبلاً جایگزین شده، رد شو
767
+ if placeholder not in text:
768
+ # الگوی regex: کلمه اضافی (اختیاری) + توکن
769
+ # مثلاً: "فروش amount-01" یا "مبلغ amount-05"
770
+ entity_type = placeholder.split('-')[0]
771
+ entity_num = placeholder.split('-')[1]
772
+
773
+ # الگوهای مختلف
774
+ patterns = [
775
+ # کلمه فارسی + فاصله + توکن
776
+ rf'[ء-ي]+\s+({entity_type}-{entity_num})\b',
777
+ # توکن + فاصله + کلمه فارسی
778
+ rf'\b({entity_type}-{entity_num})\s+[ء-ي]+',
779
+ # فاصله اضافی داخل توکن
780
+ rf'\b{entity_type}\s+-\s+{entity_num}\b',
781
+ ]
782
+
783
+ for pattern in patterns:
784
+ matches = list(re.finditer(pattern, restored))
785
+ if matches:
786
+ logger.info(f"✅ پیدا شد با regex: {pattern}")
787
+ for match in matches:
788
+ # جایگزینی کل عبارت با فقط original
789
+ full_match = match.group(0)
790
+ # اگر توکن داخل match هست، فقط اون رو جایگزین کن
791
+ if placeholder in full_match:
792
+ restored = restored.replace(full_match, full_match.replace(placeholder, original))
793
+ else:
794
+ # اگر فرمت توکن متفاوت بود
795
+ restored = restored.replace(full_match, original)
796
+ logger.info(f"✅ regex: {placeholder} → {original[:30]}...")
797
+ break
798
+
799
+ return restored
800
+
801
+ def _normalize_tokens(self, text: str) -> str:
802
+ """نرمال‌سازی توکن‌ها - حذف فاصله‌های اضافی و hyphen یونیکد"""
803
+ logger.info("🧹 نرمال‌سازی توکن‌ها...")
804
+
805
+ normalized = text
806
+ changes = 0
807
+
808
+ # ✅ 1. نرمال‌سازی hyphen های یونیکد برای همه موجودیت‌ها
809
+ # این hyphen ها: ‐ ‑ ‒ – — − و hyphen معمولی -
810
+ unicode_hyphens = r'[\u2010\u2011\u2012\u2013\u2014\u2212\-]'
811
+
812
+ for entity_type in self.entities_to_anonymize:
813
+ # تبدیل همه hyphen ها به - معمولی
814
+ pattern = rf'{entity_type}{unicode_hyphens}(\d+)'
815
+ replacement = rf'{entity_type}-\1'
816
+ count = len(re.findall(pattern, normalized))
817
+ if count > 0:
818
+ normalized = re.sub(pattern, replacement, normalized)
819
+ changes += count
820
+ logger.info(f" ✅ {entity_type}: {count} hyphen یونیکد نرمال شد")
821
+
822
+ # ✅ 2. حذف فاضله‌های اضافی داخل توکن
823
+ for entity_type in self.entities_to_anonymize:
824
+ pattern = rf'{entity_type}\s+-\s+(\d+)'
825
+ replacement = rf'{entity_type}-\1'
826
+ count = len(re.findall(pattern, normalized))
827
+ if count > 0:
828
+ normalized = re.sub(pattern, replacement, normalized)
829
+ changes += count
830
+ logger.info(f" ✅ {entity_type}: {count} فاصله اضافی حذف شد")
831
+
832
+ # ✅ 3. جدا کردن توکن‌ها از کلمات فارسی چسبیده (ویژه amount)
833
+ # مثال: amount-01در → amount-01 در
834
+ if "amount" in self.entities_to_anonymize:
835
+ pattern = r'(amount-\d+)([ء-ي])'
836
+ replacement = r'\1 \2'
837
+ before = normalized
838
+ normalized = re.sub(pattern, replacement, normalized)
839
+ if normalized != before:
840
+ count = len(re.findall(pattern, before))
841
+ changes += count
842
+ logger.info(f" ✅ amount: {count} کلمه چسبیده جدا شد")
843
+
844
+ # ✅ 4. جدا کردن توکن‌ها از نشانه‌گذاری (ویژه amount)
845
+ # مثال: amount-01، → amount-01 ،
846
+ if "amount" in self.entities_to_anonymize:
847
+ pattern = r'(amount-\d+)([،؛:.!?])'
848
+ replacement = r'\1 \2'
849
+ before = normalized
850
+ normalized = re.sub(pattern, replacement, normalized)
851
+ if normalized != before:
852
+ count = len(re.findall(pattern, before))
853
+ changes += count
854
+ logger.info(f" ✅ amount: {count} نشانه‌گذاری جدا شد")
855
+
856
+ if changes > 0:
857
+ logger.info(f"✅ مجموع {changes} تغییر نرمال‌سازی")
858
+
859
+ return normalized
860
+
861
+ def get_mapping_table_md(self) -> str:
862
+ """تبدیل جدول نگاشت به Markdown"""
863
+ if not self.mapping_table:
864
+ return "### 📋 جدول نگاشت\n\nهیچ موجودیتی شناسایی نشد"
865
+
866
+ table = "### 📋 جدول نگاشت\n\n"
867
+ table += "| شناسه | متن اصلی |\n"
868
+ table += "|-------|----------|\n"
869
+
870
+ for token, original in sorted(self.mapping_table.items()):
871
+ table += f"| **{token}** | {original} |\n"
872
+
873
+ return table
874
+
875
+ # متغیر سراسری
876
+ anonymizer = None
877
+
878
+ def process(
879
+ input_text: str,
880
+ analysis_prompt: str,
881
+ llm_provider: str,
882
+ llm_model: str,
883
+ anonymize_all: bool,
884
+ anonymize_person: bool,
885
+ anonymize_company: bool,
886
+ anonymize_amount: bool,
887
+ anonymize_percent: bool
888
+ ):
889
+ """پردازش متن - 4 مرحله"""
890
+ global anonymizer
891
+
892
+ if not input_text.strip():
893
+ return "", "", "", ""
894
+
895
+ # ✅ ساخت لیست موجودیت‌های انتخابی
896
+ if anonymize_all:
897
+ entities = ["person", "company", "amount", "percent"]
898
+ else:
899
+ entities = []
900
+ if anonymize_person:
901
+ entities.append("person")
902
+ if anonymize_company:
903
+ entities.append("company")
904
+ if anonymize_amount:
905
+ entities.append("amount")
906
+ if anonymize_percent:
907
+ entities.append("percent")
908
+
909
+ # اگه هیچی انتخاب نشده
910
+ if not entities:
911
+ return "", "❌ لطفاً حداقل یک موجودیت برای ناشناس‌سازی انتخاب کنید", "", ""
912
+
913
+ cerebras_key = os.getenv("CEREBRAS_API_KEY")
914
+
915
+ # ایجاد یا آپدیت anonymizer
916
+ if not anonymizer:
917
+ anonymizer = AnonymizerAdvanced(
918
+ cerebras_key,
919
+ llm_provider=llm_provider,
920
+ llm_model=llm_model,
921
+ entities_to_anonymize=entities
922
+ )
923
+ else:
924
+ anonymizer.set_llm_provider(llm_provider, llm_model, entities)
925
+ anonymizer.mapping_table = {}
926
+ anonymizer.reverse_mapping = {}
927
+
928
+ try:
929
+ logger.info("=" * 70)
930
+ logger.info(f"🚀 شروع پردازش - LLM: {llm_provider} ({llm_model})")
931
+ logger.info(f"🎯 موجودیت‌های انتخابی: {', '.join(entities)}")
932
+ logger.info("=" * 70)
933
+
934
+ # مرحله 1: ناشناس‌سازی
935
+ logger.info("🔐 مرحله 1: ناشناس‌سازی...")
936
+ anonymized_text, _ = anonymizer.anonymize_with_cerebras(input_text)
937
+ logger.info(f"✅ ناشناس‌سازی: {len(anonymized_text)} کاراکتر")
938
+
939
+ # ✅ دیباگ: بررسی توکن‌های موجود در متن ناشناس
940
+ logger.info("=" * 70)
941
+ logger.info("🔍 DEBUG - توکن‌های موجود در متن ناشناس:")
942
+ for entity_type in entities:
943
+ tokens_found = re.findall(f'{entity_type}-\\d+', anonymized_text)
944
+ unique_tokens = sorted(set(tokens_found))
945
+ logger.info(f" {entity_type}: {unique_tokens}")
946
+ logger.info("=" * 70)
947
+
948
+ # مرحله 2: LLM (فقط اگر analysis_prompt داده شده باشد)
949
+ has_analysis = analysis_prompt and analysis_prompt.strip()
950
+
951
+ if has_analysis:
952
+ logger.info(f"🤖 مرحله 2: {llm_provider.upper()}...")
953
+ llm_response = anonymizer.analyze_with_llm(anonymized_text, analysis_prompt)
954
+ logger.info(f"✅ {llm_provider.upper()}: {len(llm_response)} کاراکتر")
955
+ else:
956
+ logger.info("⚠️ مرحله 2: بدون تحلیل LLM (پرامپت خالی)")
957
+ llm_response = "⚠️ هیچ دستور تحلیل داده نشده است"
958
+
959
+ # مرحله 3: بازگردانی
960
+ logger.info("🔄 مرحله 3: بازگردانی...")
961
+
962
+ # ✅ اصلاح: اگر تحلیل انجام نشده، متن ناشناس اصلی رو restore کن
963
+ if has_analysis:
964
+ # اگر LLM تحلیل کرده، خروجی LLM رو restore کن
965
+ restored_text = anonymizer.restore_text(llm_response)
966
+ else:
967
+ # اگر تحلیل نشده، متن ناشناس اصلی رو restore کن
968
+ restored_text = anonymizer.restore_text(anonymized_text)
969
+
970
+ logger.info("✅ بازگردانی کامل")
971
+
972
+ # مرحله 4: جدول نگاشت
973
+ logger.info("📋 مرحله 4: جدول نگاشت...")
974
+ mapping_str = anonymizer.get_mapping_table_md()
975
+ logger.info(f"✅ {len(anonymizer.mapping_table)} موجودیت")
976
+
977
+ logger.info("=" * 70)
978
+ logger.info("✅ تمام مراحل کامل!")
979
+ logger.info("=" * 70)
980
+
981
+ return restored_text, llm_response, anonymized_text, mapping_str
982
+
983
+ except Exception as e:
984
+ logger.error(f"❌ خطا: {str(e)}", exc_info=True)
985
+ return "", f"❌ خطا: {str(e)}", "", ""
986
+
987
+ def clear_all():
988
+ """پاک کردن همه"""
989
+ return "", "", "", "", "", "", True, False, False, False, False
990
+
991
+ # Gradio Interface
992
+ css_rtl = """
993
+ .input-box {
994
+ direction: rtl;
995
+ text-align: right;
996
+ }
997
+ .textbox textarea {
998
+ direction: rtl;
999
+ text-align: right;
1000
+ font-family: 'Tahoma', serif;
1001
+ }
1002
+ .thick-divider {
1003
+ border-top: 2px solid #333;
1004
+ margin: 10px 0;
1005
+ }
1006
+ .compact-group {
1007
+ margin: 0;
1008
+ padding: 0;
1009
+ }
1010
+ .compact-checkbox label {
1011
+ padding: 5px 10px !important;
1012
+ margin: 3px 0 !important;
1013
+ font-size: 0.95em !important;
1014
+ }
1015
+ """
1016
+
1017
+ with gr.Blocks(title="سیستم ناشناس‌سازی متون", theme=gr.themes.Soft(), css=css_rtl) as app:
1018
+
1019
+ gr.Markdown("# 🔐 پلتفرم امن چت با مدل‌های متنوع و ناشناس‌سازی داده‌ها", elem_classes="input-box")
1020
+
1021
+ # ردیف اول: تنظیمات مدل و انتخاب موجودیت‌ها
1022
+ with gr.Row():
1023
+ # سمت راست: تنظیمات مدل
1024
+ with gr.Column(scale=1):
1025
+ with gr.Group():
1026
+ gr.Markdown("### ⚙️ تنظیمات مدل", elem_classes="input-box")
1027
+
1028
+ llm_provider = gr.Dropdown(
1029
+ choices=["chatgpt", "grok"],
1030
+ value="chatgpt",
1031
+ label="🤖 انتخاب مدل زبانی",
1032
+ interactive=True
1033
+ )
1034
+
1035
+ llm_model = gr.Dropdown(
1036
+ choices=AVAILABLE_MODELS["chatgpt"],
1037
+ value="gpt-4o-mini",
1038
+ label="📦 انتخاب نسخه مدل",
1039
+ interactive=True
1040
+ )
1041
+
1042
+ # سمت چپ: انتخاب موجودیت‌ها
1043
+ with gr.Column(scale=1):
1044
+ with gr.Group():
1045
+ gr.Markdown("### 🎯 انتخاب موجودیت‌ها", elem_classes="input-box")
1046
+
1047
+ anonymize_all = gr.Checkbox(
1048
+ label="✅ همه موجودیت‌ها",
1049
+ value=True,
1050
+ elem_classes="input-box compact-checkbox"
1051
+ )
1052
+
1053
+ anonymize_person = gr.Checkbox(
1054
+ label="👤 اسامی اشخاص",
1055
+ value=False,
1056
+ elem_classes="input-box compact-checkbox"
1057
+ )
1058
+
1059
+ anonymize_company = gr.Checkbox(
1060
+ label="🏢 نام شرکت‌ها",
1061
+ value=False,
1062
+ elem_classes="input-box compact-checkbox"
1063
+ )
1064
+
1065
+ anonymize_amount = gr.Checkbox(
1066
+ label="💰 ارقام مالی",
1067
+ value=False,
1068
+ elem_classes="input-box compact-checkbox"
1069
+ )
1070
+
1071
+ anonymize_percent = gr.Checkbox(
1072
+ label="📊 درصدها",
1073
+ value=False,
1074
+ elem_classes="input-box compact-checkbox"
1075
+ )
1076
+
1077
+ # خط جداکننده پررنگ
1078
+ gr.Markdown("---", elem_classes="thick-divider")
1079
+
1080
+ # ردیف دوم: دستورات پردازش و متن ورودی
1081
+ with gr.Row():
1082
+ # سمت راست: دستورات پردازش
1083
+ with gr.Column(scale=1):
1084
+ gr.Markdown("### 📋 دستورات پردازش", elem_classes="input-box")
1085
+
1086
+ analysis_prompt = gr.Textbox(
1087
+ lines=22,
1088
+ placeholder="مثال: این متن را خلاصه کن\nیا: نکات کلیدی را استخراج کن",
1089
+ label="📋 دستورات LLM (اختیاری)",
1090
+ elem_classes="textbox"
1091
+ )
1092
+
1093
+ # سمت چپ: متن ورودی
1094
+ with gr.Column(scale=1):
1095
+ gr.Markdown("### 📝 متن ورودی", elem_classes="input-box")
1096
+
1097
+ input_text = gr.Textbox(
1098
+ lines=22,
1099
+ placeholder="متن مالی/خبری را وارد کنید...",
1100
+ label="",
1101
+ elem_classes="textbox"
1102
+ )
1103
+
1104
+ # دکمه‌های پردازش و پاک کردن
1105
+ with gr.Row():
1106
+ process_btn = gr.Button(
1107
+ "▶️ پردازش",
1108
+ variant="primary",
1109
+ size="lg",
1110
+ scale=2
1111
+ )
1112
+
1113
+ clear_btn = gr.Button(
1114
+ "🗑️ پاک کردن",
1115
+ variant="stop",
1116
+ size="lg",
1117
+ scale=1
1118
+ )
1119
+
1120
+ # نتایج
1121
+ gr.Markdown("## 📊 نتایج پردازش", elem_classes="input-box")
1122
+
1123
+ with gr.Row():
1124
+ with gr.Column(scale=1):
1125
+ restored_text = gr.Textbox(
1126
+ lines=12,
1127
+ label="✅ متن بازگردا��ی شده",
1128
+ interactive=False,
1129
+ elem_classes="textbox"
1130
+ )
1131
+
1132
+ with gr.Column(scale=1):
1133
+ llm_analysis = gr.Textbox(
1134
+ lines=12,
1135
+ label="🤖 تحلیل LLM",
1136
+ interactive=False,
1137
+ elem_classes="textbox"
1138
+ )
1139
+
1140
+ with gr.Column(scale=1):
1141
+ anonymized_text = gr.Textbox(
1142
+ lines=12,
1143
+ label="🔒 متن ناشناس‌شده",
1144
+ interactive=False,
1145
+ elem_classes="textbox"
1146
+ )
1147
+
1148
+ mapping_table = gr.Markdown(
1149
+ value="### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده",
1150
+ label="📋 جدول نگاشت",
1151
+ elem_classes="input-box"
1152
+ )
1153
+
1154
+
1155
+ # Event Handler برای تغییر provider
1156
+ def handle_provider_change(provider):
1157
+ models = AVAILABLE_MODELS.get(provider, [])
1158
+ default_model = models[0] if models else None
1159
+ return gr.update(choices=models, value=default_model)
1160
+
1161
+ llm_provider.change(
1162
+ fn=handle_provider_change,
1163
+ inputs=[llm_provider],
1164
+ outputs=[llm_model]
1165
+ )
1166
+
1167
+ def handle_select_all(select_all):
1168
+ if select_all:
1169
+ return (
1170
+ gr.update(value=False, interactive=False),
1171
+ gr.update(value=False, interactive=False),
1172
+ gr.update(value=False, interactive=False),
1173
+ gr.update(value=False, interactive=False)
1174
+ )
1175
+ else:
1176
+ return (
1177
+ gr.update(value=False, interactive=True),
1178
+ gr.update(value=False, interactive=True),
1179
+ gr.update(value=False, interactive=True),
1180
+ gr.update(value=False, interactive=True)
1181
+ )
1182
+
1183
+ anonymize_all.change(
1184
+ fn=handle_select_all,
1185
+ inputs=[anonymize_all],
1186
+ outputs=[anonymize_person, anonymize_company, anonymize_amount, anonymize_percent]
1187
+ )
1188
+
1189
+ # پردازش
1190
+ process_btn.click(
1191
+ fn=process,
1192
+ inputs=[
1193
+ input_text,
1194
+ analysis_prompt,
1195
+ llm_provider,
1196
+ llm_model,
1197
+ anonymize_all,
1198
+ anonymize_person,
1199
+ anonymize_company,
1200
+ anonymize_amount,
1201
+ anonymize_percent
1202
+ ],
1203
+ outputs=[restored_text, llm_analysis, anonymized_text, mapping_table]
1204
+ )
1205
+
1206
+ # پاک کردن
1207
+ clear_btn.click(
1208
+ fn=clear_all,
1209
+ outputs=[
1210
+ input_text,
1211
+ analysis_prompt,
1212
+ restored_text,
1213
+ llm_analysis,
1214
+ anonymized_text,
1215
+ mapping_table,
1216
+ anonymize_all,
1217
+ anonymize_person,
1218
+ anonymize_company,
1219
+ anonymize_amount,
1220
+ anonymize_percent
1221
+ ]
1222
+ )
1223
+
1224
+ if __name__ == "__main__":
1225
+ print("=" * 70)
1226
+ print("🚀 سیستم ناشناس‌سازی متون در حال راه‌اندازی...")
1227
+ print("=" * 70)
1228
+ print("\n📋 نحوه استفاده:\n")
1229
+ print("1. API Keyها را در Hugging Face Secrets تنظیم کنید:")
1230
+ print(" - CEREBRAS_API_KEY (ضروری برای ناشناس‌سازی)")
1231
+ print(" - OPENAI_API_KEY (برای ChatGPT)")
1232
+ print(" - XAI_API_KEY (برای Grok)")
1233
+ print("2. http://localhost:7860 را باز کنید")
1234
+ print("3. مدل زبانی (ChatGPT/Grok) و نسخه مدل را انتخاب کنید")
1235
+ print("4. موجودیت‌های مورد نظر برای ناشناس‌سازی را انتخاب کنید")
1236
+ print("5. متن و دستورات پردازش را وارد کنید")
1237
+ print("6. 'پردازش' را کلیک کنید\n")
1238
+ print("🔐 تمام API Keyها از Hugging Face Secrets خوانده می‌شوند")
1239
+ print("📦 مدل‌های پشتیبانی شده:")
1240
+ print(" • ChatGPT GPT-5: gpt-5.1, gpt-5")
1241
+ print(" • ChatGPT GPT-4: gpt-4.1, gpt-4o, gpt-4o-mini, gpt-4-turbo")
1242
+ print(" • Grok-4: grok-4-fast-reasoning, grok-4-fast-non-reasoning, grok-4-0709")
1243
+ print(" • Grok-3: grok-3, grok-3-mini")
1244
+ print(" • Grok-2: grok-2-vision-1212, grok-2-1212, grok-2")
1245
+ print("=" * 70 + "\n")
1246
+
1247
+ app.launch(
1248
+ server_name="0.0.0.0",
1249
+ server_port=7860,
1250
+ share=False,
1251
+ show_error=True
1252
+ )
config.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ⚙️ تنظیمات سیستم ناشناس‌سازی
3
+ Configuration settings for Persian Anonymizer
4
+ """
5
+
6
+ # ═══════════════════════════════════════════════════════════════
7
+ # Chunking Settings
8
+ # ═══════════════════════════════════════════════════════════════
9
+
10
+ # حداکثر سایز هر chunk (به tokens)
11
+ CHUNK_SIZE = 1000
12
+
13
+ # تعداد tokens همپوشانی بین chunks
14
+ CHUNK_OVERLAP = 150
15
+
16
+ # حد آستانه استفاده از chunking (به tokens)
17
+ # اگر متن بیشتر از این باشد، از chunking استفاده می‌شود
18
+ CHUNKING_THRESHOLD = 6000
19
+
20
+
21
+ # ═══════════════════════════════════════════════════════════════
22
+ # Merge Settings
23
+ # ═══════════════════════════════════════════════════════════════
24
+
25
+ # حد آستانه برای fuzzy matching (0.0 تا 1.0)
26
+ # مقدار پیش‌فرض: 0.75 (محافظه‌کارانه)
27
+ # - 0.75 = فقط موارد بسیار مشابه match می‌شوند (توصیه می‌شود)
28
+ # - 0.85 = بسیار محافظه‌کارانه (کمتر match می‌شود)
29
+ # - 0.65 = کمی ریسکی (بیشتر match می‌شود)
30
+ FUZZY_MATCH_THRESHOLD = 0.75
31
+
32
+ # فعال/غیرفعال کردن fuzzy matching
33
+ # اگر False باشد، فقط exact matching انجام می‌شود
34
+ ENABLE_FUZZY_MATCHING = True
35
+
36
+
37
+ # ═══════════════════════════════════════════════════════════════
38
+ # Logging Settings
39
+ # ═══════════════════════════════════════════════════════════════
40
+
41
+ # سطح لاگ
42
+ # مقادیر ممکن: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
43
+ LOG_LEVEL = "INFO"
44
+
45
+ # فرمت لاگ
46
+ LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
47
+
48
+ # فرمت تاریخ در لاگ
49
+ LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
50
+
51
+
52
+ # ═══════════════════════════════════════════════════════════════
53
+ # Anonymization Settings
54
+ # ═══════════════════════════════════════════════════════════════
55
+
56
+ # موجودیت‌های پیش‌فرض برای ناشناس‌سازی
57
+ DEFAULT_ENTITIES = ["person", "company", "amount", "percent"]
58
+
59
+ # استفاده از Cerebras برای ناشناس‌سازی
60
+ USE_CEREBRAS = True
61
+
62
+ # مدل Cerebras
63
+ CEREBRAS_MODEL = "llama-3.3-70b"
64
+
65
+ # حداکثر تعداد tokens برای Cerebras
66
+ CEREBRAS_MAX_TOKENS = 4096
67
+
68
+ # Temperature برای Cerebras (پایین‌تر = دقیق‌تر)
69
+ CEREBRAS_TEMPERATURE = 0.1
70
+
71
+
72
+ # ═══════════════════════════════════════════════════════════════
73
+ # LLM Settings
74
+ # ═══════════════════════════════════════════════════════════════
75
+
76
+ # Provider پیش‌فرض (chatgpt یا grok)
77
+ DEFAULT_LLM_PROVIDER = "chatgpt"
78
+
79
+ # مدل پیش‌فرض ChatGPT
80
+ DEFAULT_CHATGPT_MODEL = "gpt-4o-mini"
81
+
82
+ # مدل پیش‌فرض Grok
83
+ DEFAULT_GROK_MODEL = "grok-beta"
84
+
85
+ # حداکثر تعداد tokens برای پاسخ LLM
86
+ LLM_MAX_TOKENS = 2000
87
+
88
+ # Temperature برای تحلیل LLM
89
+ LLM_TEMPERATURE = 0.2
90
+
91
+ # تعداد تلاش مجدد در صورت خطا
92
+ LLM_RETRY_COUNT = 3
93
+
94
+ # Timeout برای درخواست‌های LLM (ثانیه)
95
+ LLM_TIMEOUT = 60
96
+
97
+
98
+ # ═══════════════════════════════════════════════════════════════
99
+ # Performance Settings
100
+ # ═══════════════════════════════════════════════════════════════
101
+
102
+ # استفاده از روش دقیق برای شمارش tokens (نیاز به tiktoken)
103
+ USE_ACCURATE_TOKEN_COUNTING = False
104
+
105
+ # حداکثر تعداد chunks مجاز
106
+ MAX_CHUNKS = 50
107
+
108
+ # هشدار اگر تعداد chunks بیش از این باشد
109
+ WARN_CHUNK_COUNT = 20
110
+
111
+
112
+ # ═════════════════════��═════════════════════════════════════════
113
+ # UI Settings (Gradio)
114
+ # ═══════════════════════════════════════════════════════════════
115
+
116
+ # پورت Gradio
117
+ GRADIO_PORT = 7860
118
+
119
+ # اجازه اشتراک‌گذاری عمومی
120
+ GRADIO_SHARE = False
121
+
122
+ # نمایش خطاها در UI
123
+ GRADIO_SHOW_ERROR = True
124
+
125
+
126
+ # ═══════════════════════════════════════════════════════════════
127
+ # Development Settings
128
+ # ═══════════════════════════════════════════════════════════════
129
+
130
+ # حالت توسعه (لاگ‌های بیشتر)
131
+ DEBUG_MODE = False
132
+
133
+ # ذخیره mapping tables برای debug
134
+ SAVE_MAPPINGS_DEBUG = False
135
+
136
+ # مسیر ذخیره mapping های debug
137
+ DEBUG_MAPPINGS_PATH = "/tmp/anonymizer_mappings"
138
+
139
+
140
+ # ═══════════════════════════════════════════════════════════════
141
+ # Helper Functions
142
+ # ═══════════════════════════════════════════════════════════════
143
+
144
+ def get_chunk_settings():
145
+ """دریافت تنظیمات chunking"""
146
+ return {
147
+ "chunk_size": CHUNK_SIZE,
148
+ "overlap": CHUNK_OVERLAP,
149
+ "threshold": CHUNKING_THRESHOLD
150
+ }
151
+
152
+
153
+ def get_merge_settings():
154
+ """دریافت تنظیمات merge"""
155
+ return {
156
+ "fuzzy_threshold": FUZZY_MATCH_THRESHOLD,
157
+ "enable_fuzzy": ENABLE_FUZZY_MATCHING
158
+ }
159
+
160
+
161
+ def get_llm_settings():
162
+ """دریافت تنظیمات LLM"""
163
+ return {
164
+ "provider": DEFAULT_LLM_PROVIDER,
165
+ "chatgpt_model": DEFAULT_CHATGPT_MODEL,
166
+ "grok_model": DEFAULT_GROK_MODEL,
167
+ "max_tokens": LLM_MAX_TOKENS,
168
+ "temperature": LLM_TEMPERATURE,
169
+ "retry_count": LLM_RETRY_COUNT,
170
+ "timeout": LLM_TIMEOUT
171
+ }
172
+
173
+
174
+ # ═══════════════════════════════════════════════════════════════
175
+ # Validation
176
+ # ═══════════════════════════════════════════════════════════════
177
+
178
+ def validate_config():
179
+ """اعتبارسنجی تنظیمات"""
180
+ errors = []
181
+
182
+ # بررسی chunking settings
183
+ if CHUNK_SIZE <= 0:
184
+ errors.append("CHUNK_SIZE باید بزرگتر از 0 باشد")
185
+
186
+ if CHUNK_OVERLAP < 0:
187
+ errors.append("CHUNK_OVERLAP نمی‌تواند منفی باشد")
188
+
189
+ if CHUNK_OVERLAP >= CHUNK_SIZE:
190
+ errors.append("CHUNK_OVERLAP باید کوچکتر از CHUNK_SIZE باشد")
191
+
192
+ # بررسی fuzzy threshold
193
+ if not 0.0 <= FUZZY_MATCH_THRESHOLD <= 1.0:
194
+ errors.append("FUZZY_MATCH_THRESHOLD باید بین 0.0 و 1.0 باشد")
195
+
196
+ # بررسی log level
197
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
198
+ if LOG_LEVEL not in valid_levels:
199
+ errors.append(f"LOG_LEVEL باید یکی از {valid_levels} باشد")
200
+
201
+ if errors:
202
+ raise ValueError("خطا در تنظیمات:\n" + "\n".join(f" - {e}" for e in errors))
203
+
204
+ return True
205
+
206
+
207
+ # اعتبارسنجی خودکار هنگام import
208
+ try:
209
+ validate_config()
210
+ except ValueError as e:
211
+ print(f"⚠️ {e}")
212
+
213
+
214
+ if __name__ == "__main__":
215
+ print("=" * 60)
216
+ print("⚙️ Configuration Settings")
217
+ print("=" * 60)
218
+
219
+ print("\n📊 Chunking:")
220
+ for k, v in get_chunk_settings().items():
221
+ print(f" {k}: {v}")
222
+
223
+ print("\n🔄 Merge:")
224
+ for k, v in get_merge_settings().items():
225
+ print(f" {k}: {v}")
226
+
227
+ print("\n🤖 LLM:")
228
+ for k, v in get_llm_settings().items():
229
+ print(f" {k}: {v}")
230
+
231
+ print("\n✅ Configuration is valid!")
232
+ print("=" * 60)
test_modules.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 🧪 تست کامل ماژول‌های ناشناس‌سازی
3
+ Complete testing suite for all modules
4
+ """
5
+
6
+ import sys
7
+ import os
8
+
9
+ # اضافه کردن مسیر پروژه به Python path
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from modules import (
13
+ count_tokens,
14
+ should_use_chunking,
15
+ TextChunker,
16
+ EntityNormalizer,
17
+ EntityMerger
18
+ )
19
+
20
+
21
+ def test_utils():
22
+ """تست توابع کمکی"""
23
+ print("=" * 60)
24
+ print("🧪 Test 1: Utils Module")
25
+ print("=" * 60)
26
+
27
+ # Token counting
28
+ text = "این یک متن تست است برای شمارش توکن‌ها."
29
+ tokens = count_tokens(text)
30
+ print(f"\n✅ Token Counting:")
31
+ print(f" Text: {text}")
32
+ print(f" Tokens: {tokens}")
33
+ assert tokens > 0, "Token count should be positive"
34
+
35
+ # Chunking decision
36
+ short_text = "متن کوتاه"
37
+ long_text = "متن بلند " * 2000
38
+
39
+ print(f"\n✅ Chunking Decision:")
40
+ short_decision = should_use_chunking(short_text, threshold=1000)
41
+ long_decision = should_use_chunking(long_text, threshold=1000)
42
+ print(f" Short text: {short_decision} (expected: False)")
43
+ print(f" Long text: {long_decision} (expected: True)")
44
+
45
+ assert short_decision == False, "Short text should not need chunking"
46
+ assert long_decision == True, "Long text should need chunking"
47
+
48
+ print("\n✅ Utils tests passed!")
49
+
50
+
51
+ def test_normalizer():
52
+ """تست نرمال‌سازی"""
53
+ print("\n" + "=" * 60)
54
+ print("🧪 Test 2: Normalizer Module")
55
+ print("=" * 60)
56
+
57
+ normalizer = EntityNormalizer()
58
+
59
+ # Person
60
+ print("\n✅ Person Normalization:")
61
+ test_cases = [
62
+ ("آقای علی احمدی", "علی احمدی"),
63
+ ("دکتر مریم کریمی", "مریم کریمی"),
64
+ ]
65
+
66
+ for original, expected in test_cases:
67
+ result = normalizer.normalize_person(original)
68
+ print(f" '{original}' → '{result}'")
69
+ assert result == expected, f"Expected '{expected}', got '{result}'"
70
+
71
+ # Company
72
+ print("\n✅ Company Normalization:")
73
+ company_result = normalizer.normalize_company("شرکت ملی نفت ایران")
74
+ print(f" 'شرکت ملی نفت ایران' → '{company_result}'")
75
+ assert "شرکت ملی نفت" in company_result
76
+
77
+ # Amount
78
+ print("\n✅ Amount Normalization:")
79
+ amount_result = normalizer.normalize_amount("۵۰ میلیارد ریال")
80
+ print(f" '۵۰ میلیارد ریال' → '{amount_result}'")
81
+ assert "50" in amount_result and "میلیارد" in amount_result
82
+
83
+ # Percent
84
+ print("\n✅ Percent Normalization:")
85
+ percent_result = normalizer.normalize_percent("۱۵٪")
86
+ print(f" '۱۵٪' → '{percent_result}'")
87
+ assert "15" in percent_result and "درصد" in percent_result
88
+
89
+ print("\n✅ Normalizer tests passed!")
90
+
91
+
92
+ def test_chunker():
93
+ """تست chunking"""
94
+ print("\n" + "=" * 60)
95
+ print("🧪 Test 3: Chunker Module")
96
+ print("=" * 60)
97
+
98
+ # متن تست
99
+ test_text = """
100
+ شرکت پارس در سال گذشته فروش 50 میلیارد ریال داشت. این رقم رشد 15 درصدی نشان می‌دهد.
101
+ علی احمدی مدیرعامل شرکت است. شرکت صبا نیز در همین بازار فعالیت می‌کند.
102
+ شرکت صبا فروش 30 میلیارد ریال داشت. مریم کریمی مدیر مالی است.
103
+ همکاری بین دو شرکت در دستور کار است. قرارداد 20 میلیارد ریالی است.
104
+ سرمایه‌گذاری 10 میلیارد ریال انجام می‌شود. بازده 25 درصد پیش‌بینی شده است.
105
+ """
106
+
107
+ chunker = TextChunker(chunk_size=100, overlap=20)
108
+ chunks = chunker.create_chunks(test_text)
109
+
110
+ print(f"\n✅ Chunking Results:")
111
+ print(f" Original text: {len(test_text)} chars")
112
+ print(f" Number of chunks: {len(chunks)}")
113
+
114
+ for chunk in chunks:
115
+ print(f"\n {chunk['chunk_id']}:")
116
+ print(f" Tokens: {chunk['tokens']}")
117
+ print(f" Length: {chunk['length']} chars")
118
+ print(f" Preview: {chunk['text'][:60]}...")
119
+
120
+ assert len(chunks) > 0, "Should create at least one chunk"
121
+ assert chunker.validate_chunks(chunks), "Chunks should be valid"
122
+
123
+ print("\n✅ Chunker tests passed!")
124
+
125
+
126
+ def test_merger():
127
+ """تست merge"""
128
+ print("\n" + "=" * 60)
129
+ print("🧪 Test 4: Merger Module")
130
+ print("=" * 60)
131
+
132
+ merger = EntityMerger(fuzzy_threshold=0.75)
133
+
134
+ # شبیه‌سازی دو mapping table
135
+ table1 = {
136
+ "chunk_id": "chunk_01",
137
+ "mapping": {
138
+ "company-01": "شرکت پارس",
139
+ "company-02": "شرکت صبا",
140
+ "person-01": "علی احمدی",
141
+ "person-02": "مریم کریمی",
142
+ "amount-01": "50 میلیارد ریال",
143
+ "amount-02": "30 میلیارد ریال",
144
+ "percent-01": "15 درصد"
145
+ }
146
+ }
147
+
148
+ table2 = {
149
+ "chunk_id": "chunk_02",
150
+ "mapping": {
151
+ "company-01": "شرکت پارس", # فاصله اضافی - باید match بشه
152
+ "company-02": "شرکت ملی نفت", # جدید
153
+ "person-01": "علی احمدی", # باید match بشه
154
+ "person-02": "رضا محمدی", # جدید
155
+ "amount-01": "100 میلیارد ریال", # جدید
156
+ "percent-01": "40 درصد" # جدید
157
+ }
158
+ }
159
+
160
+ result = merger.merge_mappings([table1, table2])
161
+
162
+ print(f"\n✅ Merge Results:")
163
+ print(f"\nGlobal Mapping ({len(result['global_mapping'])} entities):")
164
+ for placeholder, value in sorted(result['global_mapping'].items()):
165
+ print(f" {placeholder}: {value}")
166
+
167
+ print(f"\nRemapping for chunk_02:")
168
+ remap = result['remapping'][1]['mapping']
169
+ for old, new in remap.items():
170
+ print(f" {old} → {new}")
171
+
172
+ # بررسی‌ها
173
+ assert len(result['global_mapping']) > len(table1['mapping']), \
174
+ "Global mapping should have more entities"
175
+
176
+ assert len(result['remapping']) == 2, \
177
+ "Should have remapping for 2 chunks"
178
+
179
+ # بررسی exact match
180
+ assert "company-01" in remap and remap["company-01"] == "company-01", \
181
+ "شرکت پارس should match exactly"
182
+
183
+ assert "person-01" in remap and remap["person-01"] == "person-01", \
184
+ "علی احمدی should match exactly"
185
+
186
+ print("\n✅ Merger tests passed!")
187
+
188
+
189
+ def test_end_to_end():
190
+ """تست end-to-end کامل"""
191
+ print("\n" + "=" * 60)
192
+ print("🧪 Test 5: End-to-End Integration")
193
+ print("=" * 60)
194
+
195
+ # متن بلند برای تست
196
+ long_text = """
197
+ شرکت پارس در سال 1402 عملکرد بسیار خوبی داشت. فروش این شرکت به 50 میلیارد ریال رسید.
198
+ علی احمدی، مدیرعامل شرکت پارس، اعلام کرد که این رشد 15 درصدی قابل توجه است.
199
+
200
+ شرکت صبا نیز در همین صنعت فعالیت می‌کند. فروش شرکت صبا 30 میلیارد ریال بود.
201
+ مریم کریمی، مدیر مالی شرکت صبا، پیش‌بینی کرد که در سال آینده به 40 میلیارد خواهد رسید.
202
+
203
+ همکاری بین شرکت پارس و شرکت صبا در دستور کار قرار دارد. قرارداد به ارزش 20 میلیارد ریال
204
+ در حال نهایی شدن است. علی احمدی و مریم کریمی در مذاکرات شرکت دارند.
205
+ """
206
+
207
+ # 1. تصمیم‌گیری
208
+ use_chunking = should_use_chunking(long_text, threshold=50)
209
+ print(f"\n📊 Use chunking: {use_chunking}")
210
+
211
+ if use_chunking:
212
+ # 2. Chunking
213
+ chunker = TextChunker(chunk_size=100, overlap=20)
214
+ chunks = chunker.create_chunks(long_text)
215
+ print(f"✅ Created {len(chunks)} chunks")
216
+
217
+ # 3. شبیه‌سازی ناشناس‌سازی (بدون Cerebras)
218
+ # فرض: هر chunk یک mapping دارد
219
+ mapping_tables = []
220
+
221
+ for chunk in chunks:
222
+ # شبیه‌سازی mapping
223
+ mapping_tables.append({
224
+ "chunk_id": chunk['chunk_id'],
225
+ "mapping": {
226
+ "company-01": "شرکت پارس",
227
+ "person-01": "علی احمدی",
228
+ "amount-01": f"مبلغ تست {chunk['chunk_id']}"
229
+ }
230
+ })
231
+
232
+ # 4. Merge
233
+ merger = EntityMerger()
234
+ merge_result = merger.merge_mappings(mapping_tables)
235
+
236
+ print(f"✅ Merged to {len(merge_result['global_mapping'])} global entities")
237
+
238
+ # 5. بررسی consistency
239
+ # باید "شرکت پارس" و "علی احمدی" در همه chunks یکسان باشند
240
+ for i, remap_data in enumerate(merge_result['remapping'][1:], start=2):
241
+ remap = remap_data['mapping']
242
+ if "company-01" in remap:
243
+ assert remap["company-01"] == "company-01", \
244
+ f"company-01 in chunk {i} should map to global company-01"
245
+
246
+ print("✅ Consistency check passed!")
247
+
248
+ print("\n✅ End-to-end test passed!")
249
+
250
+
251
+ def run_all_tests():
252
+ """اجرای تمام تست‌ها"""
253
+ print("\n" + "🚀" * 30)
254
+ print("Starting Complete Test Suite")
255
+ print("🚀" * 30 + "\n")
256
+
257
+ try:
258
+ test_utils()
259
+ test_normalizer()
260
+ test_chunker()
261
+ test_merger()
262
+ test_end_to_end()
263
+
264
+ print("\n" + "=" * 60)
265
+ print("✅✅✅ ALL TESTS PASSED! ✅✅✅")
266
+ print("=" * 60)
267
+
268
+ return True
269
+
270
+ except AssertionError as e:
271
+ print("\n" + "=" * 60)
272
+ print(f"❌ TEST FAILED: {e}")
273
+ print("=" * 60)
274
+ return False
275
+
276
+ except Exception as e:
277
+ print("\n" + "=" * 60)
278
+ print(f"❌ ERROR: {e}")
279
+ import traceback
280
+ traceback.print_exc()
281
+ print("=" * 60)
282
+ return False
283
+
284
+
285
+ if __name__ == "__main__":
286
+ success = run_all_tests()
287
+ sys.exit(0 if success else 1)