# ai_processor.py - অ্যাডভান্সড রুল-বেসড ইঞ্জিন (v2.1) # আপনার দেয়া প্রোডাকশন-গ্রেড অ্যানালাইসিস এবং অপটিমাইজেশনের ওপর ভিত্তি করে আপডেট করা হয়েছে # ১০০% এআই-মুক্ত, অত্যন্ত দ্রুত এবং নির্ভুল import re # ===================================================================== # হেল্পার ফাংশন # ===================================================================== def _clean_markdown(text): """মার্কডাউন সিম্বলগুলো রিমুভ করে পরিষ্কার টেক্সট দেয়।""" if not text: return "" text = re.sub(r'[*_]+', '', text) text = re.sub(r'^#+\s*|^>\s*|^\-\s*', '', text, flags=re.MULTILINE) return text.strip() def _get_body_font_size(blocks): """ ডকুমেন্টের মূল বডি টেক্সটের ফন্ট সাইজ বের করে (Frequency/Word Count ভিত্তিক)। মিডিয়ানের বদলে সবচেয়ে বেশি ব্যবহৃত ফন্ট সাইজটিকে বডি হিসেবে ধরে। """ size_char_counts = {} for block in blocks: if block.get('type') not in ['image', 'table'] and block.get('font_size'): size = round(block['font_size'], 0) word_count = len(_clean_markdown(block.get('content', '')).split()) # বড় ব্লকগুলোকে বেশি ওয়েট (Weight) দেওয়া হচ্ছে size_char_counts[size] = size_char_counts.get(size, 0) + word_count if not size_char_counts: return 12.0 # যে ফন্ট সাইজে সবচেয়ে বেশি শব্দ আছে সেটিই বডি ফন্ট return max(size_char_counts.keys(), key=lambda s: size_char_counts[s]) # ===================================================================== # স্টেপ ১: অ্যাডভান্সড মার্জিং (While Loop & Soft Signals) # ===================================================================== def _should_merge(current, nxt): """দুটি ব্লক মার্জ করা উচিত কিনা তা যাচাই করে।""" # রুল: হেডিং ব্লকগুলোকে নন-হেডিং ব্লকের সাথে জোড়া লাগতে দেওয়া হবে না c_type = current.get('type', 'paragraph') n_type = nxt.get('type', 'paragraph') heading_types = {'heading1', 'heading2', 'heading3'} if (c_type in heading_types and n_type not in heading_types) or \ (n_type in heading_types and c_type not in heading_types): return False if c_type in ['image', 'table'] or n_type in ['image', 'table']: return False c_text = current.get('content', '').strip() n_text = nxt.get('content', '').strip() c_clean = _clean_markdown(c_text) n_clean = _clean_markdown(n_text) if not c_clean or not n_clean: return False # রুল: স্ট্যান্ডঅ্যালোন হেডারগুলো প্রটেক্ট করা standalone = ["INTRODUCTION", "FOREWORD", "PREFACE", "CONCLUSION", "CONTENTS", "TABLE OF CONTENTS", "GLOSSARY", "APPENDIX"] if c_clean.upper() in standalone or n_clean.upper() in standalone: return False # টাইপোগ্রাফি চেক c_size = current.get('font_size', 12) n_size = nxt.get('font_size', 12) if abs(c_size - n_size) > 1.5: return False same_formatting = current.get('is_bold') == nxt.get('is_bold') if not same_formatting: return False word_count_current = len(c_clean.split()) word_count_next = len(n_clean.split()) # রিলাক্সড টাইপ চেক: ব্লক ছোট হলে টাইপ মিসম্যাচ ইগনোর করা হবে same_type = c_type == n_type if not same_type: if word_count_current < 10 and word_count_next < 10: same_type = True if not same_type: return False # সফট সিগন্যালস (Soft Signals) ends_with_punct = bool(re.search(r'[.!?;:]\s*["\u0027\u2018\u2019\u201C\u201D]?$', c_clean)) starts_with_lower = n_clean[0].islower() # "it", "is", "was", "are", "were" বাদ দেওয়া হয়েছে কারণ এগুলো বাক্যের শেষে থাকতে পারে prep_regex = r'\b(the|a|an|of|in|to|for|and|or|but|with|from|by|at|on)\s*$' # যেকোনো একটি স্ট্রং সিগন্যাল পেলেই মার্জ করবে strong_merge = ( (not ends_with_punct and starts_with_lower) or (c_clean.isupper() and n_clean.isupper() and len(c_clean) < 80 and len(n_clean) < 80) or (c_clean and c_clean[-1] in ',;-') or bool(re.search(prep_regex, c_clean, re.IGNORECASE)) ) return strong_merge def _advanced_merge(blocks): """While লুপ ব্যবহার করে একাধিক (৩ বা ততোধিক) ভাঙা ফ্র্যাগমেন্ট জোড়া লাগায়।""" merged_blocks = [] i = 0 while i < len(blocks): current = dict(blocks[i]) # যতক্ষণ পর্যন্ত পরের ব্লকটি মার্জ করার যোগ্য, লুপ চলতে থাকবে while i < len(blocks) - 1: nxt = blocks[i + 1] if _should_merge(current, nxt): c_text = current.get('content', '').strip() n_text = nxt.get('content', '').strip() c_clean = _clean_markdown(c_text) n_clean = _clean_markdown(n_text) prefix = "" if c_text.startswith('### '): prefix = "### " elif c_text.startswith('## '): prefix = "## " elif c_text.startswith('# '): prefix = "# " current['content'] = f"{prefix}{c_clean} {n_clean}".strip() print(f" 🔗 ফ্র্যাগমেন্ট মার্জ করা হয়েছে: \"{c_clean[-20:]} {n_clean[:20]}\"") i += 1 else: break merged_blocks.append(current) i += 1 return merged_blocks # ===================================================================== # স্টেপ ২: ক্লাস্টার ভিত্তিক TOC ডিটেকশন (অপটিমাইজড) # ===================================================================== def _detect_toc_region(blocks): """পরপর অনেকগুলো ছোট চ্যাপ্টার-লাইক এন্ট্রি দেখে TOC ক্লাস্টার বের করে। (While loop দিয়ে জাম্প করে)""" toc_indices = set() i = 0 while i < len(blocks) - 2: streak = 0 temp_indices = [] for j in range(i, min(i + 30, len(blocks))): clean = _clean_markdown(blocks[j].get('content', '')).strip() word_count = len(clean.split()) if word_count > 20: break # বড় প্যারাগ্রাফ পেলে ক্লাস্টার ভেঙে যাবে is_chapter_like = bool(re.match( r'^(chapter|part|section|appendix|introduction|conclusion|glossary|index|preface|foreword|contents)', clean, re.IGNORECASE )) is_numbered = bool(re.match(r'^\d+[\.\)]\s', clean)) if is_chapter_like or is_numbered or bool(re.search(r'(\.{3,}|…)\s*\d+$', clean)): streak += 1 temp_indices.append(j) elif word_count < 5: temp_indices.append(j) # পেজ নাম্বার বা ছোট গ্যাপ হতে পারে, স্কিপ করে স্ট্রিক বজায় রাখবে continue else: break # ৩ বা তার বেশি এন্ট্রি পেলে সেটি একটি TOC রিজিয়ন if streak >= 3: toc_indices.update(temp_indices) i = temp_indices[-1] + 1 # বারবার একই ইনডেক্স চেক না করে জাম্প করবে (লুপ এফিশিয়েন্সি) else: i += 1 return toc_indices # ===================================================================== # স্টেপ ৩: সেকশন স্কোরিং এবং ফিল্টারিং # ===================================================================== def _apply_section_scoring(blocks): """টেক্সটের ঘনত্ব, ফন্ট সাইজ এবং ক্লাস্টার ব্যবহার করে সেকশন চিহ্নিত করে।""" body_size = _get_body_font_size(blocks) section_counter = 1 toc_indices = _detect_toc_region(blocks) for i, block in enumerate(blocks): if block.get('type') in ['image', 'table']: block['is_section_start'] = False continue if i == 0: block['is_section_start'] = True text = block.get('content', '').strip() clean_text = _clean_markdown(text) title = clean_text[:40].strip() + ("..." if len(clean_text) > 40 else "") if not title: title = "Section 1" block['section_name'] = title continue text = block.get('content', '').strip() clean_text = _clean_markdown(text) word_count = len(clean_text.split()) if word_count == 0: block['is_section_start'] = False continue # --- উন্নত পেজ নাম্বার ফিল্টারিং --- # "316", "- 316 -", "Page 316" ফরম্যাটগুলো ধরবে is_page_number = bool(re.match(r'^[-—–\s]*\d{1,4}[-—–\s]*$', clean_text.strip())) or \ bool(re.match(r'^page\s+\d{1,4}$', clean_text.strip(), re.IGNORECASE)) if is_page_number: block['is_section_start'] = False block['is_page_number'] = True continue # TOC এর ভেতরের এলিমেন্টগুলো সেকশন হবে না if i in toc_indices: block['is_section_start'] = False continue score = 0 # ফ্যাক্টর A: হেডিং টাইপ (PDF-এর heading3 কেও বুস্ট দেওয়া হলো) if block.get('type') in ['heading1', 'heading2']: score += 5 elif block.get('type') == 'heading3': score += 3 # ফ্যাক্টর B: ফন্ট সাইজ (বডি টেক্সটের সাথে তুলনা) f_size = block.get('font_size', body_size) if f_size >= body_size + 4: score += 6 elif f_size >= body_size + 2: score += 3 # ফ্যাক্টর C: ফন্ট ওয়েট if block.get('is_bold'): score += 3 # ফ্যাক্টর D: কেস (Case) if clean_text.isupper() and 3 < len(clean_text) < 80: score += 3 # ফ্যাক্টর E: কি-ওয়ার্ডস lower_text = clean_text.lower() if re.match(r'^(chapter|part|section|appendix|introduction|preface|prologue|epilogue|foreword|glossary|index)', lower_text): score += 5 # TABLE OF CONTENTS স্পেশাল রুল লজিক ফিক্স if "table of contents" in lower_text or "contents" == lower_text: if not toc_indices and i < 50: # যদি কোনো TOC ক্লাস্টার না পাওয়া যায়, কিন্তু প্রথম দিকে থাকে score += 5 elif len(toc_indices) > 0 and i <= min(toc_indices): # যদি ক্লাস্টার থাকে এবং এটি তার আগে থাকে score += 5 # --- পেনাল্টি (নেগেটিভ স্কোরিং - ফ্লেক্সিবল রুল) --- if word_count > 20: score -= 10 elif word_count > 12: # ফন্ট বড় না হলে এবং বোল্ড না হলে তবেই পেনাল্টি if not block.get('is_bold') and f_size <= body_size + 2: score -= 5 if re.search(r'[.!?]\s*["\u0027\u2018\u2019\u201C\u201D]?$', clean_text): score -= 3 # চূড়ান্ত সিদ্ধান্ত if score >= 6: block['is_section_start'] = True title = clean_text[:60].strip() if title.isupper() and len(title) > 10: title = title.title() block['section_name'] = title section_counter += 1 print(f" 📌 সেকশন চিহ্নিত করা হয়েছে: [{score} pts] {title}") else: block['is_section_start'] = False block['section_name'] = "" # পেজ নাম্বার ব্লকগুলো মূল ডেটা থেকে পুরোপুরি বাদ দিয়ে দেওয়া হচ্ছে (TTS যেন না পড়ে) filtered_blocks = [b for b in blocks if not b.get('is_page_number')] return filtered_blocks # ===================================================================== # মেইন এক্সপোর্ট ফাংশন # ===================================================================== def process_document_smartly(blocks, metadata): """ মেইন এন্ট্রি পয়েন্ট। রুল-বেসড ইঞ্জিনের মাধ্যমে পুরো ডকুমেন্ট প্রসেস করা হয়। """ print("\n" + "=" * 60, flush=True) print("🚀 অ্যাডভান্সড রুল-বেসড ইঞ্জিন (v2.1) শুরু হচ্ছে...", flush=True) print(f"📄 মোট {len(blocks)} টি ব্লক বিশ্লেষণ করা হচ্ছে।", flush=True) if not blocks: return blocks merged_blocks = _advanced_merge(blocks) print(f"✂️ মার্জ করার পর মোট ব্লক সংখ্যা: {len(merged_blocks)}", flush=True) final_blocks = _apply_section_scoring(merged_blocks) section_count = sum(1 for b in final_blocks if b.get('is_section_start')) print(f"📑 ডকুমেন্টে মোট {section_count} টি সেকশন পাওয়া গেছে।", flush=True) print("=" * 60 + "\n", flush=True) return final_blocks