nlp-project / scripts /demo_app.py
ervua's picture
Show five retrieved sources in Space demo
9137071
Raw
History Blame Contribute Delete
40.5 kB
from __future__ import annotations
import argparse
import html
import io
import json
import math
import re
import time
import warnings
import webbrowser
import zipfile
import xml.etree.ElementTree as ET
from collections import Counter
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs
import email
SAMPLE_QUESTIONS = [
"Kasten öldürme suçunun cezası nedir?",
"Hırsızlık suçunun yasal tanımı ve unsurları nelerdir?",
"Adil yargılanma hakkı nasıl güvence altına alınır?",
"Cumhurbaşkanı seçilebilmek için gerekli şartlar nelerdir?",
"Olağanüstü hal (OHAL) hangi durumlarda ilan edilebilir?",
"Haksız fiil sebebiyle tazminat sorumluluğunun şartları nelerdir?",
"Evlilik birliği temelinden sarsılırsa ne olur?",
]
@dataclass
class Doc:
id: str
title: str
text: str
citation: str
def tokenize(text: str) -> list[str]:
return re.findall(r"\w+", text.lower(), flags=re.UNICODE)
def iter_jsonl(path: Path):
with path.open("r", encoding="utf-8") as f:
for line in f:
if line.strip():
yield json.loads(line)
def resolve_corpus_file(data_dir: Path) -> Path:
candidates = [
data_dir / "real_corpus.jsonl",
data_dir / "corpus_index.jsonl",
data_dir / "corpus.jsonl",
Path("data") / "real_corpus.jsonl",
Path("data") / "corpus_index.jsonl",
Path("data") / "corpus.jsonl",
]
for candidate in candidates:
if candidate.exists():
return candidate
raise FileNotFoundError("No corpus file found.")
def load_docs(corpus_file: Path, limit: int | None = None) -> list[Doc]:
docs: list[Doc] = []
for row in iter_jsonl(corpus_file):
metadata = row.get("metadata") or {}
doc_id = str(row.get("id") or metadata.get("chunk_id") or len(docs))
title = str(row.get("title") or metadata.get("category") or "Legal Source")
text = str(row.get("text") or row.get("content") or "")
if not text.strip():
continue
citation = str(metadata.get("citation_label") or row.get("citation_label") or f"{title} - {doc_id}")
docs.append(Doc(doc_id, title, text, citation))
if limit and len(docs) >= limit:
break
return docs
def extract_docx_text(payload: bytes) -> str:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
xml_bytes = archive.read("word/document.xml")
root = ET.fromstring(xml_bytes)
namespace = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
paragraphs = []
for paragraph in root.findall(".//w:p", namespace):
texts = [node.text or "" for node in paragraph.findall(".//w:t", namespace)]
text = "".join(texts).strip()
if text:
paragraphs.append(text)
return "\n".join(paragraphs)
def extract_pdf_text(payload: bytes) -> str:
try:
from pypdf import PdfReader
except Exception as exc:
raise RuntimeError("PDF destegi icin pypdf kurulmali: pip install pypdf") from exc
reader = PdfReader(io.BytesIO(payload))
pages = []
for idx, page in enumerate(reader.pages, start=1):
page_text = page.extract_text() or ""
if page_text.strip():
pages.append(f"[Page {idx}]\n{page_text.strip()}")
return "\n\n".join(pages)
def extract_uploaded_text(filename: str, payload: bytes) -> str:
suffix = Path(filename).suffix.lower()
if suffix in {".txt", ".md", ".csv", ".json", ".jsonl"}:
return payload.decode("utf-8", errors="ignore")
if suffix == ".docx":
return extract_docx_text(payload)
if suffix == ".pdf":
return extract_pdf_text(payload)
raise ValueError("Desteklenen dosya tipleri: .txt, .md, .csv, .json, .jsonl, .docx, .pdf")
def chunk_uploaded_text(text: str, filename: str, chunk_size: int = 900, overlap: int = 150) -> list[Doc]:
text = re.sub(r"\r\n?", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text).strip()
if not text:
return []
chunks: list[Doc] = []
start = 0
while start < len(text):
end = min(start + chunk_size, len(text))
chunk = text[start:end].strip()
if end < len(text):
split_at = max(chunk.rfind("."), chunk.rfind("?"), chunk.rfind("!"), chunk.rfind("\n"))
if split_at > int(chunk_size * 0.55):
chunk = chunk[: split_at + 1].strip()
end = start + split_at + 1
if chunk:
number = len(chunks) + 1
chunks.append(
Doc(
id=f"UPLOAD_{number:03d}",
title=f"{filename} - chunk {number}",
text=chunk,
citation=f"Uploaded file: {filename} | chunk {number}",
)
)
next_start = end - overlap
if next_start <= start:
next_start = start + chunk_size
start = next_start
return chunks
class SimpleBM25:
def __init__(self, docs: list[Doc]) -> None:
self.docs = docs
self.doc_tokens = [tokenize(f"{doc.title} {doc.text}") for doc in docs]
self.avgdl = sum(len(tokens) for tokens in self.doc_tokens) / max(len(self.doc_tokens), 1)
df: Counter[str] = Counter()
for tokens in self.doc_tokens:
df.update(set(tokens))
n = len(docs)
self.idf = {term: math.log(1 + (n - freq + 0.5) / (freq + 0.5)) for term, freq in df.items()}
def search(self, query: str, top_k: int = 5) -> list[tuple[Doc, float]]:
q_terms = tokenize(query)
scores: list[tuple[int, float]] = []
k1 = 1.5
b = 0.75
for idx, tokens in enumerate(self.doc_tokens):
tf = Counter(tokens)
dl = len(tokens) or 1
score = 0.0
for term in q_terms:
if term not in tf:
continue
numerator = tf[term] * (k1 + 1)
denominator = tf[term] + k1 * (1 - b + b * dl / max(self.avgdl, 1))
score += self.idf.get(term, 0.0) * numerator / denominator
if score > 0:
scores.append((idx, score))
scores.sort(key=lambda item: item[1], reverse=True)
return [(self.docs[idx], score) for idx, score in scores[:top_k]]
class AnswerGenerator:
def generate(self, question: str, results: list[tuple[Doc, float]]) -> str:
raise NotImplementedError
class ExtractiveGenerator(AnswerGenerator):
def generate(self, question: str, results: list[tuple[Doc, float]]) -> str:
if not results:
return "Bu soru için kaynak bulunamadı."
best = results[0][0]
text = best.text.strip()
title = best.title.strip()
# Strip the title/question prefix from the beginning of the text if present
if text.lower().startswith(title.lower()):
text = text[len(title):].strip()
# Strip any remaining leading punctuation or question marks
text = re.sub(r"^[?\s,.:;!-]+", "", text).strip()
# Ensure the first letter is capitalized
if text:
text = text[0].upper() + text[1:]
return f"Kaynağa göre: {text}\n\nKaynak: {best.citation}"
class LocalHFGenerator(AnswerGenerator):
def __init__(self, model_name: str, max_new_tokens: int = 64) -> None:
from transformers import AutoTokenizer, AutoModelForCausalLM, AutoModelForSeq2SeqLM
import torch
from pathlib import Path
import json
self.model_name = model_name
self.max_new_tokens = max_new_tokens
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
peft_config_path = Path(model_name) / "adapter_config.json"
is_peft = peft_config_path.exists()
if is_peft:
with peft_config_path.open("r", encoding="utf-8") as f:
peft_data = json.load(f)
base_model_name = peft_data.get("base_model_name_or_path")
print(f"Loading PEFT adapter from {model_name} with base model {base_model_name}...")
if torch.cuda.is_available():
from transformers import BitsAndBytesConfig
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.float16,
)
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
quantization_config=quantization_config,
device_map="auto"
)
else:
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
device_map={"": "cpu"},
torch_dtype=torch.float32,
low_cpu_mem_usage=True,
)
from peft import PeftModel
self.model = PeftModel.from_pretrained(base_model, model_name)
self.is_causal = True
else:
try:
print(f"Loading base model {model_name}...")
if torch.cuda.is_available():
from transformers import BitsAndBytesConfig
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.float16,
)
self.model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=quantization_config,
device_map="auto"
)
else:
self.model = AutoModelForCausalLM.from_pretrained(
model_name,
device_map={"": "cpu"},
torch_dtype=torch.float32,
low_cpu_mem_usage=True,
)
self.is_causal = True
except Exception as e:
print(f"Failed to load as causal LM: {e}")
print(f"Loading seq2seq model {model_name}...")
self.model = AutoModelForSeq2SeqLM.from_pretrained(
model_name,
device_map={"": "cpu"},
torch_dtype=torch.float32,
low_cpu_mem_usage=True,
)
self.is_causal = False
self.model.eval()
@staticmethod
def build_prompt(question: str, results: list[tuple[Doc, float]]) -> str:
context = "\n\n".join(
f"[{rank}] Baslik: {doc.title}\nKaynak: {doc.citation}\nMetin: {doc.text[:600]}"
for rank, (doc, _score) in enumerate(results[:3], start=1)
)
return (
"Sen bir Turk hukuku RAG asistanisin. Yalnizca verilen kaynaklara dayanarak "
"kisa ve dogru cevap ver. Kaynakta olmayan bilgiyi uretme.\n\n"
f"Kaynaklar:\n{context}\n\n"
f"Soru: {question}\n\n"
"Cevap:"
)
@staticmethod
def clean_answer(answer: str, fallback_doc: Doc) -> str:
answer = answer.strip()
# Clean specific template structures
answer = re.sub(r"(?i)Baslangic:\s*\[\d+\]", "", answer)
answer = re.sub(r"(?i)Kendisi:\s*\[\d+\]", "", answer)
answer = re.sub(r"(?i)Sonu[çc]:\s*\[\d+\]", "", answer)
answer = re.sub(r"(?i)Kaynak:\s*\[\d+\]", "", answer)
answer = re.sub(r"(?i)Sonu[çc]:", "", answer)
answer = re.sub(r"(?i)İlk cihazı:", "", answer)
# Remove prompt leakage/artifacts
artifact_pattern = r"Bu metnin tümünü kontrol etmek için kaynaklarını seçerek, anlayışını gözden geçirerek ve sonuçları yazınız\.?"
answer = re.sub(artifact_pattern, "", answer)
# Filter out lines matching bracketed headers
lines = []
for line in answer.splitlines():
line_strip = line.strip()
if re.match(r"^\[(?:BaslangIC|Dışişler IC|Taahhût IC|Taahhüt IC)\]", line_strip, re.IGNORECASE):
continue
lines.append(line)
answer = "\n".join(lines).strip()
# Clean specific corrupted suffixes
answer = re.sub(r"(?i)ülükdir\.?", "", answer)
answer = re.sub(r"(?i)Kaynak:\s*Anaya\s*Sistemi\.?", "", answer)
# Normal cleanup
answer = re.sub(r"^\s*Soru:\s*", "", answer, flags=re.IGNORECASE)
answer = re.split(r"\[\d+\]\s*Baslik:|\n\s*Baslik:|\n\s*Metin:", answer, maxsplit=1)[0].strip()
answer = re.sub(r"\s+", " ", answer).strip()
if len(answer.split()) < 5:
answer = f"Verilen kaynaklara göre: {fallback_doc.text[:300]}...\n\nKaynak: {fallback_doc.citation}"
if "Kaynak:" not in answer:
answer = f"{answer}\n\nKaynak: {fallback_doc.citation}"
return answer
def generate(self, question: str, results: list[tuple[Doc, float]]) -> str:
import torch
if not results:
return "Bu soru icin kaynak bulunamadi."
# Format the context and question
context = "\n\n".join(
f"[{rank}] Baslik: {doc.title}\nKaynak: {doc.citation}\nMetin: {doc.text[:600]}"
for rank, (doc, _score) in enumerate(results[:3], start=1)
)
system_instruction = (
"Sen bir Türk hukuku RAG asistanısın. Yalnızca verilen kaynaklara dayanarak cevap ver. "
"Kaynakta olmayan bilgiyi üretme ve cevabın sonunda kaynak belirt."
)
user_content = (
f"[Kaynaklar]\n{context}\n\n"
f"[Soru]\n{question}\n\n"
"Cevabı yalnızca kaynaklara dayanarak Türkçe ver. "
"Cevabın sonunda kullandığın citation bilgisini 'Kaynak:' satırıyla yaz."
)
if self.is_causal and getattr(self.tokenizer, "chat_template", None):
messages = [
{"role": "system", "content": system_instruction},
{"role": "user", "content": user_content}
]
prompt = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
else:
prompt = self.build_prompt(question, results)
inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True, max_length=768)
device = "cuda" if torch.cuda.is_available() else "cpu"
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.inference_mode():
output_ids = self.model.generate(
**inputs,
max_new_tokens=self.max_new_tokens,
do_sample=False,
repetition_penalty=1.2,
no_repeat_ngram_size=5,
num_beams=1,
use_cache=True,
pad_token_id=self.tokenizer.pad_token_id,
eos_token_id=self.tokenizer.eos_token_id,
)
generated_ids = output_ids[0]
if self.is_causal:
input_len = inputs["input_ids"].shape[1]
generated_ids = generated_ids[input_len:]
answer = self.tokenizer.decode(generated_ids, skip_special_tokens=True).strip()
# Clean answer fallback logic
answer = self.clean_answer(answer, results[0][0])
return answer
def build_generator(answer_mode: str, generation_model: str | None, max_new_tokens: int) -> AnswerGenerator:
if answer_mode == "extractive":
return ExtractiveGenerator()
if not generation_model:
raise ValueError("--generation-model is required when --answer-mode local_hf")
try:
return LocalHFGenerator(generation_model, max_new_tokens=max_new_tokens)
except Exception as exc:
raise RuntimeError(f"Local HF model could not be loaded: {exc}") from exc
def first_submitted_question(payload: str, field_name: str) -> str:
values = parse_qs(payload, keep_blank_values=True).get(field_name, [])
for value in reversed(values):
value = value.strip()
if value:
return value
return ""
def page(
question: str = "",
answer: str = "",
results: list[tuple[Doc, float]] | None = None,
answer_mode: str = "extractive",
generation_model: str | None = None,
upload_question: str = "",
upload_answer: str = "",
upload_results: list[tuple[Doc, float]] | None = None,
upload_message: str = "",
) -> bytes:
results = results or []
upload_results = upload_results or []
sample_buttons = "".join(
f"<button name='question' value='{html.escape(q)}'>{html.escape(q)}</button>" for q in SAMPLE_QUESTIONS
)
source_cards = "".join(
f"""
<article class="source-card">
<div class="source-card-header">
<h3>#{rank} | {html.escape(doc.title)}</h3>
<span class="score-badge">Skor: {score:.3f}</span>
</div>
<p>{html.escape(doc.text[:900])}</p>
<div class="source-card-footer">
<code>{html.escape(doc.citation)}</code>
</div>
</article>
"""
for rank, (doc, score) in enumerate(results, start=1)
)
upload_source_cards = "".join(
f"""
<article class="source-card">
<div class="source-card-header">
<h3>#{rank} | {html.escape(doc.title)}</h3>
<span class="score-badge">Skor: {score:.3f}</span>
</div>
<p>{html.escape(doc.text[:900])}</p>
<div class="source-card-footer">
<code>{html.escape(doc.citation)}</code>
</div>
</article>
"""
for rank, (doc, score) in enumerate(upload_results, start=1)
)
body = f"""<!doctype html>
<html lang="tr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Türkçe Hukuk RAG Asistanı</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap');
:root {{
--bg-primary: #0b0f19;
--bg-secondary: #161f30;
--bg-card: rgba(22, 31, 48, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--border-focus: rgba(59, 130, 246, 0.5);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--accent-color: #3b82f6;
--accent-gradient: linear-gradient(135deg, #3b82f6, #1d4ed8);
--accent-glow: rgba(59, 130, 246, 0.3);
--success-color: #10b981;
--success-gradient: linear-gradient(135deg, #10b981, #047857);
}}
body {{
margin: 0;
font-family: 'Inter', sans-serif;
color: var(--text-primary);
background: var(--bg-primary);
background-image: radial-gradient(circle at 10% 20%, rgba(19, 26, 42, 1) 0%, rgba(11, 15, 25, 1) 90%);
min-height: 100vh;
line-height: 1.6;
}}
header {{
background: rgba(11, 15, 25, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}}
header h1 {{
margin: 0;
font-family: 'Outfit', sans-serif;
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}}
header p {{
margin: 4px 0 0;
color: var(--text-secondary);
font-size: 13px;
}}
main {{
max-width: 1100px;
margin: 20px auto;
padding: 0 16px;
width: calc(100% - 32px);
box-sizing: border-box;
}}
/* Metrics Dashboard */
.comparison-container {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}}
@media (max-width: 768px) {{
.comparison-container {{
grid-template-columns: 1fr;
}}
}}
.metrics-panel {{
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 18px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
}}
.metrics-panel h2 {{
margin-top: 0;
margin-bottom: 14px;
font-family: 'Outfit', sans-serif;
font-size: 16px;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}}
.metrics-panel.production h2 {{
color: #60a5fa;
}}
.metrics-panel.experimental h2 {{
color: #a78bfa;
}}
.badge {{
font-size: 10px;
padding: 2px 8px;
border-radius: 99px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
.badge.prod {{
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.3);
}}
.badge.exp {{
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
border: 1px solid rgba(167, 139, 250, 0.3);
}}
.metrics-grid {{
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}}
@media (max-width: 900px) {{
.metrics-grid {{
grid-template-columns: repeat(2, 1fr);
}}
}}
.metric-card {{
background: rgba(15, 23, 42, 0.5);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 6px;
text-align: center;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 64px;
}}
.metric-card:hover {{
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
}}
.metric-card strong {{
display: block;
font-size: 18px;
font-family: 'Outfit', sans-serif;
font-weight: 700;
margin-bottom: 2px;
}}
.metrics-panel.production .metric-card strong {{
color: #60a5fa;
}}
.metrics-panel.experimental .metric-card strong {{
color: #a78bfa;
}}
.metric-card span {{
font-size: 10px;
color: var(--text-secondary);
display: block;
line-height: 1.2;
}}
/* General Panel Styling */
.panel {{
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 22px;
margin-bottom: 22px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
}}
.panel-title {{
margin-top: 0;
margin-bottom: 16px;
font-family: 'Outfit', sans-serif;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
border-left: 4px solid var(--accent-color);
padding-left: 10px;
}}
/* Forms & Inputs */
textarea {{
width: 100%;
min-height: 90px;
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-family: 'Inter', sans-serif;
font-size: 14px;
padding: 12px;
box-sizing: border-box;
resize: vertical;
transition: all 0.3s ease;
}}
textarea:focus {{
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px var(--border-focus);
background: rgba(15, 23, 42, 0.8);
}}
.actions {{
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
}}
button[type=submit] {{
background: var(--accent-gradient);
border: none;
color: white;
padding: 10px 20px;
border-radius: 8px;
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: 13px;
cursor: pointer;
box-shadow: 0 4px 12px var(--accent-glow);
transition: all 0.3s ease;
}}
button[type=submit]:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 16px var(--accent-glow);
}}
/* Suggestion Chips */
.samples {{
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}}
.samples button {{
background: rgba(59, 130, 246, 0.06);
border: 1px solid rgba(59, 130, 246, 0.15);
color: #93c5fd;
padding: 6px 12px;
border-radius: 16px;
font-size: 11px;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: all 0.2s ease;
}}
.samples button:hover {{
background: rgba(59, 130, 246, 0.18);
border-color: #60a5fa;
color: white;
transform: translateY(-1px);
}}
/* Answer Display */
.answer-box {{
background: rgba(16, 185, 129, 0.03);
border: 1px solid rgba(16, 185, 129, 0.15);
border-radius: 10px;
padding: 18px;
margin-top: 8px;
}}
.answer-box pre {{
margin: 0;
white-space: pre-wrap;
font-family: 'Inter', sans-serif;
font-size: 14px;
line-height: 1.6;
color: #e2e8f0;
}}
/* Source Cards */
.sources-grid {{
display: grid;
grid-template-columns: 1fr;
gap: 14px;
margin-top: 14px;
}}
.source-card {{
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
transition: all 0.3s ease;
}}
.source-card:hover {{
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
}}
.source-card-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}}
.source-card-header h3 {{
margin: 0;
font-family: 'Outfit', sans-serif;
font-size: 15px;
font-weight: 600;
color: #60a5fa;
}}
.score-badge {{
background: rgba(16, 185, 129, 0.08);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.15);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
font-family: monospace;
}}
.source-card p {{
margin: 0 0 12px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}}
.source-card-footer {{
display: flex;
align-items: center;
gap: 6px;
}}
.source-card-footer code {{
background: rgba(15, 23, 42, 0.5);
border: 1px solid var(--border-color);
padding: 3px 6px;
border-radius: 4px;
font-size: 11px;
color: #cbd5e1;
font-family: monospace;
}}
/* File Upload Area */
.upload-zone {{
border: 2px dashed rgba(255, 255, 255, 0.15);
border-radius: 10px;
padding: 24px;
text-align: center;
background: rgba(15, 23, 42, 0.3);
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 14px;
}}
.upload-zone:hover {{
border-color: var(--accent-color);
background: rgba(59, 130, 246, 0.03);
}}
.upload-icon {{
font-size: 28px;
margin-bottom: 8px;
color: var(--text-secondary);
}}
.upload-zone input[type=file] {{
display: none;
}}
.upload-label-btn {{
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: white;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
display: inline-block;
margin-top: 8px;
}}
.upload-label-btn:hover {{
background: rgba(255, 255, 255, 0.12);
}}
</style>
</head>
<body>
<header>
<div>
<h1>Türkçe Hukuk RAG Asistanı</h1>
<p>BM25 Arama + Kaynağa Dayalı Yanıt + Hukuki Kaynakça</p>
</div>
</header>
<main>
<!-- Metrics Comparison Dashboard -->
<section class="comparison-container">
<div class="metrics-panel production">
<h2>
<span>Extractive BM25 Model</span>
<span class="badge prod">Üretim (Base)</span>
</h2>
<div class="metrics-grid">
<div class="metric-card">
<strong>0.975</strong>
<span>Recall@10</span>
</div>
<div class="metric-card">
<strong>0.799</strong>
<span>Token F1</span>
</div>
<div class="metric-card">
<strong>0.908</strong>
<span>Top-5 Hit</span>
</div>
<div class="metric-card">
<strong>0.813</strong>
<span>Kaynak Doğr.</span>
</div>
</div>
</div>
<div class="metrics-panel experimental">
<h2>
<span>Fine-Tuned QLoRA LLM</span>
<span class="badge exp">Deneysel (SFT)</span>
</h2>
<div class="metrics-grid">
<div class="metric-card">
<strong>0.975</strong>
<span>Recall@10</span>
</div>
<div class="metric-card">
<strong>0.382</strong>
<span>Token F1</span>
</div>
<div class="metric-card">
<strong>0.908</strong>
<span>Top-5 Hit</span>
</div>
<div class="metric-card">
<strong>0.033</strong>
<span>Kaynak Doğr.</span>
</div>
</div>
</div>
</section>
<!-- Active Mode & Model Information Panel -->
<section class="panel" style="border-left: 4px solid var(--accent-color); padding: 12px 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<div style="font-size: 13px;">
<span style="color: var(--text-secondary);">Aktif Yanıt Modu:</span>
<span class="badge prod" style="margin-left: 6px; font-size: 11px; padding: 2px 8px;">{html.escape(answer_mode)}</span>
</div>
{f'<div style="font-size: 13px;"><span style="color: var(--text-secondary);">Aktif Model:</span><span class="badge exp" style="margin-left: 6px; font-size: 11px; padding: 2px 8px; font-family: monospace;">QLoRA LLM | {html.escape(generation_model)}</span></div>' if generation_model else ''}
</div>
</section>
<!-- Ask RAG Panel -->
<section class="panel">
<h2 class="panel-title">Hukuki Soru Sorun</h2>
<form method="post" action="/ask" onsubmit="const btn=this.querySelector('button[type=submit]'); btn.disabled=true; btn.textContent='LLM yanıt üretiyor...';">
<textarea id="question" name="question" placeholder="Hukuki sorunuzu buraya yazın...">{html.escape(question)}</textarea>
<div class="actions">
<button type="submit">Sorgula (Ask RAG)</button>
</div>
<div class="samples">
{sample_buttons}
</div>
</form>
</section>
{f'<section class="panel"><h2 class="panel-title" style="color: var(--success-color); border-color: var(--success-color);">Cevap</h2><div class="answer-box"><pre>{html.escape(answer)}</pre></div></section>' if answer else ''}
{f'<section><h2 class="panel-title">Getirilen Hukuki Kaynaklar (Top-5)</h2><div class="sources-grid">{source_cards}</div></section><div style="margin-bottom:24px;"></div>' if results else ''}
<!-- Custom Document Panel -->
<section class="panel">
<h2 class="panel-title">Özel Doküman Testi</h2>
<form method="post" action="/upload_ask" enctype="multipart/form-data" onsubmit="const btn=this.querySelector('button[type=submit]'); btn.disabled=true; btn.textContent='LLM yanıt üretiyor...';">
<div class="upload-zone" onclick="document.getElementById(\'custom_file\').click()">
<div class="upload-icon">📂</div>
<div style="font-weight: 500; font-size: 14px;">Dokümanınızı buraya sürükleyin veya seçin</div>
<div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">Desteklenen dosya türleri: .txt, .md, .docx, .pdf, .csv, .json, .jsonl</div>
<input id="custom_file" name="custom_file" type="file" accept=".txt,.md,.csv,.json,.jsonl,.docx,.pdf" onchange="document.getElementById(\'file-chosen\').textContent = this.files[0].name">
<span class="upload-label-btn">Dosya Seç</span>
<div id="file-chosen" style="margin-top: 6px; font-size: 12px; color: var(--success-color); font-weight: 500;">Seçili dosya yok</div>
</div>
<textarea id="upload_question" name="upload_question" placeholder="Yüklediğiniz doküman hakkında bir soru yazın...">{html.escape(upload_question)}</textarea>
<div class="actions">
<button type="submit">Dokümanda Sorgula</button>
</div>
</form>
{f'<div class="answer-box" style="border-color: rgba(59,130,246,0.2); background: rgba(59,130,246,0.02); margin-top:14px;"><strong style="color:#60a5fa;">Bilgi:</strong> <span style="color:var(--text-secondary); font-size:13px;">{html.escape(upload_message)}</span></div>' if upload_message else ''}
</section>
{f'<section class="panel"><h2 class="panel-title" style="color:#60a5fa; border-color:#60a5fa;">Doküman Cevabı</h2><div class="answer-box" style="border-color:rgba(59,130,246,0.2); background:rgba(59,130,246,0.02);"><pre>{html.escape(upload_answer)}</pre></div></section>' if upload_answer else ''}
{f'<section><h2 class="panel-title">Dokümandan Elde Edilen Parçalar</h2><div class="sources-grid">{upload_source_cards}</div></section>' if upload_results else ''}
</main>
</body>
</html>"""
return body.encode("utf-8")
def build_handler(retriever: SimpleBM25, generator: AnswerGenerator, answer_mode: str, generation_model: str | None):
class DemoHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(page(answer_mode=answer_mode, generation_model=generation_model))
def do_POST(self) -> None:
if self.path == "/upload_ask":
self.handle_upload_ask()
return
length = int(self.headers.get("Content-Length", "0"))
payload = self.rfile.read(length).decode("utf-8")
question = first_submitted_question(payload, "question")
results = retriever.search(question, top_k=5) if question else []
answer = ""
if question:
try:
started = time.perf_counter()
answer = generator.generate(question, results)
elapsed = time.perf_counter() - started
answer = f"{answer}\n\nYanıt süresi: {elapsed:.1f} sn"
except Exception as exc:
answer = f"LLM yanıt üretirken hata oluştu: {exc}"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(page(question, answer, results, answer_mode=answer_mode, generation_model=generation_model))
def handle_upload_ask(self) -> None:
upload_question = ""
upload_answer = ""
upload_results: list[tuple[Doc, float]] = []
upload_message = ""
try:
content_length = int(self.headers.get("Content-Length", "0"))
payload = self.rfile.read(content_length)
content_type = self.headers.get("Content-Type", "")
msg = email.message_from_bytes(
b"Content-Type: " + content_type.encode("utf-8") + b"\r\n\r\n" + payload
)
if not msg.is_multipart():
raise ValueError("Geçersiz istek: multipart form verisi bekleniyor.")
upload_question = ""
filename = ""
file_payload = b""
for part in msg.get_payload():
name = part.get_param("name", header="Content-Disposition")
if name == "upload_question":
upload_question = part.get_payload(decode=True).decode("utf-8", errors="ignore").strip()
elif name == "custom_file":
filename = part.get_filename()
file_payload = part.get_payload(decode=True)
if not upload_question:
raise ValueError("Lutfen yuklenen dokuman icin bir soru yazin.")
if not filename:
raise ValueError("Lutfen .txt, .md, .docx veya .pdf dosyasi secin.")
filename = Path(filename).name
text = extract_uploaded_text(filename, file_payload)
upload_docs = chunk_uploaded_text(text, filename)
if not upload_docs:
raise ValueError("Yuklenen dosyadan okunabilir metin cikarilamadi.")
custom_retriever = SimpleBM25(upload_docs)
upload_results = custom_retriever.search(upload_question, top_k=5)
try:
started = time.perf_counter()
upload_answer = generator.generate(upload_question, upload_results)
elapsed = time.perf_counter() - started
upload_answer = f"{upload_answer}\n\nYanıt süresi: {elapsed:.1f} sn"
except Exception as exc:
upload_answer = f"LLM yanıt üretirken hata oluştu: {exc}"
upload_message = f"{filename} indexed with {len(upload_docs)} chunks."
except Exception as exc:
upload_message = f"Upload error: {exc}"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(
page(
answer_mode=answer_mode,
generation_model=generation_model,
upload_question=upload_question,
upload_answer=upload_answer,
upload_results=upload_results,
upload_message=upload_message,
)
)
return DemoHandler
def main() -> None:
parser = argparse.ArgumentParser(description="Browser demo for Turkish legal RAG")
parser.add_argument("--data-dir", type=Path, default=Path("data"))
parser.add_argument("--corpus-file", type=Path, default=None)
parser.add_argument("--limit", type=int, default=None)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=7860)
parser.add_argument("--answer-mode", choices=["extractive", "local_hf"], default="local_hf")
parser.add_argument("--generation-model", default="outputs/models/turkish_llm_qlora")
parser.add_argument("--max-new-tokens", type=int, default=64)
parser.add_argument("--no-browser", action="store_true")
args = parser.parse_args()
corpus_file = args.corpus_file or resolve_corpus_file(args.data_dir)
docs = load_docs(corpus_file, limit=args.limit)
retriever = SimpleBM25(docs)
generator = build_generator(args.answer_mode, args.generation_model, args.max_new_tokens)
server = ThreadingHTTPServer(
(args.host, args.port),
build_handler(retriever, generator, args.answer_mode, args.generation_model),
)
url = f"http://{args.host}:{args.port}"
print(f"Loaded {len(docs)} documents from {corpus_file}")
print(f"Answer mode: {args.answer_mode}")
if args.generation_model:
print(f"Generation model: {args.generation_model}")
print(f"Demo running at {url}")
if not args.no_browser:
webbrowser.open(url)
server.serve_forever()
if __name__ == "__main__":
main()