emrecn commited on
Commit
efc4680
·
0 Parent(s):

HF Spaces deploy: temiz tek-commit history, chroma_db Git LFS üzerinden

Browse files
.gitattributes ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ * text=auto eol=lf
2
+
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.pickle filter=lfs diff=lfs merge=lfs -text
5
+ *.sqlite3 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ .env
3
+ __pycache__/
4
+ tests/
5
+ pdfs/
6
+ logs/
7
+ output.txt
8
+ allpdfs/
9
+ cleanpdfs/
10
+ test_chunk_output.txt
11
+ resp_samples.html
12
+ resp_sample.html
13
+
14
+ # dev/inceleme dosyaları HF deploy'a girmesin (main branch'ta tutuluyor)
15
+ test_*.py
16
+ check_patterns.py
17
+ download_tablet_kt.py
18
+ ilac_chatbot_colab.ipynb
19
+ drugs_list.txt
20
+ manifest.json
README.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ilacChat
3
+ emoji: 💊
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ app_file: app.py
8
+ pinned: false
9
+ ---
10
+
11
+ # Huggingface Space : [emrecn/ilacChatBot](https://huggingface.co/spaces/emrecn/ilacChatBot)
12
+
13
+ <img width="2390" height="434" alt="mermaid-diagram" src="https://github.com/user-attachments/assets/1ba2f96c-0bfc-4d5d-80f2-c06752615501" />
14
+
15
+
16
+ # ilacChatBot
17
+
18
+ Turkce ilac kullanma talimatlarindan bilgi alan, PDF tabanli bir RAG sohbet uygulamasidir.
19
+ Uygulama, prospektusleri okuyup vektor veritabanina kaydeder; kullanici sorularini bu bilgi tabanina gore yanitlar.
20
+
21
+ ## ÖNEMLİ !! Doküman olarak kullandığım pdflerin ait olduğu ilaçların adları drug_list.text belgesinin içerisinde yazmaktadır. Model sadece bu ilaçlarla ilgili cevap verebilir.
22
+
23
+ ## Özellikler
24
+
25
+ - PDF kullanma talimatlarindan otomatik veri cekme
26
+ - Regex tabanli metin temizleme ve bolum ayirma
27
+ - ChromaDB ile vektor arama
28
+ - Google Gemini ile yanit olusturma
29
+ - Jina Embeddings ile semantik temsil
30
+ - Gradio tabanli web arayuzu
31
+ - Hafizasiz, tek soruluk RAG akisi
32
+
33
+ ## Kullanılan Teknolojiler
34
+
35
+ - Python
36
+ - LangChain
37
+ - ChromaDB
38
+ - Gradio
39
+ - Google Gemini API
40
+ - Jina Embeddings
41
+ - PyPDF
42
+ - Hugging Face Spaces
43
+
44
+ ## Proje Yapisi
45
+
46
+ ```text
47
+ .
48
+ ├── app.py
49
+ ├── app/
50
+ │ ├── __init__.py
51
+ │ ├── ingest.py
52
+ │ ├── retrieval.py
53
+ │ └── ui.py
54
+ ├── chroma_db/
55
+ ├── pdfs/
56
+ ├── requirements.txt
57
+ ├── manifest.json
58
+ └── drugs_list.txt
59
+ ```
60
+
61
+ ## Yerel Kurulum
62
+
63
+ 1. Sanal ortam olusturun ve bagimliliklari yukleyin.
64
+
65
+ ```bash
66
+ python -m venv venv
67
+ .\venv\Scripts\activate
68
+ pip install -r requirements.txt
69
+ ```
70
+
71
+ 2. Koku dizinde `.env` dosyasi olusturun ve API anahtarlarini ekleyin.
72
+
73
+ ```ini
74
+ GOOGLE_API_KEY=your_gemini_key
75
+ JINA_API_KEY=your_jina_key
76
+ ```
77
+
78
+ 3. PDF dosyalarinizi `pdfs/` klasorune koyun ve vektor veritabanini olusturun.
79
+
80
+ ```bash
81
+ python -m app.ingest --pdf-dir ./pdfs --mode full
82
+ ```
83
+
84
+ 4. Uygulamayi calistirin.
85
+
86
+ ```bash
87
+ python app.py
88
+ ```
89
+
90
+
91
+ ## Geliştirilecek Özellikler
92
+
93
+ - Daha iyi bolum tespiti ve chunk kalitesi
94
+ - Benzer ilaclar icin akilli eslestirme ve yeniden sorgulama
95
+ - Kaynak gosterimini daha okunabilir hale getirme
96
+ - Soru-cevap gecmisini opsiyonel hale getirme
97
+ - PDF disinda ilac kutu bilgileri ve prospektus metadata destegi
98
+ - Kullanici arayuzu icin daha gelismis filtreleme ve sonuc ozetleri
99
+ - Toplu PDF yukleme ve otomatik yeniden indeksleme
100
+ - Hata izleme ve log kaydi iyilestirmeleri
101
+
app.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import os
2
+ from app.ui import main, demo # `demo` module-level export — `gradio app.py` hot reload için
3
+
4
+ if __name__ == "__main__":
5
+ # Hugging Face Spaces üzerinden çalışırken share=False ve host=0.0.0.0 olmalıdır.
6
+ # Gradio HF spaces tarafında varsayılan 7860 portunu kullanır.
7
+ main(host="0.0.0.0", port=7860, share=False)
app/__init__.py ADDED
File without changes
app/ingest.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import re
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from langchain_core.documents import Document
9
+ from langchain_community.document_loaders import PyPDFLoader
10
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
11
+ from langchain_community.embeddings import JinaEmbeddings
12
+ from langchain_community.vectorstores import Chroma
13
+ from dotenv import load_dotenv
14
+ from app.logger import get_logger
15
+
16
+ load_dotenv()
17
+
18
+ logger = get_logger("ingest")
19
+
20
+ CHROMA_DB_DIR = "./chroma_db"
21
+ MANIFEST_PATH = "./manifest.json"
22
+
23
+
24
+ def _loose(phrase: str) -> str:
25
+ """Bir ifadeyi, PDF çıkarımında kelime içine serpilmiş rastgele
26
+ boşluklara ('Ku llanm a') toleranslı bir regex'e çevirir.
27
+
28
+ Boşluklar \\s+, diğer tokenlar arasına \\s* eklenir. '.' -> \\.?,
29
+ ':' -> :?, '?' önceki tokenı opsiyonel yapmak yerine literal
30
+ bırakmaz (opsiyonel karakterler nokta/:).
31
+ Karakter sınıfları ([ıi] gibi) tek token sayılır.
32
+ """
33
+ out: list[str] = []
34
+ i, n = 0, len(phrase)
35
+ while i < n:
36
+ c = phrase[i]
37
+ if c.isspace():
38
+ out.append(r"\s+")
39
+ while i < n and phrase[i].isspace():
40
+ i += 1
41
+ continue
42
+ if c == "[":
43
+ j = phrase.index("]", i)
44
+ token = phrase[i : j + 1]
45
+ i = j + 1
46
+ elif c == ".":
47
+ if out and out[-1] == r"\s*":
48
+ out.pop()
49
+ out.append(r"\.?")
50
+ i += 1
51
+ continue
52
+ elif c == ":":
53
+ if out and out[-1] == r"\s*":
54
+ out.pop()
55
+ out.append(r":?")
56
+ i += 1
57
+ continue
58
+ elif c == "?":
59
+ i += 1
60
+ continue
61
+ else:
62
+ token = c
63
+ i += 1
64
+ if out and not out[-1].endswith(r"\s+"):
65
+ out.append(r"\s*")
66
+ out.append(token)
67
+ return "".join(out)
68
+
69
+ def get_file_hash(filepath: Path) -> str:
70
+ hasher = hashlib.md5()
71
+ with open(filepath, 'rb') as f:
72
+ buf = f.read()
73
+ hasher.update(buf)
74
+ return hasher.hexdigest()
75
+
76
+ def _extract_drug_id_from_filename(filepath: Path) -> Optional[str]:
77
+ """Dosya adından ilaç adını çeker. Format: {İLAÇ ADI}__{random}.pdf"""
78
+ stem = filepath.stem
79
+ if '__' in stem:
80
+ part = stem.split('__')[0].strip()
81
+ part = re.sub(r'\s+', ' ', part)
82
+ if part:
83
+ return part
84
+ return None
85
+
86
+ def extract_drug_id(doc_path: Path, first_page_text: str) -> str:
87
+ # 1. Ana yöntem: Dosya adından çek (ilaç adı __ ayracından önceki kısım)
88
+ drug_name = _extract_drug_id_from_filename(doc_path)
89
+ if drug_name:
90
+ logger.info(f"Dosya adından tespit edildi: {doc_path.name} → {drug_name}")
91
+ return drug_name
92
+
93
+ # 2. KULLANMA TALİMATI başlığından sonra ilaç adını topla
94
+ lines = first_page_text.split('\n')
95
+ start_collecting = False
96
+ drug_name_lines = []
97
+
98
+ stop_prefixes = [
99
+ "ağız", "oral", "deri", "kas", "damar",
100
+ "etkin madde", "yardımcı madde",
101
+ "ağızdan", "kas içine", "damar içine",
102
+ "cilt üzerine", "deri altına",
103
+ "bu kullanma talimatında", "kullanmadan önce"
104
+ ]
105
+
106
+ for line in lines:
107
+ clean_line = line.strip()
108
+
109
+ if not start_collecting:
110
+ if "KULLANMA TALİMATI" in clean_line.upper():
111
+ start_collecting = True
112
+ continue
113
+
114
+ if not clean_line:
115
+ continue
116
+
117
+ lower_line = clean_line.lower().lstrip("•.-* ")
118
+
119
+ if any(lower_line.startswith(prefix) for prefix in stop_prefixes):
120
+ break
121
+
122
+ drug_name_lines.append(clean_line)
123
+
124
+ if drug_name_lines:
125
+ result = " ".join(drug_name_lines).replace("®", "").strip()
126
+ logger.warning(f"KULLANMA TALİMATI yöntemi kullanıldı: {doc_path.name} → {result}")
127
+ return result
128
+
129
+ # 3. Regex: İlk sayfada "mg", "tablet", "kapsül" vb. içeren satırları ara
130
+ drug_pattern = re.compile(
131
+ r'^(.+(?:mg|mcg|mikrogram|ml|IU).+(?:tablet|kapsül|kapsul|film|şurup|surup|jel|krem|damla|flakon|süspansiyon|suspansiyon|sprey|ampul|enjektabl).*?)$',
132
+ re.IGNORECASE | re.MULTILINE
133
+ )
134
+ match = drug_pattern.search(first_page_text[:1000])
135
+ if match:
136
+ result = match.group(1).replace("®", "").strip()
137
+ logger.warning(f"Regex yöntemi kullanıldı: {doc_path.name} → {result}")
138
+ return result
139
+
140
+ # 4. Tespit edilemedi — None döndür, process_pdfs atlayacak
141
+ logger.warning(f"İlaç adı tespit edilemedi, atlanıyor: {doc_path.name}")
142
+ return None
143
+
144
+ def split_kt_by_sections(text: str, drug_id: str, file_hash: str) -> "list[Document]":
145
+ # Başlıkları yakalayacak esnek regex desenleri
146
+ patterns = {
147
+ "1. İlaç nedir ve ne için kullanılır?": r"(?m)^\s*1\.\s+(?!\").*nedir\s+ve\s+ne\s+için\s+kullanılır[^)\"]*$",
148
+ "2. Kullanmadan önce dikkat edilmesi gerekenler": r"(?m)^\s*2\.\s+(?!\").*kullanmadan\s+önce\s+dikkat\s+edilmesi\s+gerekenler[^)\"]*$",
149
+ "3. Nasıl kullanılır?": r"(?m)^\s*3\.\s+(?!\").*nasıl\s+kullanılır[^)\"]*$",
150
+ "4. Olası yan etkiler nelerdir?": r"(?m)^\s*4\.\s+(?!\").*olası\s+yan\s+etkiler[^)\"]*$",
151
+ "5. Saklama koşulları": r"(?m)^\s*5\.\s+(?!\").*saklanması[^)\"]*$"
152
+ }
153
+
154
+ matches = []
155
+ for section_name, pattern in patterns.items():
156
+ # İlk eşleşmeyi bul
157
+ match = re.search(pattern, text, re.IGNORECASE)
158
+ if match:
159
+ matches.append({"name": section_name, "start": match.start()})
160
+
161
+ # Başlangıç indeksine göre sırala
162
+ matches.sort(key=lambda x: x["start"])
163
+
164
+ sections = []
165
+ if not matches:
166
+ # Hiç başlık bulunamazsa tüm metni tek bir genel bölüm olarak al
167
+ sections.append({"name": "Genel Bilgiler", "content": text.strip()})
168
+ else:
169
+ # Bulunan bölümleri ayır
170
+ for i in range(len(matches)):
171
+ # İlk başlıktan önceki metni (prelude) giriş bölümü yapmak yerine ilk bölümün başına dahil ediyoruz
172
+ start_index = 0 if i == 0 else matches[i]["start"]
173
+ end_index = matches[i+1]["start"] if i + 1 < len(matches) else len(text)
174
+ sections.append({
175
+ "name": matches[i]["name"],
176
+ "content": text[start_index:end_index].strip()
177
+ })
178
+
179
+ text_splitter = RecursiveCharacterTextSplitter(
180
+ chunk_size=1800,
181
+ chunk_overlap=300,
182
+ separators=["\n\n", "\n", ". ", "! ", "? ", " ", ""]
183
+ )
184
+
185
+ docs = []
186
+ for sec in sections:
187
+ chunks = text_splitter.split_text(sec["content"])
188
+ for chunk in chunks:
189
+ # RAG performansını artırmak için ilaç adını bölüm başlığının başına ekliyoruz
190
+ chunk_text = f"[{drug_id} - {sec['name']}]\n\n{chunk}"
191
+ docs.append(Document(
192
+ page_content=chunk_text,
193
+ metadata={
194
+ "drug_id": drug_id,
195
+ "section": sec["name"],
196
+ "file_hash": file_hash
197
+ }
198
+ ))
199
+
200
+ return docs
201
+
202
+ def _generate_drugs_list(db):
203
+ """ChromaDB'den unique drug_id'leri çekip drugs_list.txt'ye yazar."""
204
+ try:
205
+ collection = db._collection
206
+ results = collection.get(include=["metadatas"])
207
+ drug_ids = set()
208
+ for meta in results["metadatas"]:
209
+ did = meta.get("drug_id", "")
210
+ if did and did != "SKIP":
211
+ drug_ids.add(did)
212
+
213
+ sorted_drugs = sorted(drug_ids, key=lambda x: x.lower())
214
+ with open("drugs_list.txt", "w", encoding="utf-8") as f:
215
+ for drug in sorted_drugs:
216
+ f.write(f"{drug}\n")
217
+ logger.info(f"drugs_list.txt güncellendi: {len(sorted_drugs)} ilaç")
218
+ except Exception as e:
219
+ logger.error(f"drugs_list.txt oluşturma hatası: {e}")
220
+
221
+ def process_pdfs(pdf_dir: str, mode: str):
222
+ import time
223
+
224
+ pdf_dir_path = Path(pdf_dir)
225
+ manifest = {}
226
+ if os.path.exists(MANIFEST_PATH):
227
+ with open(MANIFEST_PATH, "r") as f:
228
+ manifest = json.load(f)
229
+
230
+ db = Chroma(persist_directory=CHROMA_DB_DIR, embedding_function=JinaEmbeddings(jina_api_key=os.environ.get("JINA_API_KEY"), model_name="jina-embeddings-v3"))
231
+
232
+ for filepath in pdf_dir_path.glob("*.pdf"):
233
+ file_hash = get_file_hash(filepath)
234
+ old_hash = manifest.get(str(filepath))
235
+
236
+ if mode == "incremental" and old_hash == file_hash:
237
+ logger.debug(f"Atlanıyor (değişiklik yok): {filepath.name}")
238
+ continue
239
+
240
+ logger.info(f"İşleniyor: {filepath.name}")
241
+ loader = PyPDFLoader(str(filepath))
242
+ docs = loader.load()
243
+ if not docs:
244
+ logger.warning(f"Boş PDF: {filepath.name}")
245
+ continue
246
+
247
+ # Tüm sayfalarda temizleme — _loose sayesinde PDF'den gelen
248
+ # 'Ku llanm a Talim atında' gibi kelime-içi boşluklara toleranslı.
249
+ p1 = re.compile(
250
+ r"(?:" + _loose("bu ilac[ıi] kullanmaya ba[şs]lamadan [öo]nce") + r"\s+)?" +
251
+ _loose("bu kullanma tal[iı]mat[ıi]n[ıi]") +
252
+ r".*?" +
253
+ _loose("y[üu]ksek veya d[üu][şs][üu]k doz kullanmay[ıi]n[ıi]z."),
254
+ re.IGNORECASE | re.DOTALL
255
+ )
256
+ p2 = re.compile(
257
+ _loose("bu kullanma tal[iı]mat[ıi]nda:") +
258
+ r".*?" +
259
+ _loose("ba[şs]l[ıi]klar[ıi] yer almaktad[ıi]r."),
260
+ re.IGNORECASE | re.DOTALL
261
+ )
262
+ try:
263
+ for doc in docs:
264
+ cleaned = p1.sub("", doc.page_content)
265
+ cleaned = p2.sub("", cleaned)
266
+ cleaned = re.sub(r'^\s*\d+\s*$', '', cleaned, flags=re.MULTILINE)
267
+ cleaned = re.sub(r'^\s*\d+\s*/\s*\d+\s*$', '', cleaned, flags=re.MULTILINE)
268
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
269
+ doc.page_content = cleaned.strip()
270
+ except Exception as e:
271
+ logger.error(f"İçerik temizleme hatası ({filepath.name}): {e}")
272
+
273
+ drug_id = extract_drug_id(filepath, docs[0].page_content)
274
+
275
+ if drug_id is None:
276
+ continue
277
+
278
+ logger.info(f"İlaç tespit edildi: {drug_id}")
279
+
280
+ full_text = "\n".join(doc.page_content for doc in docs)
281
+
282
+ chunks = split_kt_by_sections(full_text, drug_id, file_hash)
283
+ logger.info(f"Chunk sayısı: {len(chunks)}")
284
+
285
+ # Eski chunk'ları sil (stale data önleme)
286
+ if old_hash:
287
+ try:
288
+ db._collection.delete(where={"file_hash": old_hash})
289
+ logger.info(f"Eski chunk'lar silindi (hash: {old_hash[:12]}...)")
290
+ except Exception as e:
291
+ logger.warning(f"Eski chunk silme hatası: {e}")
292
+
293
+ batch_size = 50
294
+ for i in range(0, len(chunks), batch_size):
295
+ batch = chunks[i:i+batch_size]
296
+ try:
297
+ db.add_documents(batch)
298
+ time.sleep(2)
299
+ except Exception as e:
300
+ logger.error(f"Embedding hatası (bekleniyor...): {e}")
301
+ time.sleep(10)
302
+ try:
303
+ db.add_documents(batch)
304
+ except Exception as inner_e:
305
+ logger.error(f"Retry başarısız, atlanıyor: {inner_e}")
306
+
307
+ manifest[str(filepath)] = file_hash
308
+
309
+ with open(MANIFEST_PATH, "w") as f:
310
+ json.dump(manifest, f)
311
+
312
+ # İlaç listesini ChromaDB'den otomatik oluştur
313
+ _generate_drugs_list(db)
314
+
315
+ logger.info("Ingestion tamamlandı.")
316
+
317
+ if __name__ == "__main__":
318
+ parser = argparse.ArgumentParser()
319
+ parser.add_argument("--pdf-dir", type=str, required=True)
320
+ parser.add_argument("--mode", type=str, choices=["incremental", "full"], default="full", help="Ingestion mode: 'incremental' to only process changed files, 'full' to reprocess all files")
321
+ args = parser.parse_args()
322
+ process_pdfs(args.pdf_dir, args.mode)
app/logger.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ from logging.handlers import RotatingFileHandler
4
+
5
+ LOG_DIR = "./logs"
6
+
7
+ def get_logger(name: str) -> logging.Logger:
8
+ os.makedirs(LOG_DIR, exist_ok=True)
9
+
10
+ logger = logging.getLogger(name)
11
+
12
+ if logger.handlers:
13
+ return logger
14
+
15
+ logger.setLevel(logging.DEBUG)
16
+
17
+ # Dosya handler — 5MB, max 3 dosya
18
+ file_handler = RotatingFileHandler(
19
+ os.path.join(LOG_DIR, f"{name}.log"),
20
+ maxBytes=5 * 1024 * 1024,
21
+ backupCount=3,
22
+ encoding="utf-8"
23
+ )
24
+ file_handler.setLevel(logging.DEBUG)
25
+ file_fmt = logging.Formatter("%(asctime)s | %(levelname)-8s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
26
+ file_handler.setFormatter(file_fmt)
27
+
28
+ # Console handler
29
+ console_handler = logging.StreamHandler()
30
+ console_handler.setLevel(logging.INFO)
31
+ console_fmt = logging.Formatter("%(levelname)-8s | %(message)s")
32
+ console_handler.setFormatter(console_fmt)
33
+
34
+ logger.addHandler(file_handler)
35
+ logger.addHandler(console_handler)
36
+
37
+ return logger
38
+
39
+
40
+ def get_jsonl_logger(name: str) -> logging.Logger:
41
+ """Returns a logger that writes raw JSON lines (no prefix) to logs/{name}.jsonl.
42
+ Intended for machine-readable per-query traces."""
43
+ os.makedirs(LOG_DIR, exist_ok=True)
44
+
45
+ logger = logging.getLogger(f"jsonl.{name}")
46
+
47
+ if logger.handlers:
48
+ return logger
49
+
50
+ logger.setLevel(logging.INFO)
51
+ logger.propagate = False # do not bubble up to root
52
+
53
+ file_handler = RotatingFileHandler(
54
+ os.path.join(LOG_DIR, f"{name}.jsonl"),
55
+ maxBytes=5 * 1024 * 1024,
56
+ backupCount=3,
57
+ encoding="utf-8"
58
+ )
59
+ file_handler.setLevel(logging.INFO)
60
+ file_handler.setFormatter(logging.Formatter("%(message)s"))
61
+ logger.addHandler(file_handler)
62
+
63
+ return logger
app/retrieval.py ADDED
@@ -0,0 +1,552 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ import time
5
+ from collections import Counter
6
+ from datetime import datetime, timezone
7
+ from typing import Optional
8
+ import requests
9
+ from langchain_chroma import Chroma
10
+ from langchain_community.embeddings import JinaEmbeddings
11
+ from langchain_google_genai import ChatGoogleGenerativeAI
12
+ from langchain_core.prompts import PromptTemplate
13
+ from langchain_core.output_parsers import StrOutputParser
14
+ from dotenv import load_dotenv
15
+ from app.logger import get_logger, get_jsonl_logger
16
+
17
+ load_dotenv()
18
+
19
+ logger = get_logger("retrieval")
20
+ query_logger = get_jsonl_logger("queries")
21
+
22
+ CHROMA_DB_DIR = "./chroma_db"
23
+
24
+ # ── Sabitler ────────────────────────────────────────────────────────────
25
+ MAX_HISTORY_TURNS = 3
26
+ LOW_CONFIDENCE_THRESHOLD = 0.2
27
+ REFUSAL_MESSAGE = "Bu konu hakkında elimdeki kaynaklarda yeterli bilgi bulunmuyor."
28
+ DISCLAIMER = "\n\n---\n*Bu bilgi genel bilgilendirme amaçlıdır. İlacı kullanmadan önce mutlaka doktorunuza veya eczacınıza danışın.*"
29
+ DISCLAIMER_MARKER = "doktorunuza veya eczacınıza danışın"
30
+
31
+ # Global objeler: RAG sistemi ve LLM her çağrıda yeniden oluşturulmaz (Performans Artışı)
32
+ db = Chroma(persist_directory=CHROMA_DB_DIR, embedding_function=JinaEmbeddings(jina_api_key=os.environ.get("JINA_API_KEY"), model_name="jina-embeddings-v3"))
33
+
34
+
35
+ def _load_drug_ids() -> list[str]:
36
+ """Chroma'daki benzersiz drug_id'leri döndürür (uzun ad önce sıralı,
37
+ böylece 'abizol 10 mg' eşleşmesi 'abizol'den önce denenir)."""
38
+ try:
39
+ metas = db._collection.get(include=["metadatas"])["metadatas"]
40
+ ids = {m.get("drug_id", "") for m in metas if m.get("drug_id")}
41
+ ids.discard("SKIP")
42
+ return sorted(ids, key=lambda s: (-len(s), s.lower()))
43
+ except Exception as e:
44
+ logger.warning(f"drug_id listesi yüklenemedi: {e}")
45
+ return []
46
+
47
+
48
+ DRUG_IDS = _load_drug_ids()
49
+ logger.info(f"Metadata filtering için {len(DRUG_IDS)} drug_id yüklendi")
50
+
51
+
52
+ def _normalize(s: str) -> str:
53
+ """Türkçe karakter-duyarsız, noktalama-sız eşleştirme için normalize.
54
+ ÖNEMLİ: Python'da 'İ'.lower() = 'i\\u0307' (iki karakter) olduğundan,
55
+ Türkçe karakter mapping'i .lower()'dan ÖNCE yapılmalı."""
56
+ tr = str.maketrans("ıİşŞğĞüÜöÖçÇ", "iissgguuoocc")
57
+ s = s.translate(tr).lower()
58
+ return re.sub(r"[^a-z0-9\s]", " ", s)
59
+
60
+
61
+ # ── Reranking ────────────────────────────────────────────────────────────
62
+ # Vektör araması (embedding + cosine similarity) hızlıdır ama kaba bir
63
+ # sıralama verir: anlamsal olarak yakın ama soruya tam cevap vermeyen
64
+ # chunk'lar üst sıralara çıkabilir. Reranker, (query, chunk) çiftlerini
65
+ # tek tek değerlendiren bir cross-encoder modelidir ve çok daha isabetli
66
+ # sıralama üretir. Akış: similarity_search ile top-N aday al (örn. 20) →
67
+ # Jina Reranker API'ye gönder → modelin skorlamasına göre en alakalı
68
+ # top_n chunk'ı LLM'e ver. Böylece "doğru ilaç + doğru bölüm" isabeti
69
+ # belirgin şekilde artar; karşılığında ~200-500 ms ek latency ve API
70
+ # çağrısı maliyeti gelir.
71
+ JINA_RERANK_URL = "https://api.jina.ai/v1/rerank"
72
+ JINA_RERANK_MODEL = "jina-reranker-v2-base-multilingual"
73
+
74
+
75
+ def rerank_jina_with_scores(query: str, docs: list, top_n: int = 5) -> tuple[list, list[float]]:
76
+ """Aday chunk'ları Jina Reranker v2 (multilingual) ile yeniden sıralar
77
+ ve relevance_score'larıyla birlikte döndürür. API hatasında orijinal
78
+ sıralamanın ilk top_n'ini boş skor listesiyle döndürür."""
79
+ if not docs:
80
+ return docs, []
81
+ api_key = os.environ.get("JINA_API_KEY")
82
+ if not api_key:
83
+ logger.warning("JINA_API_KEY yok, rerank atlandı")
84
+ return docs[:top_n], []
85
+ resp = requests.post(
86
+ JINA_RERANK_URL,
87
+ headers={
88
+ "Authorization": f"Bearer {api_key}",
89
+ "Content-Type": "application/json",
90
+ },
91
+ json={
92
+ "model": JINA_RERANK_MODEL,
93
+ "query": query,
94
+ "documents": [d.page_content for d in docs],
95
+ "top_n": top_n,
96
+ },
97
+ timeout=15,
98
+ )
99
+ resp.raise_for_status()
100
+ results = resp.json().get("results", [])
101
+ reranked = [docs[r["index"]] for r in results]
102
+ scores = [float(r.get("relevance_score", 0.0)) for r in results]
103
+ logger.info(f"Rerank: {len(docs)} aday → {len(reranked)} chunk")
104
+ return reranked, scores
105
+
106
+
107
+ def rerank_jina(query: str, docs: list, top_n: int = 5) -> list:
108
+ """Geriye dönük imza. Hata durumunda orijinal sıralama döner."""
109
+ try:
110
+ reranked, _ = rerank_jina_with_scores(query, docs, top_n)
111
+ return reranked
112
+ except Exception as e:
113
+ logger.warning(f"Rerank hatası, orijinal sıralama kullanılıyor: {e}")
114
+ return docs[:top_n]
115
+
116
+
117
+ def detect_drug_id(query: str) -> Optional[str]:
118
+ """Sorguda geçen ilk (en uzun) drug_id'yi bulur. İsim tüm tokenları
119
+ sorguda geçiyorsa eşleşme sayılır."""
120
+ q_norm = _normalize(query)
121
+ q_tokens = set(q_norm.split())
122
+ for did in DRUG_IDS:
123
+ d_tokens = _normalize(did).split()
124
+ if d_tokens and all(t in q_tokens for t in d_tokens):
125
+ return did
126
+ # fallback: ilk token (marka adı) yeterli
127
+ for did in DRUG_IDS:
128
+ brand = _normalize(did).split()[0] if did else ""
129
+ if brand and brand in q_tokens:
130
+ return did
131
+ return None
132
+
133
+
134
+ # ── Multi-turn yardımcıları ─────────────────────────────────────────────
135
+
136
+ def build_history_block(history: list, max_turns: int = MAX_HISTORY_TURNS) -> str:
137
+ """Gradio history'sini (user, assistant) tuple listesinden son N turluk düz metin
138
+ bloğuna çevirir. Boşsa "" döner. Gradio bazı sürümlerde dict list de verebilir,
139
+ bu durum da desteklenir."""
140
+ if not history:
141
+ return ""
142
+ recent = history[-max_turns:]
143
+ lines = []
144
+ for turn in recent:
145
+ user_msg, assistant_msg = _extract_turn(turn)
146
+ if user_msg:
147
+ lines.append(f"Kullanıcı: {user_msg.strip()}")
148
+ if assistant_msg:
149
+ # Uzun geçmiş cevaplarını kırp — rewriter'ın bağlam penceresini şişirmesin
150
+ trimmed = assistant_msg.strip()
151
+ if len(trimmed) > 500:
152
+ trimmed = trimmed[:500] + "…"
153
+ lines.append(f"Asistan: {trimmed}")
154
+ return "\n".join(lines)
155
+
156
+
157
+ def _extract_turn(turn) -> tuple[str, str]:
158
+ """Hem (user, assistant) tuple hem de {'role','content'} dict pair formatını destekler."""
159
+ if isinstance(turn, (list, tuple)) and len(turn) == 2:
160
+ return (turn[0] or ""), (turn[1] or "")
161
+ if isinstance(turn, dict):
162
+ role = turn.get("role", "")
163
+ content = turn.get("content", "") or ""
164
+ if role == "user":
165
+ return content, ""
166
+ if role == "assistant":
167
+ return "", content
168
+ return "", ""
169
+
170
+
171
+ # ── Prompts ─────────────────────────────────────────────────────────────
172
+
173
+ REWRITER_PROMPT = PromptTemplate.from_template("""Aşağıda bir sohbet geçmişi ve kullanıcının son mesajı var. Görevin: son mesajı, tek başına anlaşılır ve arama motoruna verilebilecek bağımsız bir Türkçe soruya dönüştürmek.
174
+
175
+ KURALLAR:
176
+ 1. Son mesajda bir ilaç adı geçiyorsa onu koru; rastgele başka bir ilaç ekleme.
177
+ 2. Son mesajda ilaç adı geçmiyor ama geçmişte bir ilaç konuşulduysa VE son mesaj o ilacın bir özelliğini (yan etki, doz, etkileşim, hamilelik, yaş, saklama vb.) soran bir takip sorusuysa, o ilacın adını soruya ekle. "bu", "bu ilaç", "o", "o ilaç", "bunun", "onun", "ondan" gibi işaret zamirlerini geçmişteki ilacın adıyla DEĞİŞTİR (sadece ilaç adını eklemekle kalma, zamiri çıkar).
178
+ 3. "Bunlar", "bunlardan biri", "ikisi", "diğeri" gibi önceki cevaba atıf yapan ifadeleri, geçmişteki asistan cevabından ilgili konuya (yan etki, uyarı, kullanım vb.) çözerek yaz.
179
+ 4. Son mesaj farklı bir hastalık / durum / şikayet için ilaç ÖNERİSİ sorduğu bağımsız bir soruysa ("X için hangi ilaç", "X tedavisinde hangi ilaçlar kullanılır", "X durumunda ne alınmalı", "X olduğunda hangi ilaç"), geçmişteki ilacı SORUYA EKLEME — bu sorgu önceki ilacın bir özelliği değildir, yeni bir konudur. Soruyu olduğu gibi bırak.
180
+ 5. Son mesajda YENİ bir ilaç adı geçiyorsa normalde geçmişteki ilacı yok say ve yeni ilaçla devam et.
181
+ 6. ANCAK son mesaj karşılaştırma ifadesi içeriyorsa ("fark", "farkı", "farkı nedir", "arasındaki", "kıyasla", "göre", "hangisi", "hangisi daha"), hem geçmişteki ilacı hem yeni ilacı KORU ve karşılaştırma sorusunu bozmadan yaz.
182
+ 7. Hiçbir yerde ilaç adı yoksa ya da mesaj selamlaşma / teşekkür / onay ifadesi ise ("merhaba", "selam", "teşekkürler", "tamam", "anladım", "sağol"), soruyu/ifadeyi aynen aktar; zorla ilaç adı ekleme.
183
+ 8. Sadece yeniden yazılmış soruyu tek satır olarak döndür. Açıklama, başlık, tırnak işareti, ön-ek ekleme.
184
+
185
+ ÖRNEKLER:
186
+
187
+ Örnek 1 (takip sorusu — Kural 2):
188
+ SOHBET GEÇMİŞİ:
189
+ Kullanıcı: Parol ne için kullanılır?
190
+ Asistan: Ağrı ve ateş düşürücü olarak kullanılır.
191
+ SON MESAJ: Yan etkileri neler?
192
+ YENİDEN YAZILMIŞ SORU: Parol'ün yan etkileri nelerdir?
193
+
194
+ Örnek 2 (zamir çözümü — Kural 2):
195
+ SOHBET GEÇMİŞİ:
196
+ Kullanıcı: Majezik hakkında bilgi ver.
197
+ Asistan: Majezik bir ağrı kesicidir...
198
+ SON MESAJ: Bu ilaç hamilelikte kullanılabilir mi?
199
+ YENİDEN YAZILMIŞ SORU: Majezik hamilelikte kullanılabilir mi?
200
+
201
+ Örnek 3 (konu değişikliği — Kural 5):
202
+ SOHBET GEÇMİŞİ:
203
+ Kullanıcı: Parol hamilelikte kullanılır mı?
204
+ Asistan: Doktor kontrolünde kullan��labilir.
205
+ SON MESAJ: Peki Majezik?
206
+ YENİDEN YAZILMIŞ SORU: Majezik hamilelikte kullanılır mı?
207
+
208
+ Örnek 4 (yeni medikal konu — Kural 4, KRİTİK):
209
+ SOHBET GEÇMİŞİ:
210
+ Kullanıcı: COVADRİN hangi ilaçlarla birlikte kullanılmaz?
211
+ Asistan: COVADRİN MAO inhibitörleri ve antidepresanlarla birlikte kullanılmamalıdır.
212
+ SON MESAJ: El ve ayak tırnaklarındaki mantar enfeksiyonlarının tedavisinde hangi ilaçlar kullanılabilir?
213
+ YENİDEN YAZILMIŞ SORU: El ve ayak tırnaklarındaki mantar enfeksiyonlarının tedavisinde hangi ilaçlar kullanılabilir?
214
+
215
+ Örnek 5 (karşılaştırma — Kural 6, KRİTİK):
216
+ SOHBET GEÇMİŞİ:
217
+ Kullanıcı: Parol yan etkileri nelerdir?
218
+ Asistan: Mide bulantısı, cilt döküntüsü gibi yan etkiler olabilir.
219
+ SON MESAJ: Majezik'ten farkı nedir?
220
+ YENİDEN YAZILMIŞ SORU: Parol ile Majezik arasındaki fark nedir?
221
+
222
+ Örnek 6 (sohbet ifadesi — Kural 7):
223
+ SOHBET GEÇMİŞİ:
224
+ Kullanıcı: Parol ne için kullanılır?
225
+ Asistan: Ağrı ve ateş düşürücü olarak kullanılır.
226
+ SON MESAJ: Teşekkürler, çok faydalı oldu
227
+ YENİDEN YAZILMIŞ SORU: Teşekkürler, çok faydalı oldu
228
+
229
+ Örnek 7 (önceki cevaba atıf — Kural 3):
230
+ SOHBET GEÇMİŞİ:
231
+ Kullanıcı: Parol'ün yan etkileri nelerdir?
232
+ Asistan: Mide bulantısı, cilt döküntüsü, baş ağrısı olabilir.
233
+ SON MESAJ: Bunlardan biri çocuklarda görülürse ne yapmalı?
234
+ YENİDEN YAZILMIŞ SORU: Parol'ün yan etkilerinden biri (mide bulantısı, cilt döküntüsü veya baş ağrısı) çocuklarda görülürse ne yapmalı?
235
+
236
+ Şimdi aşağıdaki son mesajı yeniden yaz:
237
+
238
+ SOHBET GEÇMİŞİ:
239
+ {history}
240
+
241
+ SON MESAJ: {query}
242
+
243
+ YENİDEN YAZILMIŞ SORU:""")
244
+
245
+
246
+ ANSWER_PROMPT = PromptTemplate.from_template("""Sen, Türkiye'de satılan ilaçların resmî "Kullanma Talimatı" (KT) belgelerine dayanarak bilgi veren bir sağlık bilgilendirme asistanısın.
247
+
248
+ KURALLAR:
249
+ 1. Her zaman Türkçe yanıt ver.
250
+ 2. Yalnızca aşağıdaki BAĞLAM bölümünde verilen bilgileri kullan. Bağlamda geçmeyen hiçbir bilgiyi ASLA uydurma, tahmin yürütme veya genel tıp bilgisi ile tamamlama.
251
+ 3. Bağlamda yanıt için yeterli bilgi yoksa sadece "Bilmiyorum." yaz.
252
+ 4. Spesifik doz önerisi verme; kişiye özel teşhis koyma; tedavi başlatma/değiştirme önerme. Kullanıcı doz sorarsa KT'de yazan genel bilgiyi aktar ve "Dozaj kararı için doktor/eczacıya danışılmalıdır" de.
253
+ 5. Kısa, net ve doğrudan cevap ver. Bağlamda olan bilgiyi tekrar etme.
254
+ 6. GEÇMİŞ KONUŞMA'yı yalnızca kullanıcının sorusunu doğru anlamak için kullan; yanıtın içinde geçmişe atıf yapma.
255
+ 7. Yanıtın sonuna doktor/eczacıya danışma hatırlatmasını mutlaka ekle.
256
+
257
+ GEÇMİŞ KONUŞMA:
258
+ {history}
259
+
260
+ BAĞLAM:
261
+ {context}
262
+
263
+ KULLANICININ SORUSU: {question}
264
+
265
+ YANIT:""")
266
+
267
+
268
+ llm = ChatGoogleGenerativeAI(model="gemini-flash-latest", temperature=0)
269
+
270
+ rewriter_chain = REWRITER_PROMPT | llm | StrOutputParser()
271
+ answer_chain = ANSWER_PROMPT | llm | StrOutputParser()
272
+
273
+
274
+ def rewrite_query(raw_query: str, history: list) -> str:
275
+ """Geçmişi kullanarak sorguyu bağımsız bir soruya dönüştürür.
276
+ Geçmiş boş veya hata durumunda orijinal sorguyu döndürür."""
277
+ history_block = build_history_block(history)
278
+ if not history_block:
279
+ return raw_query
280
+ rewritten = rewriter_chain.invoke({"history": history_block, "query": raw_query})
281
+ rewritten = (rewritten or "").strip().strip('"').strip("'")
282
+ # İlk satırı al — model bazen açıklama ekleyebilir
283
+ rewritten = rewritten.split("\n", 1)[0].strip()
284
+ if not rewritten:
285
+ return raw_query
286
+ return rewritten
287
+
288
+
289
+ # ── Kaynak + uyarı yardımcıları ─────────────────────────────────────────
290
+
291
+ def format_sources(docs: list) -> str:
292
+ """Kullanılan chunk'ların bölüm + ilaç bilgisini sade liste halinde döndürür.
293
+ Aynı (bölüm, ilaç) tekrarları teke indirir."""
294
+ seen = set()
295
+ lines = []
296
+ for doc in docs:
297
+ section = doc.metadata.get("section", "Bilinmiyor")
298
+ drug = doc.metadata.get("drug_id", "Bilinmiyor")
299
+ key = (section, drug)
300
+ if key in seen:
301
+ continue
302
+ seen.add(key)
303
+ lines.append(f"- {section} — {drug}")
304
+ return "\n".join(lines)
305
+
306
+
307
+ def append_disclaimer(answer: str) -> str:
308
+ """Doktor/eczacı uyarısını garanti altına alır."""
309
+ if DISCLAIMER_MARKER in answer:
310
+ return answer
311
+ return answer.rstrip() + DISCLAIMER
312
+
313
+
314
+ def _is_bilmiyorum(text: str) -> bool:
315
+ return bool(re.fullmatch(
316
+ r'(?i)^[^\w]*(üzgünüm|maalesef|hayır)?[^\w]*bilmiyorum[^\w]*$',
317
+ text.strip()
318
+ ))
319
+
320
+
321
+ def _is_quota_error(exc: Exception) -> bool:
322
+ """Google Gemini (veya benzeri) kota / rate-limit hatalarını tespit eder."""
323
+ msg = str(exc).lower()
324
+ return any(tok in msg for tok in (
325
+ "429",
326
+ "quota",
327
+ "resourceexhausted",
328
+ "resource_exhausted",
329
+ "rate limit",
330
+ "rate_limit",
331
+ "exceeded",
332
+ ))
333
+
334
+
335
+ QUOTA_MESSAGE = (
336
+ "⚠️ **Servis geçici olarak yanıt veremiyor.**\n\n"
337
+ "Yapay zekâ modeli için kullanım kotası şu anda dolmuş görünüyor. "
338
+ "Lütfen birkaç dakika bekledikten sonra tekrar deneyin. "
339
+ "Sorun devam ederse günlük limit dolmuş olabilir; bu durumda 24 saat içinde otomatik olarak yenilenecektir."
340
+ )
341
+
342
+
343
+ def _build_chunks_debug_string(docs: list) -> str:
344
+ out = ""
345
+ for i, doc in enumerate(docs):
346
+ section_name = doc.metadata.get("section", "Bilinmiyor")
347
+ out += f"**Parça {i+1} ({section_name}):**\n```text\n{doc.page_content}\n```\n\n"
348
+ return out
349
+
350
+
351
+ def _log_candidates_detail(candidates: list, distances: list[float]) -> None:
352
+ """Retrieval'dan gelen aday chunk'ları (rerank öncesi) retrieval.log'a yazar.
353
+ Distance: ChromaDB cosine distance (düşük değer = daha yakın eşleşme)."""
354
+ lines = ["", "═" * 70, f"RETRIEVAL ADAYLARI (RERANK ÖNCESİ) — {len(candidates)} chunk", "═" * 70]
355
+ for i, doc in enumerate(candidates):
356
+ dist = distances[i] if i < len(distances) else None
357
+ dist_str = f"{dist:.4f}" if dist is not None else "N/A"
358
+ lines.append(f"\n┌─ #{i+1} distance={dist_str}")
359
+ lines.append(f"│ metadata: {doc.metadata}")
360
+ lines.append(f"├─ content ({len(doc.page_content)} karakter)")
361
+ lines.append(doc.page_content)
362
+ lines.append("└" + "─" * 69)
363
+ lines.append("═" * 70)
364
+ logger.info("\n".join(lines))
365
+
366
+
367
+ def _log_chunks_detail(docs: list, scores: list[float]) -> None:
368
+ """Rerank sonrası seçilen chunk'ların tam detayını retrieval.log'a yazar.
369
+ Her chunk için: sıra, skor, tüm metadata, tam metin."""
370
+ lines = ["", "═" * 70, "RERANK SONRASI SEÇİLEN CHUNK'LAR", "═" * 70]
371
+ for i, doc in enumerate(docs):
372
+ score = scores[i] if i < len(scores) else None
373
+ score_str = f"{score:.4f}" if score is not None else "N/A"
374
+ lines.append(f"\n┌─ #{i+1} score={score_str}")
375
+ lines.append(f"│ metadata: {doc.metadata}")
376
+ lines.append(f"├─ content ({len(doc.page_content)} karakter)")
377
+ lines.append(doc.page_content)
378
+ lines.append("└" + "─" * 69)
379
+ lines.append("═" * 70)
380
+ logger.info("\n".join(lines))
381
+
382
+
383
+ def _log_query_json(payload: dict) -> None:
384
+ try:
385
+ query_logger.info(json.dumps(payload, ensure_ascii=False))
386
+ except Exception as e:
387
+ logger.warning(f"JSONL log hatası: {e}")
388
+
389
+
390
+ # ── Ana akış ────────────────────────────────────────────────────────────
391
+
392
+ def get_answer(query: str, history: list = None) -> tuple[str, str, str]:
393
+ t_total = time.perf_counter()
394
+ history = history or []
395
+ flags: list[str] = []
396
+ logger.info(f"Sorgu: {query}")
397
+
398
+ # 1) Query rewriting
399
+ t = time.perf_counter()
400
+ try:
401
+ rewritten = rewrite_query(query, history) if history else query
402
+ except Exception as e:
403
+ rewritten = query
404
+ flags.append("rewrite_failed")
405
+ logger.warning(f"Rewrite hatası: {e}")
406
+ if _is_quota_error(e):
407
+ flags.append("quota_exhausted")
408
+ t_rewrite_ms = (time.perf_counter() - t) * 1000
409
+ final = append_disclaimer(QUOTA_MESSAGE)
410
+ _emit_log(query, rewritten, None, [], [], final, flags,
411
+ t_rewrite_ms, 0.0, 0.0, 0.0, t_total)
412
+ return final, "Tespit edilemedi", ""
413
+ t_rewrite_ms = (time.perf_counter() - t) * 1000
414
+ if rewritten != query:
415
+ logger.info(f"Yeniden yazılmış sorgu: {rewritten}")
416
+
417
+ # 2) Drug detect (yeniden yazılmış sorgu üzerinde)
418
+ detected = detect_drug_id(rewritten)
419
+
420
+ # 3) Retrieval
421
+ t = time.perf_counter()
422
+ search_kwargs: dict = {"k": 20}
423
+ if detected:
424
+ search_kwargs["filter"] = {"drug_id": detected}
425
+ logger.info(f"Metadata filtresi uygulandı: drug_id={detected!r}")
426
+ else:
427
+ logger.info("Sorguda ilaç tespit edilemedi, filtre uygulanmadı")
428
+ try:
429
+ candidates_with_scores = db.similarity_search_with_score(rewritten, **search_kwargs)
430
+ candidates = [d for d, _ in candidates_with_scores]
431
+ candidate_distances = [float(s) for _, s in candidates_with_scores]
432
+ except Exception as e:
433
+ t_retrieval_ms = (time.perf_counter() - t) * 1000
434
+ flags.append("retrieval_failed")
435
+ logger.error(f"Retrieval hatası (embedding/DB erişimi başarısız): {e}")
436
+ final = append_disclaimer(
437
+ "Şu anda arama servisine erişilemiyor. Lütfen internet bağlantınızı kontrol edip birkaç saniye sonra tekrar deneyin."
438
+ )
439
+ _emit_log(query, rewritten, detected, [], [], final, flags,
440
+ t_rewrite_ms, t_retrieval_ms, 0.0, 0.0, t_total)
441
+ return final, detected or "Tespit edilemedi", ""
442
+ t_retrieval_ms = (time.perf_counter() - t) * 1000
443
+
444
+ logger.info(f"Retrieval: {len(candidates)} aday chunk")
445
+ _log_candidates_detail(candidates, candidate_distances)
446
+
447
+ # 4) Rerank (+ skorlar)
448
+ t = time.perf_counter()
449
+ try:
450
+ docs, scores = rerank_jina_with_scores(rewritten, candidates, top_n=5)
451
+ except Exception as e:
452
+ docs, scores = candidates[:5], []
453
+ flags.append("rerank_failed")
454
+ logger.warning(f"Rerank hatası, orijinal sıralama kullanılıyor: {e}")
455
+ t_rerank_ms = (time.perf_counter() - t) * 1000
456
+
457
+ top_score = max(scores) if scores else 0.0
458
+
459
+ # 5) Güven / boş kontrol
460
+ if not docs or (scores and top_score < LOW_CONFIDENCE_THRESHOLD):
461
+ flags.append("no_docs" if not docs else "low_confidence")
462
+ final = append_disclaimer(REFUSAL_MESSAGE)
463
+ _emit_log(query, rewritten, detected, docs, scores, final, flags,
464
+ t_rewrite_ms, t_retrieval_ms, t_rerank_ms, 0.0, t_total)
465
+ return final, detected or "Tespit edilemedi", ""
466
+
467
+ drug_id = docs[0].metadata.get("drug_id", "Bilinmiyor")
468
+ unique_drugs = Counter(d.metadata.get("drug_id", "Bilinmiyor") for d in docs)
469
+ if len(unique_drugs) > 1:
470
+ logger.warning(
471
+ f"Chunk'lar farklı ilaçlardan geliyor ({len(unique_drugs)} farklı drug_id): "
472
+ f"{dict(unique_drugs)}. docs[0]={drug_id} (rerank'te en alakalı) seçildi."
473
+ )
474
+ logger.info(f"Tespit edilen ilaç: {drug_id} | Döküman sayısı: {len(docs)} | top_score={top_score:.3f}")
475
+
476
+ # Detaylı chunk log'u (retrieval.log'a)
477
+ _log_chunks_detail(docs, scores)
478
+
479
+ # 6) LLM çağrısı
480
+ context = "\n\n".join(d.page_content for d in docs)
481
+ history_block = build_history_block(history) or "(Geçmiş yok)"
482
+
483
+ t = time.perf_counter()
484
+ try:
485
+ raw_answer = answer_chain.invoke({
486
+ "context": context,
487
+ "history": history_block,
488
+ "question": rewritten,
489
+ })
490
+ except Exception as e:
491
+ flags.append("llm_failed")
492
+ logger.error(f"LLM hatası: {e}")
493
+ if _is_quota_error(e):
494
+ flags.append("quota_exhausted")
495
+ t_llm_ms = (time.perf_counter() - t) * 1000
496
+ final = append_disclaimer(QUOTA_MESSAGE)
497
+ _emit_log(query, rewritten, detected, docs, scores, final, flags,
498
+ t_rewrite_ms, t_retrieval_ms, t_rerank_ms, t_llm_ms, t_total)
499
+ return final, detected or "Tespit edilemedi", ""
500
+ raw_answer = "Bilmiyorum."
501
+ t_llm_ms = (time.perf_counter() - t) * 1000
502
+
503
+ # 7) "Bilmiyorum" fail-safe + kaynak bloğu
504
+ if _is_bilmiyorum(raw_answer):
505
+ answer = "Bilmiyorum."
506
+ logger.info("Cevap: Bilmiyorum (fail-safe)")
507
+ else:
508
+ answer = raw_answer.strip() + "\n\n---\n**Kaynaklar:**\n" + format_sources(docs)
509
+
510
+ # 8) Doktor uyarısı — garanti
511
+ final = append_disclaimer(answer)
512
+
513
+ # 9) Log
514
+ _emit_log(query, rewritten, detected, docs, scores, final, flags,
515
+ t_rewrite_ms, t_retrieval_ms, t_rerank_ms, t_llm_ms, t_total)
516
+
517
+ logger.info(
518
+ f"Toplam süre: {(time.perf_counter() - t_total) * 1000:.0f}ms | "
519
+ f"Bağlam: {len(context)} karakter"
520
+ )
521
+
522
+ used_chunks_str = _build_chunks_debug_string(docs)
523
+ return final, drug_id, used_chunks_str
524
+
525
+
526
+ def _emit_log(raw_query, rewritten, detected, docs, scores, final, flags,
527
+ t_rewrite_ms, t_retrieval_ms, t_rerank_ms, t_llm_ms, t_total_start):
528
+ payload = {
529
+ "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
530
+ "raw_query": raw_query,
531
+ "rewritten_query": rewritten,
532
+ "detected_drug": detected,
533
+ "retrieved": [
534
+ {
535
+ "idx": i,
536
+ "rerank_score": round(scores[i], 4) if i < len(scores) else None,
537
+ "metadata": dict(d.metadata),
538
+ "content": d.page_content,
539
+ }
540
+ for i, d in enumerate(docs)
541
+ ],
542
+ "answer_preview": (final or "")[:200],
543
+ "latency_ms": {
544
+ "rewrite": round(t_rewrite_ms, 1),
545
+ "retrieval": round(t_retrieval_ms, 1),
546
+ "rerank": round(t_rerank_ms, 1),
547
+ "llm": round(t_llm_ms, 1),
548
+ "total": round((time.perf_counter() - t_total_start) * 1000, 1),
549
+ },
550
+ "flags": flags,
551
+ }
552
+ _log_query_json(payload)
app/ui.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import gradio as gr
3
+ import dotenv
4
+ from app.retrieval import get_answer, DRUG_IDS, _normalize
5
+
6
+ dotenv.load_dotenv()
7
+
8
+ DRUG_LIST_SORTED = sorted(DRUG_IDS, key=_normalize)
9
+ DRUG_NORMALIZED = [(d, _normalize(d)) for d in DRUG_LIST_SORTED]
10
+ DRUG_COUNT = len(DRUG_LIST_SORTED)
11
+ MAX_LIST_DISPLAY = 100
12
+
13
+
14
+ def filter_drugs(query: str) -> str:
15
+ q = _normalize(query or "").strip()
16
+ if not q:
17
+ shown = DRUG_LIST_SORTED[:MAX_LIST_DISPLAY]
18
+ body = "\n".join(f"- {d}" for d in shown)
19
+ if DRUG_COUNT > MAX_LIST_DISPLAY:
20
+ body += f"\n\n_İlk {len(shown)} ilaç gösteriliyor (toplam {DRUG_COUNT}). Daraltmak için yukarıya yazın._"
21
+ return body
22
+ matches = [d for d, n in DRUG_NORMALIZED if q in n]
23
+ if not matches:
24
+ return f"_Eşleşme bulunamadı: **{query}**_"
25
+ shown = matches[:MAX_LIST_DISPLAY]
26
+ body = "\n".join(f"- {d}" for d in shown)
27
+ if len(matches) > MAX_LIST_DISPLAY:
28
+ body += f"\n\n_{len(shown)} / {len(matches)} sonuç gösteriliyor._"
29
+ else:
30
+ body += f"\n\n_{len(matches)} sonuç_"
31
+ return body
32
+
33
+ def chat_interface(message, history):
34
+ if not message:
35
+ return ""
36
+
37
+ # RAG sistemi 3 parametre dönüyor (Cevap, İlaç ID, Kullanılan Chunklar).
38
+ answer, drug_id, chunks_str = get_answer(message, history)
39
+
40
+ final_response = answer
41
+
42
+ # Text chunklarını Colab hücresinin çıktısına (console) yazdırıyoruz
43
+ """if chunks_str:
44
+ print("\n" + "="*60)
45
+ print(f"🧐 KULLANICI SORUSU: {message}")
46
+ print("-" * 60)
47
+ print(f"📄 MODELE GÖNDERİLEN KAYNAK METİNLER (CHUNKLAR):\n\n{chunks_str}")
48
+ print("="*60 + "\n") """
49
+
50
+ return final_response
51
+
52
+ FORCE_DARK_JS = """
53
+ () => {
54
+ const url = new URL(window.location);
55
+ if (url.searchParams.get('__theme') !== 'dark') {
56
+ url.searchParams.set('__theme', 'dark');
57
+ window.location.href = url.href;
58
+ }
59
+ }
60
+ """
61
+
62
+
63
+ # Module-level demo — `gradio app/ui.py` ile hot-reload için gerekli
64
+ with gr.Blocks(title="İlaç KT Chatbot", theme=gr.themes.Default(), js=FORCE_DARK_JS) as demo:
65
+ gr.Markdown("## İlaç Sohbet Botu RAG Q&A")
66
+
67
+ gr.Markdown("""
68
+ ⚠️ İLAC ADLARINI YAZARKEN DOĞRU ŞEKİLDE YAZIN. BU SAYEDE SİSTEMİN DOĞRU KULLANMA TALİMATI BELGESİNİ BULMASI VE DOĞRU CEVAPLAR VERMESİ DAHA MUHTEMEL OLUR. ÖRNEK SORU: **Parol hamilelikte kullanılır mı?**
69
+
70
+ ⚠️ Bu asistan yalnızca geliştirme amaçlıdır. Her tıbbi karar öncesinde mutlaka doktorunuza veya eczacınıza danışın.
71
+
72
+ """)
73
+
74
+ gr.Markdown(
75
+ f"""📋 Sistemimiz şu anda **{DRUG_COUNT}** TABLET ilacın resmî Kullanma Talimatı (KT) belgesini işliyor.\n
76
+ Kullanılan model gemini-flash-latest free tier olduğu için, günlük istek limiti bulunmakta bu nedenle bazen cevap veremeyebilir."""
77
+ )
78
+
79
+ with gr.Accordion("İşlenen ilaçların tam listesi", open=False):
80
+ drug_search = gr.Textbox(
81
+ placeholder="İlaç ara... (örn: parol)",
82
+ show_label=False,
83
+ container=False,
84
+ )
85
+ drug_list_view = gr.Markdown(filter_drugs(""))
86
+ drug_search.change(fn=filter_drugs, inputs=drug_search, outputs=drug_list_view)
87
+
88
+ gr.ChatInterface(
89
+ fn=chat_interface,
90
+ chatbot=gr.Chatbot(height=400),
91
+ textbox=gr.Textbox(placeholder="İlacın adını belirterek sorunuzu girin... (Örn: Parol hamilelikte kullanılır mı?)", container=False, scale=7),
92
+ title="Sadece İlaç KT PDF'lerine Dayanarak Cevap Veren Asistan",
93
+ )
94
+
95
+
96
+ def main(host, port, share=False):
97
+ demo.launch(server_name=host, server_port=port, share=share)
98
+
99
+ if __name__ == "__main__":
100
+ parser = argparse.ArgumentParser()
101
+ parser.add_argument("--host", type=str, default="0.0.0.0")
102
+ parser.add_argument("--port", type=int, default=7860)
103
+ parser.add_argument("--share", action="store_true", help="Create a public link for Gradio")
104
+ args = parser.parse_args()
105
+ main(args.host, args.port, True)
chroma_db/2ec18670-9ffc-4dd2-954a-ca8c9ffb8344/data_level0.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:acaa68f51810a4200141a4a28fc3f20c2ee36c6808f82334efd3d827b2048087
3
+ size 123839460
chroma_db/2ec18670-9ffc-4dd2-954a-ca8c9ffb8344/header.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:977a57867dce5ff67ee0c97c820f9b3f5ec07c0456268eb503691ce55ebf710e
3
+ size 100
chroma_db/2ec18670-9ffc-4dd2-954a-ca8c9ffb8344/index_metadata.pickle ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb8613de51de13e55ebacb91b30e8b036ba6e371d872021512c1434f27e3d6f3
3
+ size 2689864
chroma_db/2ec18670-9ffc-4dd2-954a-ca8c9ffb8344/length.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:69a54212939283e10aee649f98ef24718e348732a743947580428f9df669876d
3
+ size 116940
chroma_db/2ec18670-9ffc-4dd2-954a-ca8c9ffb8344/link_lists.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e68fb7ca7679131a2afa708f8c52a1fb1330b558cc7038d05acfa442a738f3f8
3
+ size 257020
chroma_db/chroma.sqlite3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1fc93b609ed18e9cdd4f5c52bb2643e76d2ff6c21b8114f22fdc13bb06840b6c
3
+ size 405786624
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ greenlet<3.1; python_version < "3.10"
2
+ langchain
3
+ langchain-core
4
+ langchain-community
5
+ langchain-text-splitters
6
+ langchain-google-genai
7
+ langchain-openai
8
+ langchain-chroma
9
+ chromadb
10
+ gradio
11
+ huggingface_hub<1.0
12
+ pypdf
13
+ tiktoken
14
+ pydantic
15
+ python-dotenv
16
+ unstructured