Spaces:
Sleeping
Sleeping
Download src/pdf_processor.py from hllerdgn/LawAgent-backend: direct link, hf CLI and curl.
- Browser
- Download file 8.51 kB
-
https://huggingface.co/spaces/hllerdgn/LawAgent-backend/resolve/main/src/pdf_processor.py
- Command line
-
hf download hf://spaces/hllerdgn/LawAgent-backend/src/pdf_processor.py
-
curl -L -o pdf_processor.py https://huggingface.co/spaces/hllerdgn/LawAgent-backend/resolve/main/src/pdf_processor.py
8.51 kB
| import uuid | |
| import fitz | |
| import json | |
| import os | |
| import re | |
| from datetime import datetime | |
| from typing import List | |
| from qdrant_client.http import models as qmodels | |
| SITE_COLLECTION_NAME = "site_corpus" | |
| DOCS_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "site_documents.json") | |
| def get_uploaded_documents() -> List[dict]: | |
| if not os.path.exists(DOCS_FILE): | |
| return [] | |
| try: | |
| with open(DOCS_FILE, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| except Exception: | |
| return [] | |
| def save_document_metadata(filename: str, chunk_count: int): | |
| docs = get_uploaded_documents() | |
| # Check if already exists, if so update it | |
| exists = False | |
| for d in docs: | |
| if d["filename"] == filename: | |
| d["chunk_count"] = chunk_count | |
| d["upload_date"] = datetime.now().isoformat() | |
| exists = True | |
| break | |
| if not exists: | |
| docs.append({ | |
| "filename": filename, | |
| "chunk_count": chunk_count, | |
| "upload_date": datetime.now().isoformat() | |
| }) | |
| os.makedirs(os.path.dirname(DOCS_FILE), exist_ok=True) | |
| with open(DOCS_FILE, "w", encoding="utf-8") as f: | |
| json.dump(docs, f, ensure_ascii=False, indent=4) | |
| def delete_document(filename: str, qdrant) -> bool: | |
| try: | |
| # Create index if it doesn't exist (required for filtering) | |
| try: | |
| qdrant.create_payload_index( | |
| collection_name=SITE_COLLECTION_NAME, | |
| field_name="filename", | |
| field_schema=qmodels.PayloadSchemaType.KEYWORD, | |
| ) | |
| except Exception as idx_err: | |
| pass | |
| # Delete from Qdrant | |
| qdrant.delete( | |
| collection_name=SITE_COLLECTION_NAME, | |
| points_selector=qmodels.FilterSelector( | |
| filter=qmodels.Filter( | |
| must=[ | |
| qmodels.FieldCondition( | |
| key="filename", | |
| match=qmodels.MatchValue(value=filename) | |
| ) | |
| ] | |
| ) | |
| ) | |
| ) | |
| # Delete from JSON | |
| docs = get_uploaded_documents() | |
| docs = [d for d in docs if d["filename"] != filename] | |
| with open(DOCS_FILE, "w", encoding="utf-8") as f: | |
| json.dump(docs, f, ensure_ascii=False, indent=4) | |
| return True | |
| except Exception as e: | |
| print(f"Delete document error: {e}") | |
| return False | |
| SITE_COLLECTION_NAME = "site_corpus" | |
| # ── Heading / section boundary patterns ─────────────────────────────────────── | |
| # Bir satır şu kalıplardan birine uyuyorsa yeni bir bölüm başladığı kabul edilir: | |
| # • MADDE 12 / Madde 12 / m. 12 | |
| # • BÖLÜM III / Bölüm 3 | |
| # • § 5 | |
| # • 1. Genel Hükümler (numara + büyük başlangıç) | |
| # • I. / II. / III. … (Romen rakamı) | |
| _HEADING_RE = re.compile( | |
| r"""^ | |
| (?: | |
| (?:MADDE|Madde|m\.)\s*\d+ # Madde numarası | |
| | (?:BÖLÜM|Bölüm|KISIM|Kısım)\s*[\dIVXivx]+ # Bölüm / Kısım | |
| | §\s*\d+ # § işareti | |
| | \d+\.\s+[A-ZÇĞİÖŞÜa-zçğışöüü] # 1. Başlık | |
| | [IVX]+\.\s+[A-ZÇĞİÖŞÜ] # Romen rakamı başlık | |
| ) | |
| """, | |
| re.VERBOSE, | |
| ) | |
| def _is_heading(line: str) -> bool: | |
| return bool(_HEADING_RE.match(line.strip())) | |
| def _split_oversized(text: str, max_words: int, overlap_words: int) -> List[str]: | |
| """Doğal sınır bulunamayan büyük blokları kelime bazlı böler (fallback).""" | |
| words = text.split() | |
| chunks: List[str] = [] | |
| i = 0 | |
| while i < len(words): | |
| chunks.append(" ".join(words[i : i + max_words])) | |
| i += max_words - overlap_words | |
| return chunks | |
| def chunk_text( | |
| text: str, | |
| max_words: int = 300, | |
| overlap_words: int = 50, | |
| ) -> List[str]: | |
| """ | |
| Paragraf ve başlık sınırlarına saygı gösteren yapısal chunking. | |
| Strateji | |
| -------- | |
| 1. Metni çift-yeni-satır (\\n\\n) ile paragraflara böl. | |
| 2. Her paragrafı "başlık mı?" diye kontrol et. | |
| 3. Paragrafları biriktir; toplam kelime sayısı max_words'ü geçince | |
| birikmiş bloğu bir chunk olarak kaydet ve son overlap_words kelimeyi | |
| bir sonraki bloğa taşı (bağlam sürekliliği). | |
| 4. Yeni bir BAŞLIK gördüğümüzde mevcut bloğu sıfırla (konu sınırı). | |
| 5. Eğer tek bir paragraf max_words'ü aşarsa _split_oversized ile böl. | |
| """ | |
| paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()] | |
| chunks: List[str] = [] | |
| current_block: List[str] = [] | |
| current_words = 0 | |
| def flush(block: List[str]) -> List[str]: | |
| joined = "\n\n".join(block) | |
| return _split_oversized(joined, max_words, overlap_words) if len(joined.split()) > max_words else [joined] | |
| for para in paragraphs: | |
| para_words = len(para.split()) | |
| is_heading = _is_heading(para) | |
| # Başlık geldiğinde mevcut bloğu kapat | |
| if is_heading and current_block: | |
| chunks.extend(flush(current_block)) | |
| # overlap: son bloğun son overlap_words kelimesini taşı | |
| last_text = current_block[-1] | |
| tail_words = last_text.split()[-overlap_words:] | |
| current_block = [" ".join(tail_words)] if tail_words else [] | |
| current_words = len(current_block[0].split()) if current_block else 0 | |
| # Mevcut blok + yeni paragraf sınırı aşıyorsa flush | |
| if current_words + para_words > max_words and current_block: | |
| chunks.extend(flush(current_block)) | |
| last_text = current_block[-1] | |
| tail_words = last_text.split()[-overlap_words:] | |
| current_block = [" ".join(tail_words)] if tail_words else [] | |
| current_words = len(current_block[0].split()) if current_block else 0 | |
| current_block.append(para) | |
| current_words += para_words | |
| if current_block: | |
| chunks.extend(flush(current_block)) | |
| return [c for c in chunks if c.strip()] | |
| def _extract_pages(doc: fitz.Document) -> List[dict]: | |
| """Her sayfadan metni çeker; sayfa numarası metadata olarak döndürülür.""" | |
| pages = [] | |
| for page_num, page in enumerate(doc, start=1): | |
| text = page.get_text("text").strip() | |
| if text: | |
| pages.append({"page": page_num, "text": text}) | |
| return pages | |
| def process_and_index_pdf(pdf_bytes: bytes, filename: str, embedder, qdrant) -> int: | |
| # 1. Sayfa bazlı metin çıkarımı | |
| doc = fitz.open("pdf", pdf_bytes) | |
| pages = _extract_pages(doc) | |
| # 2. Yapısal chunking (paragraf/başlık sınırı) | |
| all_chunks: List[dict] = [] # {"text": ..., "page": ...} | |
| for page_info in pages: | |
| page_chunks = chunk_text(page_info["text"], max_words=300, overlap_words=50) | |
| for ch in page_chunks: | |
| all_chunks.append({"text": ch, "page": page_info["page"]}) | |
| if not all_chunks: | |
| return 0 | |
| # 3. Collection oluştur (yoksa) | |
| existing = [c.name for c in qdrant.get_collections().collections] | |
| if SITE_COLLECTION_NAME not in existing: | |
| qdrant.create_collection( | |
| SITE_COLLECTION_NAME, | |
| vectors_config=qmodels.VectorParams( | |
| size=embedder.vector_size, distance=qmodels.Distance.COSINE | |
| ), | |
| ) | |
| # 4. Embed + Upload (batch) | |
| BATCH_SIZE = 32 | |
| total_added = 0 | |
| for i in range(0, len(all_chunks), BATCH_SIZE): | |
| batch = all_chunks[i : i + BATCH_SIZE] | |
| texts = [c["text"] for c in batch] | |
| vecs = embedder.encode(texts) | |
| points = [] | |
| for idx, chunk_info in enumerate(batch): | |
| chunk_id = uuid.uuid4() | |
| points.append( | |
| qmodels.PointStruct( | |
| id=chunk_id.int >> 64, | |
| vector=vecs[idx], | |
| payload={ | |
| "chunk_id": str(chunk_id), | |
| "text": chunk_info["text"], | |
| "source": "site_document", | |
| "filename": filename, | |
| "page": chunk_info["page"], | |
| }, | |
| ) | |
| ) | |
| qdrant.upsert(collection_name=SITE_COLLECTION_NAME, points=points) | |
| total_added += len(points) | |
| save_document_metadata(filename, total_added) | |
| return total_added | |