File size: 16,168 Bytes
5a7d213
 
 
 
 
03ee111
 
5a7d213
 
a989689
d8d3708
5a7d213
03ee111
 
d8d3708
 
 
841a1ea
03ee111
5a7d213
 
03ee111
d8d3708
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03ee111
 
 
5a7d213
d8d3708
03ee111
 
 
5a7d213
 
 
d8d3708
 
 
5a7d213
d8d3708
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a7d213
d8d3708
3e8a039
fd4240c
03ee111
d8d3708
3e8a039
fd4240c
d8d3708
fd4240c
d8d3708
 
 
 
 
 
fd4240c
03ee111
 
5a7d213
d8d3708
 
 
 
 
 
 
 
160bc01
d8d3708
03ee111
5a7d213
03ee111
5a7d213
d8d3708
160bc01
d8d3708
 
03ee111
 
5a7d213
2d15f15
03ee111
 
 
 
 
 
 
 
d8d3708
03ee111
 
 
 
 
 
 
 
5a7d213
03ee111
5a7d213
 
a989689
d8d3708
03ee111
d8d3708
03ee111
 
 
d8d3708
5a7d213
03ee111
d8d3708
5a7d213
d8d3708
 
03ee111
 
 
 
 
d8d3708
 
 
03ee111
d8d3708
a989689
d8d3708
 
 
 
 
 
 
 
 
03ee111
d8d3708
 
 
 
03ee111
d8d3708
03ee111
d8d3708
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03ee111
 
 
d8d3708
 
 
 
 
03ee111
d8d3708
 
 
 
 
 
03ee111
 
 
 
 
 
 
 
d8d3708
03ee111
d8d3708
03ee111
 
 
 
 
d8d3708
03ee111
 
d8d3708
 
03ee111
 
d8d3708
 
03ee111
 
160bc01
03ee111
 
 
 
 
 
 
d8d3708
 
03ee111
 
 
d8d3708
03ee111
 
 
d8d3708
 
 
 
 
 
 
 
 
 
 
03ee111
d8d3708
 
03ee111
 
d8d3708
03ee111
 
 
d8d3708
 
fd4240c
5a7d213
d8d3708
 
03ee111
 
 
 
 
 
 
 
 
 
 
d8d3708
03ee111
 
3e8a039
160bc01
03ee111
 
 
 
 
 
 
 
 
 
 
5a7d213
 
03ee111
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
import gradio as gr
import PyPDF2
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import HuggingFaceEmbeddings
from langchain.vectorstores import FAISS
from langchain.llms import HuggingFacePipeline 
from transformers import pipeline, AutoTokenizer, GPT2LMHeadModel, BitsAndBytesConfig
import os
import torch
import re
import unicodedata # Unicode normalizasyonu için

# --- 0. Global Değişkenler ve Ayarlar ---
PDF_PATH = "mevzuat.pdf" 
# Mevzuat için daha uygun Chunk boyutları
CHUNK_SIZE = 700 # Daha da artırıldı
CHUNK_OVERLAP = 150 # Daha da artırıldı
EMBEDDING_MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
LLM_MODEL_NAME = "ytu-ce-cosmos/turkish-gpt2-medium" 
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"

MODEL_MAX_LENGTH = 1024 
MAX_NEW_TOKENS = 350 # Daha detaylı cevaplar için artırıldı

# --- Yardımcı Metin Temizleme ve Normalizasyon Fonksiyonu (Daha Kapsamlı) ---
def normalize_text(text):
    if not isinstance(text, str):
        return ""
    
    # Unicode normalizasyonu (aksansız karakterlere çevirme, vb.)
    text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('utf-8', 'ignore')
    
    # Küçük harfe çevir
    text = text.lower() 
    
    # Noktalama işaretlerini kaldır (kelime bazlı arama için önemli)
    text = re.sub(r'[^\w\s]', '', text) 
    
    # Birden fazla boşluğu tek boşluğa çevir ve baştaki/sondaki boşlukları kaldır
    text = re.sub(r'\s+', ' ', text).strip() 
    
    return text

# --- 1. PDF'i İşle ve Vektör Veritabanı Oluştur ---
def create_vector_db_from_pdf(pdf_path):
    print(f"[{os.path.basename(__file__)}] PDF okunuyor: {pdf_path}")
    
    chunks_with_metadata = [] 

    try:
        with open(pdf_path, "rb") as file:
            reader = PyPDF2.PdfReader(file)
            total_pages = len(reader.pages)
            print(f"[{os.path.basename(__file__)}] Toplam {total_pages} sayfa bulundu.")

            for page_num, page in enumerate(reader.pages):
                try:
                    page_text = page.extract_text()
                    if page_text:
                        # Metin temizliği ve normalizasyonunu uygula
                        cleaned_and_normalized_page_text = normalize_text(page_text)
                        
                        text_splitter = RecursiveCharacterTextSplitter(
                            chunk_size=CHUNK_SIZE,
                            chunk_overlap=CHUNK_OVERLAP,
                            length_function=len,
                        )
                        page_chunks = text_splitter.split_text(cleaned_and_normalized_page_text)
                        
                        for chunk in page_chunks:
                            chunks_with_metadata.append({
                                "content": chunk,
                                "source_page": page_num + 1 
                            })
                except Exception as page_e:
                    print(f"[{os.path.basename(__file__)}] Sayfa {page_num + 1} okunurken veya işlenirken hata oluştu: {page_e}")
                    continue # Bir sonraki sayfaya geç

    except Exception as e:
        print(f"[{os.path.basename(__file__)}] PDF dosyası okunurken genel hata oluştu: {e}")
        return None

    if not chunks_with_metadata:
        print(f"[{os.path.basename(__file__)}] PDF'ten metin çıkarılamadı veya parçalanamadı. Lütfen PDF içeriğini kontrol edin.")
        return None

    print(f"[{os.path.basename(__file__)}] Metin {len(chunks_with_metadata)} parçaya ayrıldı (metadata dahil).")

    print(f"[{os.path.basename(__file__)}] Embedding modeli yükleniyor: {EMBEDDING_MODEL_NAME}")
    try:
        embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL_NAME) 
    except Exception as e:
        print(f"[{os.path.basename(__file__)}] Embedding modeli yüklenirken hata oluştu: {e}")
        return None

    texts = [item["content"] for item in chunks_with_metadata]
    metadatas = [{"source_page": item["source_page"]} for item in chunks_with_metadata]

    print(f"[{os.path.basename(__file__)}] FAISS vektör veritabanı oluşturuluyor...")
    try:
        db = FAISS.from_texts(texts, embeddings, metadatas=metadatas)
        print(f"[{os.path.basename(__file__)}] Vektör veritabanı başarıyla oluşturuldu.")
        return db, embeddings
    except Exception as e:
        print(f"[{os.path.basename(__file__)}] FAISS veritabanı oluşturulurken hata oluştu: {e}")
        return None

# --- Vektör veritabanını başlatma ---
vector_db, embeddings_model = None, None
if os.path.exists(PDF_PATH):
    vector_db, embeddings_model = create_vector_db_from_pdf(PDF_PATH)
else:
    print(f"[{os.path.basename(__file__)}] Hata: {PDF_PATH} bulunamadı. Lütfen PDF dosyasını projenizin kök dizinine yükleyin ve doğru yolda olduğundan emin olun.")

# --- LLM modelini yükleme ---
print(f"[{os.path.basename(__file__)}] LLM modeli yükleniyor: {LLM_MODEL_NAME} - Cihaz: {DEVICE}")
tokenizer = None 
llm = None
try:
    tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_NAME)
    
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token
    
    if DEVICE == "cuda":
        quantization_config = BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type="nf4",
            bnb_4bit_compute_dtype=torch.float16, # Doğru format "torch.float16" veya torch.float16
            bnb_4bit_use_double_quant=True,
        ) 
        model = GPT2LMHeadModel.from_pretrained(LLM_MODEL_NAME, 
                                                quantization_config=quantization_config,
                                                device_map="auto")
    else: 
        model = GPT2LMHeadModel.from_pretrained(LLM_MODEL_NAME).to(DEVICE)
    
    text_generation_pipeline = pipeline(
        "text-generation",
        model=model,
        tokenizer=tokenizer,
        max_new_tokens=MAX_NEW_TOKENS, 
        temperature=0.3,   # Daha kesin ve tutarlı cevaplar için biraz daha düşürüldü
        do_sample=True,    
        top_k=30,          # Daha odaklı cevaplar için biraz daha düşürüldü
        num_return_sequences=1,
        device=0 if DEVICE == "cuda" else -1, 
        pad_token_id=tokenizer.pad_token_id,
        return_full_text=False # Sadece üretilen metni döndür
    )
    llm = HuggingFacePipeline(pipeline=text_generation_pipeline)
    print(f"[{os.path.basename(__file__)}] LLM modeli başarıyla yüklendi.")
except Exception as e:
    print(f"[{os.path.basename(__file__)}] LLM modeli yüklenirken kritik hata oluştu: {e}")
    llm = None # Hata durumunda LLM'i None olarak bırak

# --- 2. Gradio Arayüzü İçin Soru Cevaplama Fonksiyonu ---
def answer_question(question, chat_history):
    global tokenizer 
    if vector_db is None or llm is None or tokenizer is None:
        error_msg = "Üzgünüm, sistem başlatılamadı (PDF, LLM veya Tokenizer yüklenemedi). Lütfen logları kontrol edin ve yöneticinizle iletişime geçin."
        print(f"[{os.path.basename(__file__)}] Hata: {error_msg}")
        return "", chat_history + [[question, error_msg]]

    print(f"[{os.path.basename(__file__)}] Gelen soru: '{question}'")
    
    # Soruyu da veritabanındaki metinlerle aynı şekilde normalize et
    normalized_question = normalize_text(question)
    print(f"[{os.path.basename(__file__)}] Normalize edilmiş soru: '{normalized_question}'")

    if not normalized_question.strip():
        return "", chat_history + [[question, "Lütfen geçerli bir soru girin."]]

    # Daha fazla potansiyel ilgili belge getir
    retrieved_docs_with_scores = vector_db.similarity_search_with_score(normalized_question, k=6) # k değeri 4'ten 6'ya çıkarıldı
    
    if not retrieved_docs_with_scores:
        print(f"[{os.path.basename(__file__)}] Sorunuza uygun ilgili belge bulunamadı.")
        return "", chat_history + [[question, "Üzgünüm, sorunuzla ilgili bağlamda herhangi bir bilgi bulunamadı. Lütfen sorunuzu farklı bir şekilde ifade etmeyi deneyin veya daha spesifik olun."]]

    context_parts = []
    source_pages = set() # Kullanılan sayfa numaralarını tutmak için
    
    for i, (doc, score) in enumerate(retrieved_docs_with_scores):
        # Yüksek benzerlik skoruna sahip (düşük mesafe) belgeleri tercih et
        # Çok alakasız görünenleri eleyebiliriz, eşik değeri deneyerek ayarlanmalı
        # Örneğin, mesafe 0.5'ten büyükse ele (FAISS'te daha küçük mesafe daha iyi benzerliktir)
        # print(f"Doc {i+1} Score: {score}, Content: {doc.page_content[:100]}...") # Debug için
        
        # Eğer embedding modeli cosine similarity döndürüyorsa, 0'a yakınlık yüksek benzerlik demektir.
        # Eğer L2 mesafesi döndürüyorsa, 0'a yakınlık yüksek benzerlik demektir.
        # sentence-transformers genellikle cosine similarity döndürür, bu yüzden 1'e yakın değerler daha iyi.
        # Ancak FAISS genellikle L2 mesafesi kullanır (veya cosine distance'ı 1-cos(theta) olarak temsil eder).
        # Genellikle 0'a yakın değerler daha iyi (daha küçük mesafe).
        # Eğer score çok yüksek (büyük bir sayı) ise, bu doküman çok alakasız olabilir.
        
        # Basit bir filtreleme: İlk 3 dokümanı kesin al, diğerlerini skorlarına göre değerlendir
        if i < 3 or score < 0.6: # Bu eşik değeri denemelerle ayarlanmalı
             context_parts.append(doc.page_content)
             if 'source_page' in doc.metadata:
                source_pages.add(doc.metadata['source_page'])
        else:
            print(f"[{os.path.basename(__file__)}] Doküman {i+1} çok alakasız olduğu için bağlama eklenmedi (Skor: {score:.4f}).")
            
    context = "\n\n".join(context_parts)
    
    if not context.strip():
        print(f"[{os.path.basename(__file__)}] Filtreleme sonrası bağlam boş kaldı.")
        return "", chat_history + [[question, "Üzgünüm, sorunuzla ilgili yeterince alakalı bilgi bulunamadı. Lütfen sorunuzu farklı bir şekilde ifade etmeyi deneyin veya daha spesifik olun."]]

    print(f"[{os.path.basename(__file__)}] Bağlam için {len(context_parts)} belge parçası kullanıldı. İlk 200 karakter:\n'{context[:200]}...'")

    # Geliştirilmiş, daha detaylı ve kısıtlayıcı Prompt
    prompt = f"""Sen bir Fırat Üniversitesi mevzuat uzmanısın. Görevin, yalnızca ve kesinlikle aşağıdaki 'Bağlam' bölümünde verilen bilgilere dayanarak 'Soru'yu eksiksiz, net, anlaşılır ve madde madde olacak şekilde cevaplamaktır.
    
    Eğer 'Bağlam'da soruya cevap verecek yeterli ve doğrudan bilgi bulunmuyorsa, kesinlikle kendi bilginizden uydurma yapmayın. Bu durumda, 'Üzgünüm, bu soruya bağlamda yeterli bilgi bulunmamaktadır.' şeklinde açıkça belirtin.
    
    Cevabın sonunda, bilginin alındığı sayfa numaralarını 'Kaynak: Sayfa X, Y, Z' formatında açıkça ve sadece bağlamda belirtilen sayfa numaralarını kullanarak listeleyin. Bağlamda sayfa bilgisi yoksa (ki bu olmamalı), kaynak belirtme.

Bağlam:
{context}

Soru: {question}

Cevap:
"""
    print(f"[{os.path.basename(__file__)}] LLM'e gönderilen prompt'ın başlangıcı: '{prompt[:500]}...'") 

    # --- Prompt uzunluğunu MODEL_MAX_LENGTH - MAX_NEW_TOKENS ile sınırlayalım ---
    max_input_tokens = MODEL_MAX_LENGTH - MAX_NEW_TOKENS 
    
    prompt_tokens = tokenizer.encode(prompt, return_tensors='pt')[0]
    tokenized_prompt_length = prompt_tokens.shape[0]

    print(f"[{os.path.basename(__file__)}] Oluşturulan prompt'un token uzunluğu: {tokenized_prompt_length}")
    
    if tokenized_prompt_length > max_input_tokens:
        print(f"[{os.path.basename(__file__)}] UYARI: Prompt'un token uzunluğu ({tokenized_prompt_length}) modelin kabul edebileceği maksimum girdi uzunluğunu ({max_input_tokens}) aşıyor! Kısaltılıyor...")
        # Token bazında kısaltma
        prompt_tokens = prompt_tokens[:max_input_tokens]
        prompt = tokenizer.decode(prompt_tokens, skip_special_tokens=True) 
        print(f"[{os.path.basename(__file__)}] Prompt kısaltıldı. Yeni token uzunluğu: {len(tokenizer.encode(prompt))}")
        print(f"[{os.path.basename(__file__)}] Kısaltılmış prompt'ın başlangıcı: '{prompt[:500]}...'")

    final_answer = ""
    try:
        response_data = llm(prompt) 
        
        if isinstance(response_data, list) and len(response_data) > 0 and 'generated_text' in response_data[0]:
            final_answer = response_data[0]['generated_text'].strip()
        else:
            final_answer = str(response_data).strip()

        # LLM'in prompt'u tekrar etme veya gereksiz kısımları kaldırma
        # Çoklu kontrol noktası
        if final_answer.lower().startswith(prompt.lower()):
            final_answer = final_answer[len(prompt):].strip()
        
        # "Cevap:" kısmı LLM tarafından tekrar edildiyse temizle
        if "Cevap:" in final_answer: 
             final_answer = final_answer.split("Cevap:", 1)[-1].strip()
        
        # Prompt'un başlangıcından kalıntı kaldıysa temizle
        if final_answer.lower().startswith("sen bir fırat üniversitesi mevzuat uzmanısın.") or \
           final_answer.lower().startswith("aşağıdaki bağlamı kullanarak"):
            # Daha sofistike bir temizleme: "Cevap:" ifadesini arayarak o kısımdan sonrasını al
            match = re.search(r"cevap:\s*(.*)", final_answer, re.IGNORECASE | re.DOTALL)
            if match:
                final_answer = match.group(1).strip()
            else: # Bulamazsa yine de bir kısmı sil
                final_answer = final_answer.split("Soru:", 1)[-1].strip() if "Soru:" in final_answer else final_answer.strip()
                final_answer = final_answer.split("Cevap:", 1)[-1].strip() if "Cevap:" in final_answer else final_answer.strip()


        # Cevabın boş veya anlamsız olup olmadığını kontrol et
        if not final_answer or final_answer.strip().lower() in ["üzgünüm, bu soruya bağlamda yeterli bilgi bulunmamaktadır.", "üzgünüm, bu soruya bağlamda yeterli bilgi bulunmamaktadır", "bilgi bulunamadı.", "bilgi bulunamadı"]:
            final_answer = "Üzgünüm, bu soruya bağlamda yeterli bilgi bulunmamaktadır."
        
        # Kaynakları cevaba ekle
        if source_pages and final_answer != "Üzgünüm, bu soruya bağlamda yeterli bilgi bulunmamaktadır.":
            sorted_pages = sorted(list(source_pages))
            final_answer += f"\n\n**Kaynak:** Sayfa {', '.join(map(str, sorted_pages))}"
        
        print(f"[{os.path.basename(__file__)}] Üretilen son cevap: '{final_answer[:500]}...'")

    except Exception as e:
        final_answer = f"Cevap üretilirken kritik bir hata oluştu: {e}. Lütfen daha sonra tekrar deneyin veya farklı bir soru sorun."
        print(f"[{os.path.basename(__file__)}] Kritik cevap üretim hatası: {e}")

    chat_history.append((question, final_answer))
    return "", chat_history 

# --- 3. Gradio Arayüz Tanımı ---
with gr.Blocks() as demo:
    gr.Markdown(
        """
        # Fırat Üniversitesi Mini RAG Botu
        Fırat Üniversitesi mevzuat.firat.edu.tr adresinden alınan PDF dosyası üzerinde soru-cevap yapın.
        Sorularınıza belgedeki bilgilere dayanarak cevaplar verilecektir.
        Bağlamda bilgi yoksa, bot kesinlikle uydurma yapmayacaktır ve bunu açıkça belirtecektir.
        **Cevaplar artık kaynak sayfa numarasını da içermektedir.**
        """
    )

    chatbot = gr.Chatbot(height=400, label="Sohbet")
    msg = gr.Textbox(label="Sorunuzu buraya yazın:", placeholder="Örn: 'Belgede tez jürisi kuralı ne?'")
    
    with gr.Row():
        submit_btn = gr.Button("Gönder")
        clear_btn = gr.Button("Sohbeti Temizle")

    msg.submit(answer_question, [msg, chatbot], [msg, chatbot]) 
    submit_btn.click(answer_question, [msg, chatbot], [msg, chatbot])
    
    clear_btn.click(lambda: (None, []), outputs=[msg, chatbot]) 

if __name__ == "__main__":
    demo.launch()