gemma-sage / app.py
neuralworm's picture
fix
90a2234
Raw
History Blame
14.6 kB
# app.py - v1.5
# Beschreibung: Finale, robuste Version mit umfangreichem Debugging, Assertions
# und Korrekturen für API-Inkompatibilitäten (Gemma-3 Prozessor, LangChain Embeddings).
import os
import torch
import gradio as gr
import time # Für Debug-Timings
from typing import List, Tuple, Generator, Dict
from threading import Thread
# ML / Transformers
from transformers import AutoProcessor, Gemma3ForConditionalGeneration, TextStreamer
# Dokumentenverarbeitung & RAG
from pypdf import PdfReader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
# NEUER, KORREKTER IMPORT für Zukunftssicherheit
from langchain_huggingface import HuggingFaceEmbeddings
# --------------------------------------------------------------------
# Konfiguration & Globale States
# --------------------------------------------------------------------
EMBED_MODEL_ID = "google/embeddinggemma-300m"
LLM_MODEL_ID = "google/gemma-3-4b-it"
EMBEDDING_FUNCTION: HuggingFaceEmbeddings = None
LLM_MODEL: Gemma3ForConditionalGeneration = None
LLM_PROCESSOR: AutoProcessor = None
VECTOR_STORE: FAISS = None
# --- DEBUGGING HELPER ---
def print_debug(message: str):
"""Konsistente Debug-Ausgabe mit Zeitstempel."""
print(f"[DEBUG {time.strftime('%H:%M:%S')}] {message}")
# --------------------------------------------------------------------
# Model Loading
# --------------------------------------------------------------------
def get_device() -> torch.device:
"""Gibt das verfügbare torch-Device zurück (CUDA, wenn verfügbar)."""
if torch.cuda.is_available():
return torch.device("cuda")
return torch.device("cpu")
def get_embedding_function() -> HuggingFaceEmbeddings:
"""Lädt das Embedding-Modell über den korrekten LangChain-Wrapper."""
global EMBEDDING_FUNCTION
if EMBEDDING_FUNCTION is None:
device = get_device()
print_debug(f"Initialisiere Embedding-Modell '{EMBED_MODEL_ID}' auf Device '{device}'.")
# Nutzt jetzt die dedizierte, zukunftssichere Klasse.
EMBEDDING_FUNCTION = HuggingFaceEmbeddings(
model_name=EMBED_MODEL_ID,
model_kwargs={'device': device}
)
print_debug("Embedding-Modell erfolgreich initialisiert.")
return EMBEDDING_FUNCTION
def get_llm() -> Tuple[Gemma3ForConditionalGeneration, AutoProcessor]:
"""Lädt und initialisiert das LLM und den zugehörigen Prozessor."""
global LLM_MODEL, LLM_PROCESSOR
if LLM_MODEL is None or LLM_PROCESSOR is None:
device = get_device()
print_debug(f"Initialisiere LLM '{LLM_MODEL_ID}' auf Device '{device}'.")
dtype = torch.bfloat16 if "cuda" in device.type else torch.float32
LLM_MODEL = Gemma3ForConditionalGeneration.from_pretrained(
LLM_MODEL_ID,
torch_dtype=dtype,
device_map="auto",
).eval()
LLM_PROCESSOR = AutoProcessor.from_pretrained(LLM_MODEL_ID)
print_debug("LLM und Prozessor erfolgreich initialisiert.")
return LLM_MODEL, LLM_PROCESSOR
# --------------------------------------------------------------------
# Datei-Handling & Chunking
# --------------------------------------------------------------------
def extract_text_from_file(path: str) -> str:
"""Extrahiert Text aus verschiedenen Dateitypen."""
# ... (Keine Änderungen in dieser Funktion, bleibt wie in v1.4)
ext = os.path.splitext(path)[1].lower()
if ext in [".txt", ".md", ".markdown"]:
with open(path, "r", encoding="utf-8", errors="ignore") as f: return f.read()
if ext == ".pdf":
text_parts = []
try:
reader = PdfReader(path)
for page in reader.pages:
page_text = page.extract_text()
if page_text: text_parts.append(page_text)
return "\n\n".join(text_parts)
except Exception as e:
print(f"Fehler beim Lesen von PDF {path}: {e}"); return ""
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f: return f.read()
except Exception: return ""
def get_text_splitter() -> RecursiveCharacterTextSplitter:
"""Erstellt einen semantisch bewussten Text-Splitter."""
return RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200, length_function=len)
# --------------------------------------------------------------------
# Indexing / RAG mit FAISS
# --------------------------------------------------------------------
def index_files(file_paths: List[str], progress=gr.Progress(track_tqdm=True)) -> str:
"""Liest Dateien, erstellt Chunks und baut/aktualisiert einen FAISS-Vektorindex."""
global VECTOR_STORE
if not file_paths:
return "Keine Dateien zum Indexieren ausgewählt."
print_debug(f"Indexierung gestartet für {len(file_paths)} Datei(en).")
embedding_function = get_embedding_function()
text_splitter = get_text_splitter()
documents: List[Document] = []
for path in progress.tqdm(file_paths, desc="1/2: Dateien verarbeiten & chunken"):
if path is None: continue
text = extract_text_from_file(path)
if not text.strip():
print_debug(f"Datei '{os.path.basename(path)}' enthält keinen extrahierbaren Text.")
continue
chunks = text_splitter.split_text(text)
source_name = os.path.basename(path)
for chunk in chunks:
doc = Document(page_content=chunk, metadata={"source": source_name})
documents.append(doc)
# ASSERT: Sicherstellen, dass wir eine Liste von Document-Objekten haben.
assert all(isinstance(d, Document) for d in documents), "Alle Elemente in 'documents' müssen vom Typ langchain.Document sein."
print_debug(f"Erfolgreich {len(documents)} Chunks aus den Dateien erstellt.")
if not documents:
return "Kein Text in den Dateien gefunden, der indexiert werden konnte."
progress(0.5, desc="2/2: Embeddings erstellen & FAISS Index aufbauen...")
new_store = FAISS.from_documents(documents, embedding_function)
print_debug("FAISS Index erfolgreich aus Dokumenten erstellt.")
if VECTOR_STORE is None:
VECTOR_STORE = new_store
else:
VECTOR_STORE.add_documents(documents)
print_debug("Neuen Index mit bestehendem Index zusammengeführt.")
# ASSERT: Sicherstellen, dass der Index nun existiert und Elemente enthält.
assert VECTOR_STORE is not None and VECTOR_STORE.index.ntotal > 0, "VECTOR_STORE wurde nicht korrekt initialisiert."
final_count = VECTOR_STORE.index.ntotal
print_debug(f"Indexierung abgeschlossen. Gesamtanzahl der Chunks im Index: {final_count}")
return f"Index aktualisiert: {final_count} Chunks insgesamt."
def clear_index() -> str:
"""Leert den Vektorindex."""
global VECTOR_STORE
VECTOR_STORE = None
import gc; gc.collect()
print_debug("Vektor-Index wurde geleert.")
return "Index geleert."
def retrieve_relevant_chunks(query: str, top_k: int = 5) -> List[Dict]:
"""Sucht die relevantesten Chunks mit FAISS."""
if VECTOR_STORE is None:
print_debug("Retrieval versucht, aber Vektor-Index ist leer.")
return []
print_debug(f"Suche nach {top_k} relevanten Chunks für die Anfrage: '{query[:80]}...'")
results_with_scores = VECTOR_STORE.similarity_search_with_score(query, k=top_k)
formatted_results = []
for doc, score in results_with_scores:
formatted_results.append({
"content": doc.page_content, "source": doc.metadata.get("source", "Unbekannt"), "score": 1 - score
})
# ASSERT: Sicherstellen, dass die zurückgegebene Struktur korrekt ist.
assert isinstance(formatted_results, list), "Retrieval-Ergebnis muss eine Liste sein."
if formatted_results:
assert all("content" in r and "source" in r and "score" in r for r in formatted_results), "Jedes Retrieval-Ergebnis muss 'content', 'source' und 'score' enthalten."
print_debug(f"{len(formatted_results)} Chunks gefunden.")
return formatted_results
# --------------------------------------------------------------------
# LLM-Generierung mit Streaming
# --------------------------------------------------------------------
def build_rag_prompt(user_question: str, retrieved_chunks: List[Dict]) -> str:
# ... (Keine Änderungen in dieser Funktion, bleibt wie in v1.4)
if not retrieved_chunks:
context_str = "Es wurden keine relevanten Dokumente im Kontext gefunden."
else:
context_parts = []
for i, ch in enumerate(retrieved_chunks, start=1):
context_parts.append(
f"Dokument [{i}] (Quelle: {ch['source']}, Relevanz: {ch['score']:.3f}):\n\"{ch['content']}\""
)
context_str = "\n\n".join(context_parts)
prompt = (f"Du bist ein präziser, hilfreicher Assistent. Deine Aufgabe ist es, die folgende Benutzerfrage ausschließlich "
f"basierend auf den unten stehenden Kontext-Dokumenten zu beantworten. "
f"Wenn die Antwort nicht in den Dokumenten enthalten ist, gib klar an: 'Die Information ist in den bereitgestellten Dokumenten nicht enthalten.' "
f"Antworte auf Deutsch und fasse die relevanten Informationen zusammen, anstatt die Dokumente wörtlich zu zitieren.\n\n"
f"--- Kontext-Dokumente ---\n{context_str}\n\n"
f"--- Benutzerfrage ---\n{user_question}\n\n"
f"--- Deine Antwort ---\n")
return prompt
def answer_with_rag(question: str, history: list) -> Generator[str, None, None]:
"""Führt RAG durch und generiert eine gestreamte Antwort."""
print_debug("Starte RAG-Antwort-Generierung.")
model, processor = get_llm()
streamer = TextStreamer(processor, skip_prompt=True, skip_special_tokens=True)
retrieved = retrieve_relevant_chunks(question, top_k=5)
prompt = build_rag_prompt(question, retrieved)
print_debug(f"Generierter RAG-Prompt (erste 200 Zeichen): '{prompt[:200].replace(chr(10), ' ')}...'")
# KORREKTUR: Gemma-3 erwartet eine Liste von Inhalts-Dictionaries.
messages = [
{"role": "user", "content": [{"type": "text", "text": prompt}]}
]
# ASSERT: Überprüft die korrekte, verschachtelte Struktur vor dem Aufruf.
print_debug(f"Nachrichten-Struktur wird für Prozessor vorbereitet: {str(messages)[:200]}...")
assert isinstance(messages, list) and len(messages) > 0, "Messages muss eine nicht-leere Liste sein."
assert isinstance(messages[0], dict) and "role" in messages[0] and "content" in messages[0], "Nachricht muss ein Dictionary mit 'role' und 'content' sein."
assert isinstance(messages[0]["content"], list) and len(messages[0]["content"]) > 0, "Content muss eine nicht-leere Liste sein."
assert isinstance(messages[0]["content"][0], dict) and "type" in messages[0]["content"][0] and "text" in messages[0]["content"][0], "Content-Block muss ein Dictionary mit 'type' und 'text' sein."
print_debug("ASSERTIONS für Nachrichten-Struktur erfolgreich bestanden.")
inputs = processor.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
generation_kwargs = dict(inputs, streamer=streamer, max_new_tokens=1024, do_sample=True, temperature=0.7, top_p=0.9)
thread = Thread(target=model.generate, kwargs=generation_kwargs)
thread.start()
print_debug("LLM-Generierungs-Thread gestartet.")
for new_text in streamer:
yield new_text
print_debug("LLM-Generierung abgeschlossen.")
# --------------------------------------------------------------------
# Gradio UI
# --------------------------------------------------------------------
def build_demo() -> gr.Blocks:
with gr.Blocks(title="Gemma 3 RAG v1.5", theme="soft") as demo:
gr.Markdown(
"""
# 🔍 Gemma 3 RAG v1.5 – Robust & Debug-Fähig
**Eine "State of the Art" RAG-Pipeline mit `google/embeddinggemma-300m` und `google/gemma-3-4b-it`**
1. Lade deine Dokumente hoch und klicke auf "Index aktualisieren".
2. Stelle deine Fragen im Chatfenster. Die Antworten werden live gestreamt.
"""
)
with gr.Row():
with gr.Column(scale=1):
# ... (Keine UI-Änderungen in dieser Spalte)
gr.Markdown("### 📁 Dokumenten-Management")
file_uploader = gr.File(label="Dateien hochladen (.pdf, .txt, .md)", file_count="multiple", type="filepath")
with gr.Row():
index_button = gr.Button("🔄 Index aktualisieren", variant="primary")
clear_index_button = gr.Button("🧹 Index leeren")
index_status = gr.Markdown("Index ist leer.")
index_button.click(fn=index_files, inputs=file_uploader, outputs=index_status)
clear_index_button.click(fn=clear_index, inputs=None, outputs=index_status)
with gr.Column(scale=2):
# ... (Keine UI-Änderungen in dieser Spalte)
gr.Markdown("### 💬 Chat über deine Dokumente")
chatbot = gr.Chatbot(label="Gemma-3 Chat", type="messages", show_copy_button=True, height=600)
with gr.Row():
msg_textbox = gr.Textbox(label="Deine Frage", placeholder="Stelle eine Frage zu den Dokumenten...", scale=4, autofocus=True)
send_btn = gr.Button("Senden", variant="primary", scale=1)
def chat_interface(message: str, history: list):
if not message or not message.strip():
return history
history.append({"role": "user", "content": message})
history.append({"role": "assistant", "content": ""})
for token in answer_with_rag(message, history):
history[-1]["content"] += token
yield history
msg_textbox.submit(fn=chat_interface, inputs=[msg_textbox, chatbot], outputs=chatbot).then(fn=lambda: gr.update(value=""), outputs=msg_textbox)
send_btn.click(fn=chat_interface, inputs=[msg_textbox, chatbot], outputs=chatbot).then(fn=lambda: gr.update(value=""), outputs=msg_textbox)
return demo
if __name__ == "__main__":
# Stelle sicher, dass die Modelle beim Start geladen werden,
# um eine Verzögerung bei der ersten Anfrage zu vermeiden.
get_embedding_function()
get_llm()
app_demo = build_demo()
app_demo.launch()