Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import re | |
| import os | |
| import requests | |
| import json | |
| import logging | |
| from typing import Dict, List, Tuple, Optional | |
| from llm_sender_unified import create_llm_sender | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # ───────────────────────────────────────────────────────────── | |
| # مدلهای موجود — برای تحلیل LLM | |
| # ───────────────────────────────────────────────────────────── | |
| AVAILABLE_MODELS = { | |
| "chatgpt": ["gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o", "gpt-4o-mini", "gpt-4-turbo"], | |
| "grok": ["grok-4-0709", "grok-3", "grok-3-mini", "grok-2-1212"], | |
| "deepinfra": [ | |
| "Qwen/Qwen3-14B", "Qwen/Qwen3-32B", "Qwen/Qwen3-30B-A3B", | |
| "Qwen/Qwen2.5-72B-Instruct", "Qwen/Qwen2.5-14B-Instruct", | |
| ], | |
| } | |
| ANON_MODEL = "Qwen/Qwen3-14B" | |
| ANON_API_URL = "https://api.deepinfra.com/v1/openai/chat/completions" | |
| # ───────────────────────────────────────────────────────────── | |
| # SYSTEM PROMPT — برگرفته از نسخه بنچمارک ۹۰٪+ | |
| # ترکیب با قابلیت JSON output برای single call | |
| # Thinking mode فعال — همان چیزی که دقت بالا میداد | |
| # ───────────────────────────────────────────────────────────── | |
| ANON_SYSTEM_PROMPT = """شما یک «ناشناسساز متون مالی/خبری فارسی» هستید. وظیفهتان جایگزینی اسامی خاص و مقادیر عددی با شناسههای بیمعناست. | |
| قبل از دادن پاسخ نهایی، ابتدا در تگ <thinking> گامبهگام تحلیل کنید: | |
| 1. موجودیتهای موجود در متن را شناسایی کنید (شرکت، شخص، مبلغ، درصد) | |
| 2. ترتیب ظهور آنها را مشخص کنید | |
| 3. نامهای مختصر/تکرار را به همان توکن اول نسبت دهید | |
| 4. سپس JSON نهایی را بدهید | |
| ### قوانین اندیسگذاری: | |
| - شرکتها: company-01, company-02, ... (بر اساس ترتیب ظهور) | |
| - اشخاص: person-01, person-02, ... | |
| - اعداد/مبالغ: amount-01, amount-02, ... | |
| - درصدها: percent-01, percent-02, ... | |
| - هر بار که همان موجودیت تکرار میشود → همان توکن قبلی | |
| - فقط: company, person, amount, percent ❌ ممنوع: bank-01, sazman-01, group-XX | |
| ### تشخیص شرکتها: | |
| - با پیشوند: شرکت، بانک، سازمان، گروه، هلدینگ، صندوق، بیمه، پتروشیمی، ملی، سرمایهگذاری | |
| - بدون پیشوند (نامهای تجاری): ایران خودرو، سایپا، تاپیکو، پارسیان → company-XX | |
| - نام مختصر = همان توکن: «شرکت پتروشیمی بوعلی سینا» = «بوعلی» → هر دو company-01 | |
| - نام در پرانتز = همان توکن: «شرکت X (Y)» → company-01، و «Y» بعداً → company-01 | |
| - حسابرس/بازرس قانونی هم company-XX است: «وانیا نیک تدبیر» → company-XX | |
| - کلمات عمومی ناشناس نشوند: «بانکهای کشور»، «این بانک»، «12 بانک کشور»، «سه شرکت» | |
| ### قوانین amount (مبلغ + واحد = یک موجودیت): | |
| ✅ «100 میلیون دلار» → amount-01 ❌ «amount-01 دلار» | |
| ✅ «283 ریال» → amount-01 ❌ «amount-01 ریال» | |
| ✅ «41.5 همت» → amount-01 | |
| ✅ «1,429,349 میلیون ریال» → amount-01 | |
| ### قوانین percent (عدد + درصد = یک موجودیت): | |
| ✅ «80 درصد» → percent-01 ❌ «percent-01 درصد» | |
| ✅ «14%» → percent-01 | |
| ✅ «منفی 345 درصد» → percent-01 ❌ «منفی percent-01» | |
| ✅ «37 درصدی» → percent-01 | |
| ### بازهها (یک توکن برای کل بازه): | |
| ✅ «50 الی 70 درصد» → percent-01 ❌ «percent-01 الی percent-02» | |
| ✅ «40–60٪» → percent-01 ❌ «percent-01–percent-02» | |
| ✅ «12 تا 18 ماه» → amount-01 ❌ «amount-01 تا amount-02» | |
| ✅ «یک تا 1.5 میلیون تن» → amount-01 | |
| ### موارد که باید حفظ شوند (ناشناس نشوند): | |
| - تاریخ: «30 آذر 1403»، «1403/04/12»، «1404/04/29» | |
| - مکان: تهران، اصفهان، ایران، خوزستان | |
| - زمان: «راس ساعت 10:00»، «روز سه شنبه»، «مردادماه» | |
| - دوره زمانی: «۹ ماهه»، «سال مالی منتهی به»، «سهماهه نخست» | |
| - عناوین شغلی: مدیرعامل، رئیس کل، بازرس قانونی، حسابرس | |
| - کلمات عمومی: «19 بانکی»، «12 بانک کشور»، «سه شرکت»، «بانکهای مورد بررسی» | |
| - نماد بورسی → با company-XX جایگزین شود (همان شرکت مربوطه) | |
| ### فرمت خروجی نهایی (بعد از thinking): | |
| { | |
| "anonymized": "متن ناشناس شده اینجا", | |
| "mapping": {"company-01": "نام کامل", "amount-01": "عدد+واحد", ...} | |
| }""" | |
| # ───────────────────────────────────────────────────────────── | |
| # few-shot examples — از باگهای واقعی شناساییشده | |
| # ───────────────────────────────────────────────────────────── | |
| FEW_SHOT_EXAMPLES = """ | |
| === EXAMPLES === | |
| EXAMPLE 1 — نام مختصر + نام در پرانتز + تکرار: | |
| INPUT: شرکت گروه توسعه مالی مهر آیندگان (ومهان) رشد 14 درصدی داشت. سرمایهگذاریهای ومهان به 16 هزار و 495 میلیارد تومان رسید. | |
| OUTPUT json: | |
| {"anonymized": "company-01 رشد percent-01 داشت. سرمایهگذاریهای company-01 به amount-01 رسید.", "mapping": {"company-01": "شرکت گروه توسعه مالی مهر آیندگان (ومهان)", "percent-01": "14 درصد", "amount-01": "16 هزار و 495 میلیارد تومان"}} | |
| KEY: «ومهان» = company-01 (same token, NOT company-02) | |
| EXAMPLE 2 — نام کوتاه متفاوت برای شرکتهای متفاوت: | |
| INPUT: مجمع شرکت پتروشیمی بوعلی سینا برگزار شد و وانیا نیک تدبیر بازرس شد. هزینه بوعلی 100 میلیون دلار بود. تحلیل شپنا (شرکت پالایش نفت اصفهان) نشان میدهد EPS به 936 ریال برسد. | |
| OUTPUT json: | |
| {"anonymized": "مجمع company-01 برگزار شد و company-02 بازرس شد. هزینه company-01 amount-01 بود. تحلیل company-03 نشان میدهد EPS به amount-02 برسد.", "mapping": {"company-01": "شرکت پتروشیمی بوعلی سینا", "company-02": "وانیا نیک تدبیر", "amount-01": "100 میلیون دلار", "company-03": "شرکت پالایش نفت اصفهان", "amount-02": "936 ریال"}} | |
| KEY: «بوعلی» = company-01. «شپنا» = company-03 (شرکت پالایش نفت اصفهان، موجودیت جداگانه از بوعلی) | |
| EXAMPLE 3 — کلمات عمومی ناشناس نشوند + بانکهای مشخص: | |
| INPUT: دو بانک ملت و پاسارگاد سود 157 و 155 هزار میلیارد ریال داشتند. مجموع بانکهای مورد بررسی زیان 1388 هزار میلیارد ریال داشتند که 10 درصد افزایش یافت. 12 بانک کشور زیان 336 هزار میلیارد تومانی رقم زدند. | |
| OUTPUT json: | |
| {"anonymized": "دو company-01 و company-02 سود amount-01 و amount-02 داشتند. مجموع بانکهای مورد بررسی زیان amount-03 داشتند که percent-01 افزایش یافت. 12 بانک کشور زیان amount-04 رقم زدند.", "mapping": {"company-01": "بانک ملت", "company-02": "بانک پاسارگاد", "amount-01": "157 هزار میلیارد ریال", "amount-02": "155 هزار میلیارد ریال", "amount-03": "1388 هزار میلیارد ریال", "percent-01": "10 درصد", "amount-04": "336 هزار میلیارد تومانی"}} | |
| KEY: «بانکهای مورد بررسی» و «12 بانک کشور» = generic → ناشناس نشوند | |
| EXAMPLE 4 — نام چندکلمهای با مکان + بازه درصد: | |
| INPUT: شرکت فولاد مبارکه اصفهان با شرکت ملی نفت ایران قرارداد امضا کرد. شرکت فاما سرمایه را از 8,700 میلیارد ریال به 12,500 میلیارد ریال افزایش میدهد. سهم سودهای ارزی 40 الی 60 درصد است. | |
| OUTPUT json: | |
| {"anonymized": "company-01 با company-02 قرارداد امضا کرد. company-03 سرمایه را از amount-01 به amount-02 افزایش میدهد. سهم سودهای ارزی percent-01 است.", "mapping": {"company-01": "شرکت فولاد مبارکه اصفهان", "company-02": "شرکت ملی نفت ایران", "company-03": "شرکت فاما", "amount-01": "8,700 میلیارد ریال", "amount-02": "12,500 میلیارد ریال", "percent-01": "40 الی 60 درصد"}} | |
| KEY: «اصفهان» داخل company-01. «شرکت فاما» = company-03. بازه «40 الی 60 درصد» = یک توکن percent-01 | |
| EXAMPLE 5 — چند شرکت همنام + مبالغ کوچک + درصد با %: | |
| INPUT: شرکت بیمه پارسیان از شرکت سرمایه گذاری پارسیان 1,429,349 میلیون ریال سود شناسایی کرد که 89 ریال برای هر سهم است. جواد شکرخواه مدیرعامل بانک پارسیان گفت سود 41.5 همت شد و 99.99 درصد سهام در اختیار است. | |
| OUTPUT json: | |
| {"anonymized": "company-01 از company-02 amount-01 سود شناسایی کرد که amount-02 برای هر سهم است. person-01 مدیرعامل company-03 گفت سود amount-03 شد و percent-01 سهام در اختیار است.", "mapping": {"company-01": "شرکت بیمه پارسیان", "company-02": "شرکت سرمایه گذاری پارسیان", "amount-01": "1,429,349 میلیون ریال", "amount-02": "89 ریال", "person-01": "جواد شکرخواه", "company-03": "بانک پارسیان", "amount-03": "41.5 همت", "percent-01": "99.99 درصد"}} | |
| KEY: واحد داخل توکن. مبالغ کوچک ریال هم ناشناس میشوند. | |
| EXAMPLE 6 — نام مختصر + پادرو + تیپیکو + شپنا: | |
| INPUT: شرکت سرمایهگذاری دارویی تأمین (تیپیکو) درآمد 681,667 میلیارد ریال داشت. صورتهای مالی شرکت آسان پادرو 6 میلیارد تومان زیان نشان داد. پادرو 30 میلیارد تومان درآمد کسب کرد. شپنا EPS 936 ریال گزارش داد. | |
| OUTPUT json: | |
| {"anonymized": "company-01 درآمد amount-01 داشت. صورتهای مالی company-02 amount-02 زیان نشان داد. company-02 amount-03 درآمد کسب کرد. company-03 EPS amount-04 گزارش داد.", "mapping": {"company-01": "شرکت سرمایهگذاری دارویی تأمین (تیپیکو)", "amount-01": "681,667 میلیارد ریال", "company-02": "شرکت آسان پادرو", "amount-02": "6 میلیارد تومان", "amount-03": "30 میلیارد تومان", "company-03": "شرکت پالایش نفت اصفهان", "amount-04": "936 ریال"}} | |
| KEY: «تیپیکو» = company-01. «پادرو» = company-02 (همان شرکت آسان پادرو). «شپنا» = company-03 (شرکت پالایش نفت اصفهان) | |
| === END EXAMPLES === | |
| """ | |
| # ───────────────────────────────────────────────────────────── | |
| # ساخت prompt | |
| # ───────────────────────────────────────────────────────────── | |
| def build_single_call_prompt(text: str, entities: list) -> str: | |
| """ | |
| یک prompt = یک call | |
| Thinking mode فعال — برای دقت بالا (نسخه ۹۰٪+) | |
| """ | |
| active = [] | |
| if "company" in entities: active.append("company-XX (همه سازمانها)") | |
| if "person" in entities: active.append("person-XX (نام اشخاص)") | |
| if "amount" in entities: active.append("amount-XX (اعداد+واحد)") | |
| if "percent" in entities: active.append("percent-XX (درصدها)") | |
| mapping_hints = [] | |
| if "person" in entities: mapping_hints.append('"person-XX": "نام کامل"') | |
| if "company" in entities: mapping_hints.append('"company-XX": "نام کامل سازمان"') | |
| if "amount" in entities: mapping_hints.append('"amount-XX": "عدد + واحد کامل"') | |
| if "percent" in entities: mapping_hints.append('"percent-XX": "عدد + درصد/% کامل"') | |
| return f"""{FEW_SHOT_EXAMPLES} | |
| موجودیتهای فعال: {' | '.join(active)} | |
| متن زیر را ناشناس کن. ابتدا در <thinking> تحلیل کن، سپس JSON نهایی بده: | |
| فرمت خروجی نهایی (بعد از </thinking>): | |
| {{ | |
| "anonymized": "متن ناشناس شده", | |
| "mapping": {{ {", ".join(mapping_hints)} }} | |
| }} | |
| متن: | |
| {text}""" | |
| def build_analysis_prompt(anonymized_text: str, analysis_prompt: str, entities: list) -> str: | |
| tokens = [] | |
| if "person" in entities: tokens.append("person-XX") | |
| if "company" in entities: tokens.append("company-XX") | |
| if "amount" in entities: tokens.append("amount-XX") | |
| if "percent" in entities: tokens.append("percent-XX") | |
| return f"""متن ناشناسسازی شده: | |
| {anonymized_text} | |
| دستورات: | |
| {analysis_prompt} | |
| قوانین: | |
| - فقط از توکنهای موجود استفاده کن: {', '.join(tokens)} | |
| - هیچ کلمهای قبل/بعد از توکنها اضافه نکن | |
| - توکن جدید ایجاد نکن""" | |
| # ───────────────────────────────────────────────────────────── | |
| # توابع کمکی | |
| # ───────────────────────────────────────────────────────────── | |
| def strip_thinking(text: str) -> str: | |
| """ | |
| حذف بلوکهای think/thinking از خروجی | |
| thinking mode فعال است — برای دقت استفاده میشود ولی در خروجی نهایی نمیآید | |
| """ | |
| if not text: | |
| return text | |
| # تگهای Qwen3 thinking | |
| text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL) | |
| # تگهای نسخه قدیمی | |
| text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL) | |
| return text.strip() | |
| def parse_json_response(raw: str) -> dict: | |
| """parse JSON مقاوم — thinking block + markdown fence""" | |
| raw = strip_thinking(raw) | |
| raw = re.sub(r"```(?:json)?", "", raw).replace("```", "").strip() | |
| start = raw.find("{") | |
| end = raw.rfind("}") + 1 | |
| if start == -1 or end == 0: | |
| raise ValueError("JSON یافت نشد") | |
| return json.loads(raw[start:end]) | |
| def post_deepinfra(prompt: str, system: str, max_tokens: int = 6000) -> str: | |
| """ | |
| DeepInfra Qwen3-14B | |
| Thinking mode فعال — برای دقت بالا | |
| max_tokens بالاتر برای فضای thinking | |
| """ | |
| api_key = os.getenv("DEEPINFRA_API_KEY") | |
| if not api_key: | |
| raise ValueError("DEEPINFRA_API_KEY موجود نیست") | |
| resp = requests.post( | |
| ANON_API_URL, | |
| headers={ | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json" | |
| }, | |
| json={ | |
| "model": ANON_MODEL, | |
| "messages": [ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": prompt} | |
| ], | |
| "max_tokens": max_tokens, | |
| "temperature": 0.3, # همان مقدار نسخه ۹۰٪+ | |
| "top_p": 0.9, | |
| # thinking mode فعال — chat_template_kwargs را حذف کردیم | |
| }, | |
| timeout=120 # بیشتر برای thinking | |
| ) | |
| if resp.status_code != 200: | |
| raise Exception(f"DeepInfra {resp.status_code}: {resp.text[:300]}") | |
| content = resp.json()["choices"][0]["message"]["content"] | |
| # لاگ thinking برای debug | |
| if "<think>" in content or "<thinking>" in content: | |
| thinking = re.search(r"<think(?:ing)?>(.*?)</think(?:ing)?>", content, re.DOTALL) | |
| if thinking: | |
| logger.info(f"🧠 Thinking ({len(thinking.group(1))} chars)...") | |
| return strip_thinking(content) | |
| # ───────────────────────────────────────────────────────────── | |
| # کلاس اصلی | |
| # ───────────────────────────────────────────────────────────── | |
| class AnonymizerAdvanced: | |
| def __init__( | |
| self, | |
| llm_provider: str = "chatgpt", | |
| llm_model: str = None, | |
| entities_to_anonymize: List[str] = None | |
| ): | |
| self.llm_provider = llm_provider | |
| self.llm_model = llm_model | |
| self.entities_to_anonymize = entities_to_anonymize or ["person", "company", "amount", "percent"] | |
| self.mapping_table: Dict[str, str] = {} | |
| self.reverse_mapping: Dict[str, str] = {} | |
| self._create_llm_sender() | |
| logger.info(f"✅ Anonymizer — {llm_provider}") | |
| # ── LLM sender (تحلیل) ────────────────────────────────── | |
| def _create_llm_sender(self): | |
| try: | |
| key_map = { | |
| "chatgpt": os.getenv("OPENAI_API_KEY"), | |
| "grok": os.getenv("XAI_API_KEY"), | |
| "deepinfra": os.getenv("DEEPINFRA_API_KEY"), | |
| } | |
| self.llm_sender = create_llm_sender( | |
| provider=self.llm_provider, | |
| api_key=key_map.get(self.llm_provider), | |
| model=self.llm_model | |
| ) | |
| logger.info(f"✅ LLM Sender: {self.llm_provider} — {self.llm_sender.model}") | |
| except Exception as e: | |
| logger.error(f"❌ LLM Sender خطا: {e}") | |
| self.llm_sender = create_llm_sender("chatgpt") | |
| def set_llm_provider(self, provider: str, model: str = None, entities: List[str] = None): | |
| self.llm_provider = provider | |
| self.llm_model = model | |
| if entities is not None: | |
| self.entities_to_anonymize = entities | |
| self._create_llm_sender() | |
| # ── ناشناسسازی — thinking فعال، یک call ────────────── | |
| def anonymize(self, text: str) -> Tuple[str, Dict]: | |
| """ | |
| Qwen3-14B با thinking mode فعال | |
| همان ترکیبی که بنچمارک ۹۰٪+ داد | |
| یک API call → anonymized + mapping در JSON | |
| """ | |
| logger.info("🧠 Qwen3-14B (thinking ON | single-call)...") | |
| if not self.entities_to_anonymize: | |
| return text, {} | |
| prompt = build_single_call_prompt(text, self.entities_to_anonymize) | |
| try: | |
| raw = post_deepinfra(prompt, ANON_SYSTEM_PROMPT, max_tokens=6000) | |
| logger.info(f"✅ پاسخ: {len(raw)} کاراکتر") | |
| result = parse_json_response(raw) | |
| anonymized_text = result.get("anonymized", "") | |
| self.mapping_table = result.get("mapping", {}) | |
| self._clean_orphan_tokens(anonymized_text) | |
| self._fix_mapping() | |
| self.reverse_mapping = {v: k for k, v in self.mapping_table.items()} | |
| for etype in self.entities_to_anonymize: | |
| found = sorted(set(re.findall(rf'{etype}-\d+', anonymized_text))) | |
| if found: | |
| logger.info(f" {etype}: {found}") | |
| logger.info(f"✅ mapping: {len(self.mapping_table)} موجودیت") | |
| return anonymized_text, self.mapping_table | |
| except json.JSONDecodeError as e: | |
| logger.warning(f"⚠️ JSON خطا: {e} — fallback") | |
| return self._anonymize_fallback(text) | |
| except Exception as e: | |
| logger.error(f"❌ Exception: {e}") | |
| raise | |
| def _anonymize_fallback(self, text: str) -> Tuple[str, Dict]: | |
| """Fallback: دو call — اگر JSON parse شکست خورد""" | |
| logger.info("🔄 fallback: دو call...") | |
| rules_fa = ( | |
| "متن زیر را ناشناس کن.\n" | |
| "- company-XX: نام کامل سازمان (بانک/شرکت/بیمه/پتروشیمی/...) — نام مختصر = همان توکن\n" | |
| "- person-XX: نام کامل اشخاص\n" | |
| "- amount-XX: عدد + واحد با هم\n" | |
| "- percent-XX: عدد + درصد با هم (بازه هم یک توکن)\n" | |
| "کلمات عمومی را دست نزن. فقط متن ناشناس شده." | |
| ) | |
| prompt1 = f"{FEW_SHOT_EXAMPLES}\n{rules_fa}\n\nمتن:\n{text}" | |
| anonymized_text = post_deepinfra(prompt1, ANON_SYSTEM_PROMPT, max_tokens=4096) | |
| hints = [] | |
| if "person" in self.entities_to_anonymize: hints.append('"person-XX": "نام کامل"') | |
| if "company" in self.entities_to_anonymize: hints.append('"company-XX": "نام کامل سازمان"') | |
| if "amount" in self.entities_to_anonymize: hints.append('"amount-XX": "عدد+واحد"') | |
| if "percent" in self.entities_to_anonymize: hints.append('"percent-XX": "عدد+درصد"') | |
| prompt2 = ( | |
| f"متن اصلی: {text}\n" | |
| f"متن ناشناس: {anonymized_text}\n\n" | |
| f"فقط JSON mapping:\n{{ {', '.join(hints)} }}" | |
| ) | |
| try: | |
| raw2 = post_deepinfra(prompt2, "Output ONLY valid JSON. No explanation.", max_tokens=2048) | |
| self.mapping_table = parse_json_response(raw2) | |
| except Exception: | |
| self._extract_mapping_fallback(text, anonymized_text) | |
| self._clean_orphan_tokens(anonymized_text) | |
| self._fix_mapping() | |
| self.reverse_mapping = {v: k for k, v in self.mapping_table.items()} | |
| return anonymized_text, self.mapping_table | |
| # ── پاکسازی mapping ──────────────────────────────────── | |
| def _clean_orphan_tokens(self, anonymized_text: str): | |
| to_remove = [t for t in self.mapping_table if t not in anonymized_text] | |
| for t in to_remove: | |
| logger.info(f" 🗑️ توکن اضافی: {t}") | |
| del self.mapping_table[t] | |
| def _fix_mapping(self): | |
| """اطمینان از صحت مقادیر — فقط percent بدون واحد""" | |
| for token, value in list(self.mapping_table.items()): | |
| val = str(value).strip() | |
| if token.startswith("percent-") and not re.search(r"(درصد|%|٪|درصدی)", val): | |
| self.mapping_table[token] = f"{val} درصد" | |
| logger.info(f" اصلاح {token}: '{val}' → '{val} درصد'") | |
| # ── fallback mapping با regex ──────────────────────────── | |
| def _extract_mapping_fallback(self, original: str, anonymized: str): | |
| pats: Dict[str, str] = {} | |
| if "person" in self.entities_to_anonymize: | |
| pats["person"] = r'(?<![ء-یa-zA-Z])[ء-ی]+\s+[ء-ی]+(?:\s+[ء-ی]+)*(?![ء-یa-zA-Z])' | |
| if "company" in self.entities_to_anonymize: | |
| pats["company"] = ( | |
| r'(?:(?:شرکت|بانک|سازمان|گروه|هلدینگ|صندوق|بیمه|پتروشیمی|ملی|سرمایه\s*گذاری)\s+)' | |
| r'[ء-ی][ء-ی\s]+(?:\([ء-یa-zA-Z۰-۹]+\))?' | |
| ) | |
| if "amount" in self.entities_to_anonymize: | |
| pats["amount"] = r'[\d۰-۹][,،\d۰-۹]*(?:\.\d+)?\s*(?:هزار\s+و\s+\d+|هزار|میلیون|میلیارد|همت)?\s*(?:میلیارد|میلیون|هزار|تومان|ریال|دلار|دستگاه|تن)?' | |
| if "percent" in self.entities_to_anonymize: | |
| pats["percent"] = r'[\d۰-۹]+(?:\.\d+)?\s*(?:الی|تا)?\s*(?:[\d۰-۹]+(?:\.\d+)?\s*)?(?:درصد|%|٪|درصدی)' | |
| orig_entities = { | |
| etype: [m.strip() for m in re.findall(pat, original) if m.strip()] | |
| for etype, pat in pats.items() | |
| } | |
| for etype in self.entities_to_anonymize: | |
| tokens = sorted(set(re.findall(rf'{etype}-(\d+)', anonymized)), key=int) | |
| values = orig_entities.get(etype, []) | |
| for tok_num in tokens: | |
| token = f"{etype}-{tok_num}" | |
| idx = int(tok_num) - 1 | |
| self.mapping_table[token] = values[idx] if idx < len(values) else (values[-1] if values else token) | |
| self.reverse_mapping = {v: k for k, v in self.mapping_table.items()} | |
| logger.info(f"✅ Fallback mapping: {len(self.mapping_table)} موجودیت") | |
| # ── تحلیل LLM ─────────────────────────────────────────── | |
| def analyze_with_llm(self, anonymized_text: str, analysis_prompt: str = None) -> str: | |
| logger.info(f"🤖 {self.llm_provider.upper()} تحلیل...") | |
| if not analysis_prompt or not analysis_prompt.strip(): | |
| return "⚠️ هیچ دستور تحلیل داده نشده است" | |
| prompt = build_analysis_prompt(anonymized_text, analysis_prompt, self.entities_to_anonymize) | |
| try: | |
| response = self.llm_sender.send(prompt, lang="fa", temperature=0.2, max_tokens=2000) | |
| logger.info(f"✅ {self.llm_provider.upper()}: {len(response)} کاراکتر") | |
| return response | |
| except Exception as e: | |
| logger.error(f"❌ {self.llm_provider.upper()} Exception: {e}") | |
| return f"❌ خطا: {str(e)}" | |
| # ── بازگردانی ──────────────────────────────────────────── | |
| def restore_text(self, anonymized_text: str) -> str: | |
| logger.info("🔄 بازگردانی...") | |
| if not self.mapping_table: | |
| return anonymized_text | |
| restored = self._normalize_tokens(anonymized_text) | |
| count = 0 | |
| for placeholder, original in sorted( | |
| self.mapping_table.items(), key=lambda x: len(x[0]), reverse=True | |
| ): | |
| if placeholder in restored: | |
| restored = restored.replace(placeholder, original) | |
| count += 1 | |
| logger.info(f" ✅ {placeholder} → {original[:40]}") | |
| else: | |
| logger.warning(f" ⚠️ {placeholder} یافت نشد") | |
| logger.info(f"✅ {count}/{len(self.mapping_table)} بازگردانی شد") | |
| if count < len(self.mapping_table): | |
| restored = self._restore_with_regex(restored) | |
| return restored | |
| def _normalize_tokens(self, text: str) -> str: | |
| normalized = text | |
| unicode_hyphens = r'[\u2010\u2011\u2012\u2013\u2014\u2212]' | |
| for etype in self.entities_to_anonymize: | |
| normalized = re.sub(rf'{etype}{unicode_hyphens}(\d+)', rf'{etype}-\1', normalized) | |
| normalized = re.sub(rf'{etype}\s+-\s+(\d+)', rf'{etype}-\1', normalized) | |
| normalized = re.sub(rf'({etype}-\d+)([ء-ی])', r'\1 \2', normalized) | |
| normalized = re.sub(rf'({etype}-\d+)([،؛:.!?])', r'\1 \2', normalized) | |
| return normalized | |
| def _restore_with_regex(self, text: str) -> str: | |
| restored = text | |
| for placeholder, original in self.mapping_table.items(): | |
| if placeholder not in restored: | |
| continue | |
| etype, num = placeholder.split("-") | |
| if re.search(rf'{etype}\s*-\s*{num}', restored): | |
| restored = re.sub(rf'{etype}\s*-\s*{num}', original, restored) | |
| logger.info(f" ✅ regex: {placeholder} → {original[:40]}") | |
| return restored | |
| def get_mapping_table_md(self) -> str: | |
| if not self.mapping_table: | |
| return "### 📋 جدول نگاشت\n\nهیچ موجودیتی شناسایی نشد" | |
| table = "### 📋 جدول نگاشت\n\n| شناسه | متن اصلی |\n|-------|----------|\n" | |
| for token, original in sorted(self.mapping_table.items()): | |
| table += f"| **{token}** | {original} |\n" | |
| return table | |
| # ───────────────────────────────────────────────────────────── | |
| # متغیر سراسری | |
| # ───────────────────────────────────────────────────────────── | |
| anonymizer: Optional[AnonymizerAdvanced] = None | |
| # ───────────────────────────────────────────────────────────── | |
| # تابع اصلی | |
| # ───────────────────────────────────────────────────────────── | |
| def process( | |
| input_text: str, | |
| analysis_prompt: str, | |
| llm_provider: str, | |
| llm_model: str, | |
| anonymize_all: bool, | |
| anonymize_person: bool, | |
| anonymize_company: bool, | |
| anonymize_amount: bool, | |
| anonymize_percent: bool | |
| ): | |
| global anonymizer | |
| if not input_text.strip(): | |
| return "", "", "", "" | |
| entities = ["person", "company", "amount", "percent"] if anonymize_all else [ | |
| e for e, flag in [ | |
| ("person", anonymize_person), | |
| ("company", anonymize_company), | |
| ("amount", anonymize_amount), | |
| ("percent", anonymize_percent), | |
| ] if flag | |
| ] | |
| if not entities: | |
| return "", "❌ لطفاً حداقل یک موجودیت انتخاب کنید", "", "" | |
| if not anonymizer: | |
| anonymizer = AnonymizerAdvanced( | |
| llm_provider=llm_provider, | |
| llm_model=llm_model, | |
| entities_to_anonymize=entities | |
| ) | |
| else: | |
| anonymizer.set_llm_provider(llm_provider, llm_model, entities) | |
| anonymizer.mapping_table = {} | |
| anonymizer.reverse_mapping = {} | |
| try: | |
| logger.info("=" * 60) | |
| logger.info(f"🧠 Qwen3-14B (thinking ON | single-call)") | |
| logger.info(f"🤖 تحلیل: {llm_provider} ({llm_model})") | |
| logger.info(f"🎯 موجودیتها: {entities}") | |
| logger.info("=" * 60) | |
| anon_text, _ = anonymizer.anonymize(input_text) | |
| has_analysis = bool(analysis_prompt and analysis_prompt.strip()) | |
| llm_response = anonymizer.analyze_with_llm(anon_text, analysis_prompt) if has_analysis \ | |
| else "⚠️ هیچ دستور تحلیل داده نشده است" | |
| source = llm_response if has_analysis else anon_text | |
| restored = anonymizer.restore_text(source) | |
| return restored, llm_response, anon_text, anonymizer.get_mapping_table_md() | |
| except Exception as e: | |
| logger.error(f"❌ خطا: {e}", exc_info=True) | |
| return "", f"❌ خطا: {str(e)}", "", "" | |
| def clear_all(): | |
| return "", "", "", "", "", "", True, False, False, False, False | |
| # ───────────────────────────────────────────────────────────── | |
| # رابط کاربری Gradio | |
| # ───────────────────────────────────────────────────────────── | |
| css_rtl = """ | |
| .textbox textarea { direction: rtl; text-align: right; font-family: 'Tahoma', serif; } | |
| .input-box { direction: rtl; text-align: right; } | |
| .compact-checkbox label { padding: 5px 10px !important; font-size: 0.95em !important; } | |
| """ | |
| with gr.Blocks(title="سیستم ناشناسسازی متون", theme=gr.themes.Soft(), css=css_rtl) as app: | |
| gr.Markdown( | |
| "# 🔐 پلتفرم ناشناسسازی متون فارسی\n" | |
| "> 🧠 **Qwen3-14B** با thinking mode — دقت بالا (بنچمارک ۹۰٪+)", | |
| elem_classes="input-box" | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| with gr.Group(): | |
| gr.Markdown("### ⚙️ مدل تحلیل", elem_classes="input-box") | |
| llm_provider = gr.Dropdown( | |
| choices=["chatgpt", "grok", "deepinfra"], | |
| value="chatgpt", label="🤖 مدل زبانی تحلیل", interactive=True | |
| ) | |
| llm_model = gr.Dropdown( | |
| choices=AVAILABLE_MODELS["chatgpt"], | |
| value="gpt-4o-mini", label="📦 نسخه مدل", interactive=True | |
| ) | |
| with gr.Column(scale=1): | |
| with gr.Group(): | |
| gr.Markdown("### 🎯 موجودیتهای ناشناسسازی", elem_classes="input-box") | |
| anonymize_all = gr.Checkbox(label="✅ همه", value=True, elem_classes="compact-checkbox") | |
| anonymize_person = gr.Checkbox(label="👤 اشخاص", value=False, elem_classes="compact-checkbox") | |
| anonymize_company = gr.Checkbox(label="🏢 سازمانها", value=False, elem_classes="compact-checkbox") | |
| anonymize_amount = gr.Checkbox(label="💰 ارقام مالی", value=False, elem_classes="compact-checkbox") | |
| anonymize_percent = gr.Checkbox(label="📊 درصدها", value=False, elem_classes="compact-checkbox") | |
| gr.Markdown("---") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 📋 دستورات تحلیل (اختیاری)", elem_classes="input-box") | |
| analysis_prompt = gr.Textbox( | |
| lines=20, placeholder="مثال: این متن را خلاصه کن", | |
| label="", elem_classes="textbox" | |
| ) | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 📝 متن ورودی", elem_classes="input-box") | |
| input_text = gr.Textbox( | |
| lines=20, placeholder="متن فارسی را وارد کنید...", | |
| label="", elem_classes="textbox" | |
| ) | |
| with gr.Row(): | |
| process_btn = gr.Button("▶️ پردازش", variant="primary", size="lg", scale=2) | |
| clear_btn = gr.Button("🗑️ پاک کردن", variant="stop", size="lg", scale=1) | |
| gr.Markdown("## 📊 نتایج", elem_classes="input-box") | |
| with gr.Row(): | |
| restored_text = gr.Textbox(lines=12, label="✅ متن بازگردانی شده", interactive=False, elem_classes="textbox") | |
| llm_analysis = gr.Textbox(lines=12, label="🤖 تحلیل LLM", interactive=False, elem_classes="textbox") | |
| anonymized_output = gr.Textbox(lines=12, label="🔒 متن ناشناسشده", interactive=False, elem_classes="textbox") | |
| mapping_table = gr.Markdown("### 📋 جدول نگاشت\n\nهنوز پردازشی انجام نشده", elem_classes="input-box") | |
| def handle_provider_change(provider): | |
| models = AVAILABLE_MODELS.get(provider, []) | |
| return gr.update(choices=models, value=models[0] if models else None) | |
| llm_provider.change(fn=handle_provider_change, inputs=[llm_provider], outputs=[llm_model]) | |
| def handle_select_all(select_all): | |
| s = gr.update(value=False, interactive=not select_all) | |
| return s, s, s, s | |
| anonymize_all.change( | |
| fn=handle_select_all, inputs=[anonymize_all], | |
| outputs=[anonymize_person, anonymize_company, anonymize_amount, anonymize_percent] | |
| ) | |
| process_btn.click( | |
| fn=process, | |
| inputs=[ | |
| input_text, analysis_prompt, llm_provider, llm_model, | |
| anonymize_all, anonymize_person, anonymize_company, anonymize_amount, anonymize_percent | |
| ], | |
| outputs=[restored_text, llm_analysis, anonymized_output, mapping_table] | |
| ) | |
| clear_btn.click( | |
| fn=clear_all, | |
| outputs=[ | |
| input_text, analysis_prompt, restored_text, llm_analysis, | |
| anonymized_output, mapping_table, | |
| anonymize_all, anonymize_person, anonymize_company, anonymize_amount, anonymize_percent | |
| ] | |
| ) | |
| if __name__ == "__main__": | |
| print("=" * 60) | |
| print("🧠 Qwen3-14B | thinking ON | single-call | بنچمارک ۹۰٪+") | |
| print("=" * 60) | |
| app.launch(server_name="0.0.0.0", server_port=7860, share=False, show_error=True) | |