neuralworm commited on
Commit
7bcd142
·
1 Parent(s): af1dacb

initial commit

Browse files
Files changed (2) hide show
  1. app.py +404 -0
  2. requirements.txt +7 -0
app.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import torch
4
+ import gradio as gr
5
+
6
+ from typing import List, Tuple, Dict
7
+
8
+ from sentence_transformers import SentenceTransformer
9
+ from transformers import AutoProcessor, Gemma3ForConditionalGeneration
10
+ from pypdf import PdfReader
11
+
12
+ # --------------------------------------------------------------------
13
+ # Konfiguration
14
+ # --------------------------------------------------------------------
15
+ EMBED_MODEL_ID = "google/embeddinggemma-300m"
16
+ LLM_MODEL_ID = "google/gemma-3-4b-it"
17
+
18
+ # Globale States (simpler, reicht für einen Space)
19
+ EMBED_MODEL: SentenceTransformer = None
20
+ LLM_MODEL: Gemma3ForConditionalGeneration = None
21
+ LLM_PROCESSOR: AutoProcessor = None
22
+
23
+ DOCUMENT_STORE: List[Dict] = [] # {content, embedding (tensor), source}
24
+
25
+
26
+ # --------------------------------------------------------------------
27
+ # Model Loading
28
+ # --------------------------------------------------------------------
29
+ def get_device() -> torch.device:
30
+ if torch.cuda.is_available():
31
+ return torch.device("cuda")
32
+ return torch.device("cpu")
33
+
34
+
35
+ def get_embedding_model() -> SentenceTransformer:
36
+ global EMBED_MODEL
37
+ if EMBED_MODEL is None:
38
+ device = "cuda" if torch.cuda.is_available() else "cpu"
39
+ # EmbeddingGemma nutzt SentenceTransformers-Wrapper
40
+ EMBED_MODEL = SentenceTransformer(EMBED_MODEL_ID, device=device)
41
+ return EMBED_MODEL
42
+
43
+
44
+ def get_llm() -> Tuple[Gemma3ForConditionalGeneration, AutoProcessor]:
45
+ global LLM_MODEL, LLM_PROCESSOR
46
+ if LLM_MODEL is None or LLM_PROCESSOR is None:
47
+ device = get_device()
48
+
49
+ if device.type == "cuda":
50
+ # bfloat16 für GPU wie im Model-Card-Beispiel
51
+ LLM_MODEL = Gemma3ForConditionalGeneration.from_pretrained(
52
+ LLM_MODEL_ID,
53
+ torch_dtype=torch.bfloat16,
54
+ device_map="auto",
55
+ ).eval()
56
+ else:
57
+ # CPU: float32
58
+ LLM_MODEL = Gemma3ForConditionalGeneration.from_pretrained(
59
+ LLM_MODEL_ID,
60
+ torch_dtype=torch.float32,
61
+ ).to(device).eval()
62
+
63
+ LLM_PROCESSOR = AutoProcessor.from_pretrained(LLM_MODEL_ID)
64
+
65
+ return LLM_MODEL, LLM_PROCESSOR
66
+
67
+
68
+ # --------------------------------------------------------------------
69
+ # Datei-Handling & Chunking
70
+ # --------------------------------------------------------------------
71
+ def extract_text_from_file(path: str) -> str:
72
+ ext = os.path.splitext(path)[1].lower()
73
+
74
+ if ext in [".txt", ".md", ".markdown"]:
75
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
76
+ return f.read()
77
+
78
+ if ext == ".pdf":
79
+ text = []
80
+ reader = PdfReader(path)
81
+ for page in reader.pages:
82
+ page_text = page.extract_text()
83
+ if page_text:
84
+ text.append(page_text)
85
+ return "\n".join(text)
86
+
87
+ # Fallback: Versuche als Text zu lesen
88
+ try:
89
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
90
+ return f.read()
91
+ except Exception:
92
+ return ""
93
+
94
+
95
+ def chunk_text(
96
+ text: str,
97
+ chunk_size: int = 800, # ca. "Token"-Näherung über Wörter
98
+ chunk_overlap: int = 200,
99
+ ) -> List[str]:
100
+ words = text.split()
101
+ if not words:
102
+ return []
103
+
104
+ chunks = []
105
+ start = 0
106
+ while start < len(words):
107
+ end = min(len(words), start + chunk_size)
108
+ chunk = " ".join(words[start:end]).strip()
109
+ if chunk:
110
+ chunks.append(chunk)
111
+ if end == len(words):
112
+ break
113
+ start = max(0, end - chunk_overlap)
114
+ return chunks
115
+
116
+
117
+ # --------------------------------------------------------------------
118
+ # Indexing / RAG
119
+ # --------------------------------------------------------------------
120
+ def index_files(file_paths: List[str]) -> str:
121
+ """
122
+ Liest hochgeladene Dateien ein, chunked sie und
123
+ legt Embeddings in einem einfachen In-Memory-Store ab.
124
+ """
125
+ if not file_paths:
126
+ return "Keine Dateien übergeben."
127
+
128
+ embed_model = get_embedding_model()
129
+
130
+ added_chunks = 0
131
+ for path in file_paths:
132
+ if path is None:
133
+ continue
134
+ text = extract_text_from_file(path)
135
+ if not text.strip():
136
+ continue
137
+
138
+ chunks = chunk_text(text)
139
+ if not chunks:
140
+ continue
141
+
142
+ # Embeddings mit EmbeddingGemma – nutzt automatisch passende Prompts
143
+ doc_embeddings = embed_model.encode_document(
144
+ chunks,
145
+ convert_to_tensor=True,
146
+ ) # shape: (num_chunks, dim)
147
+
148
+ for chunk, emb in zip(chunks, doc_embeddings):
149
+ DOCUMENT_STORE.append(
150
+ {
151
+ "content": chunk,
152
+ "embedding": emb, # torch.Tensor
153
+ "source": os.path.basename(path),
154
+ }
155
+ )
156
+ added_chunks += 1
157
+
158
+ if added_chunks == 0:
159
+ return "Keine verwertbaren Text-Chunks gefunden."
160
+
161
+ return f"Index aktualisiert: {len(DOCUMENT_STORE)} Chunks gespeichert (neu hinzugefügt: {added_chunks})."
162
+
163
+
164
+ def clear_index() -> str:
165
+ DOCUMENT_STORE.clear()
166
+ return "Index geleert (0 Chunks)."
167
+
168
+
169
+ def retrieve_relevant_chunks(
170
+ query: str,
171
+ top_k: int = 5,
172
+ ) -> List[Dict]:
173
+ """
174
+ Einfacher Dense-Retriever auf Basis von EmbeddingGemma-300M.
175
+ Nutzt die SentenceTransformers-Similarity-Funktion (inner product / cosine).
176
+ """
177
+ if not DOCUMENT_STORE:
178
+ return []
179
+
180
+ embed_model = get_embedding_model()
181
+
182
+ # Query-Embedding
183
+ q_emb = embed_model.encode_query(
184
+ query,
185
+ convert_to_tensor=True,
186
+ ) # (dim,)
187
+
188
+ # Stack aller Dokument-Embeddings
189
+ doc_embs = torch.stack([d["embedding"] for d in DOCUMENT_STORE]) # (N, dim)
190
+
191
+ # Similarity via SentenceTransformers-API (richtige Metrik laut Config)
192
+ sims = embed_model.similarity(q_emb, doc_embs)[0] # (N,)
193
+
194
+ top_k = min(top_k, len(DOCUMENT_STORE))
195
+ scores, indices = torch.topk(sims, k=top_k)
196
+
197
+ results = []
198
+ for score, idx in zip(scores.tolist(), indices.tolist()):
199
+ entry = DOCUMENT_STORE[idx]
200
+ results.append(
201
+ {
202
+ "content": entry["content"],
203
+ "source": entry["source"],
204
+ "score": float(score),
205
+ }
206
+ )
207
+ return results
208
+
209
+
210
+ # --------------------------------------------------------------------
211
+ # LLM-Generierung (Gemma-3-4B-IT)
212
+ # --------------------------------------------------------------------
213
+ def build_system_prompt() -> str:
214
+ return (
215
+ "Du bist ein hilfreicher, präziser Assistent. "
216
+ "Du beantwortest Fragen basierend auf bereitgestellten Dokumenten. "
217
+ "Wenn Informationen nicht im Kontext sind, sag explizit, dass sie nicht im Kontext stehen. "
218
+ "Antworte bitte auf Deutsch und zitiere ggf. die relevanten Teile in eigenen Worten."
219
+ )
220
+
221
+
222
+ def build_user_prompt(user_question: str, retrieved_chunks: List[Dict]) -> str:
223
+ if not retrieved_chunks:
224
+ return (
225
+ f"Benutzerfrage:\n{user_question}\n\n"
226
+ "Es wurden keine Dokumente im Kontext gefunden. "
227
+ "Beantworte die Frage so gut wie möglich, aber weise darauf hin, "
228
+ "dass du keinen Dokumentenkontext hast."
229
+ )
230
+
231
+ context_str_parts = []
232
+ for i, ch in enumerate(retrieved_chunks, start=1):
233
+ context_str_parts.append(
234
+ f"[{i}] (Quelle: {ch['source']}, Score: {ch['score']:.3f})\n{ch['content']}"
235
+ )
236
+ context_str = "\n\n".join(context_str_parts)
237
+
238
+ prompt = (
239
+ f"Benutzerfrage:\n{user_question}\n\n"
240
+ "Hier sind die relevantesten Kontextpassagen aus den hochgeladenen Dokumenten:\n"
241
+ f"{context_str}\n\n"
242
+ "Aufgabe:\n"
243
+ "- Nutze primär diese Kontexte.\n"
244
+ "- Wenn etwas nicht im Kontext steht, sag das klar.\n"
245
+ "- Fasse die relevanten Stellen strukturiert zusammen.\n"
246
+ "- Antworte auf Deutsch.\n\n"
247
+ "Antwort:"
248
+ )
249
+ return prompt
250
+
251
+
252
+ def answer_with_rag(question: str, history: List[Tuple[str, str]]) -> str:
253
+ model, processor = get_llm()
254
+
255
+ # 1. Retrieve relevante Chunks
256
+ retrieved = retrieve_relevant_chunks(question, top_k=5)
257
+
258
+ # 2. Prompt bauen
259
+ system_prompt = build_system_prompt()
260
+ user_prompt = build_user_prompt(question, retrieved)
261
+
262
+ messages = [
263
+ {
264
+ "role": "system",
265
+ "content": [{"type": "text", "text": system_prompt}],
266
+ },
267
+ {
268
+ "role": "user",
269
+ "content": [{"type": "text", "text": user_prompt}],
270
+ },
271
+ ]
272
+
273
+ # 3. Chat Template + Generation
274
+ inputs = processor.apply_chat_template(
275
+ messages,
276
+ add_generation_prompt=True,
277
+ tokenize=True,
278
+ return_dict=True,
279
+ return_tensors="pt",
280
+ ).to(model.device, dtype=model.dtype)
281
+
282
+ input_len = inputs["input_ids"].shape[-1]
283
+
284
+ with torch.inference_mode():
285
+ generated = model.generate(
286
+ **inputs,
287
+ max_new_tokens=512,
288
+ do_sample=True,
289
+ temperature=0.7,
290
+ top_p=0.9,
291
+ )
292
+
293
+ gen_tokens = generated[0][input_len:]
294
+ answer = processor.decode(gen_tokens, skip_special_tokens=True)
295
+
296
+ return answer
297
+
298
+
299
+ # --------------------------------------------------------------------
300
+ # Gradio UI
301
+ # --------------------------------------------------------------------
302
+ def ingest_files_ui(files) -> str:
303
+ if not files:
304
+ return "Bitte zuerst eine oder mehrere Dateien hochladen."
305
+ paths = [f.name if hasattr(f, "name") else f for f in files]
306
+ return index_files(paths)
307
+
308
+
309
+ def chat_fn(message: str, history: List[Tuple[str, str]]):
310
+ """
311
+ Callback für gr.Chatbot: nimmt die neue User-Nachricht, macht RAG+LLM
312
+ und hängt Antwort an die History an.
313
+ """
314
+ if not message or not message.strip():
315
+ return history
316
+
317
+ answer = answer_with_rag(message, history)
318
+ history = history + [(message, answer)]
319
+ return history
320
+
321
+
322
+ def build_demo() -> gr.Blocks:
323
+ with gr.Blocks(title="Gemma 3 RAG – EmbeddingGemma-300M", theme="soft") as demo:
324
+ gr.Markdown(
325
+ """
326
+ # 🔍 Gemma 3 RAG Space
327
+ **RAG-Pipeline mit `google/embeddinggemma-300m` + `google/gemma-3-4b-it`**
328
+
329
+ Schritte:
330
+ 1. Links Dateien hochladen und indexieren.
331
+ 2. Rechts im Chat Fragen zu deinen Dokumenten stellen.
332
+ """
333
+ )
334
+
335
+ with gr.Row():
336
+ with gr.Column(scale=1):
337
+ gr.Markdown("### 📁 Dokumente")
338
+ file_uploader = gr.File(
339
+ label="Dateien hochladen (.pdf, .txt, .md, ...)",
340
+ file_count="multiple",
341
+ type="filepath",
342
+ )
343
+ index_button = gr.Button("🔄 Index aktualisieren")
344
+ clear_index_button = gr.Button("🧹 Index leeren")
345
+ index_status = gr.Markdown("Noch keine Dokumente indexiert.")
346
+
347
+ index_button.click(
348
+ fn=index_files,
349
+ inputs=file_uploader,
350
+ outputs=index_status,
351
+ )
352
+
353
+ clear_index_button.click(
354
+ fn=clear_index,
355
+ inputs=None,
356
+ outputs=index_status,
357
+ )
358
+
359
+ with gr.Column(scale=2):
360
+ gr.Markdown("### 💬 Chat mit Gemma-3 über deine Dateien")
361
+ chatbot = gr.Chatbot(
362
+ label="Kontext-sensitiver Chat",
363
+ type="tuple",
364
+ show_copy_button=True,
365
+ )
366
+ msg = gr.Textbox(
367
+ label="Deine Frage",
368
+ placeholder="Frag etwas zu den hochgeladenen Dokumenten...",
369
+ lines=2,
370
+ )
371
+ send_btn = gr.Button("Senden")
372
+ clear_chat_btn = gr.Button("Chat löschen")
373
+
374
+ def user_submit(user_message, chat_history):
375
+ if not user_message:
376
+ return "", chat_history
377
+ chat_history = chat_history + [(user_message, None)]
378
+ return "", chat_history
379
+
380
+ # Ablauf:
381
+ # 1. User-Text -> History mit (msg, None)
382
+ # 2. Chat-Funktion ersetzt None durch generierte Antwort
383
+ msg.submit(user_submit, [msg, chatbot], [msg, chatbot]).then(
384
+ chat_fn, chatbot, chatbot
385
+ )
386
+ send_btn.click(user_submit, [msg, chatbot], [msg, chatbot]).then(
387
+ chat_fn, chatbot, chatbot
388
+ )
389
+
390
+ clear_chat_btn.click(
391
+ fn=lambda: [],
392
+ inputs=None,
393
+ outputs=chatbot,
394
+ )
395
+
396
+ return demo
397
+
398
+
399
+ demo = build_demo()
400
+
401
+ if __name__ == "__main__":
402
+ # In HF Spaces wird normalerweise automatisch gestartet,
403
+ # aber lokal brauchst du das:
404
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ transformers>=4.50.0
3
+ sentence-transformers>=5.0.0
4
+ pypdf>=5.0.0
5
+ accelerate>=0.33.0
6
+ torch
7
+ numpy