From 30fd55da21e475fdb4ea0c9d8b35e387febab2be Mon Sep 17 00:00:00 2001 From: Philipp Mock Date: Thu, 8 Oct 2026 10:06:32 +0200 Subject: [PATCH] - eval mit gpt-6-luna - new chat interface with personas --- .claude/launch.json | 11 + README.md | 12 +- add_pdfs.py | 2 +- eval_retrieval.py | 2 +- local_rag.py | 164 ++-- personas.py | 126 +++ server.py | 90 +- templates/chat.html | 784 +++++++++++++----- templates/visuals/contemporary_avatar.jpg | Bin 0 -> 17072 bytes templates/visuals/kafka_avatar.jpg | Bin 0 -> 19454 bytes .../visuals/kafka_contemporary_office.jpg | Bin 0 -> 299166 bytes .../visuals/kafka_contemporary_office.png | Bin 0 -> 2245806 bytes templates/visuals/kafka_office.jpg | Bin 0 -> 279237 bytes templates/visuals/kafka_office.png | Bin 0 -> 2125621 bytes templates/visuals/kafka_researcher_office.jpg | Bin 0 -> 299387 bytes templates/visuals/kafka_researcher_office.png | Bin 0 -> 2243755 bytes templates/visuals/researcher_avatar.jpg | Bin 0 -> 16974 bytes 17 files changed, 876 insertions(+), 315 deletions(-) create mode 100644 .claude/launch.json create mode 100644 personas.py create mode 100644 templates/visuals/contemporary_avatar.jpg create mode 100644 templates/visuals/kafka_avatar.jpg create mode 100644 templates/visuals/kafka_contemporary_office.jpg create mode 100644 templates/visuals/kafka_contemporary_office.png create mode 100644 templates/visuals/kafka_office.jpg create mode 100644 templates/visuals/kafka_office.png create mode 100644 templates/visuals/kafka_researcher_office.jpg create mode 100644 templates/visuals/kafka_researcher_office.png create mode 100644 templates/visuals/researcher_avatar.jpg diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..1b6900b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "kafka-chat", + "runtimeExecutable": "/Users/Philipp/opt/anaconda3/envs/docling/bin/python", + "runtimeArgs": ["-m", "uvicorn", "server:app", "--port", "8010"], + "port": 8010 + } + ] +} diff --git a/README.md b/README.md index f1b431f..58c5700 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ ollama serve In another terminal, pull a model once (example): ```bash -ollama pull gpt-oss:20b +ollama pull gemma4:12b ``` The model name must match what you configure in `server.py` (see [Configuration reference](#configuration-reference)). @@ -170,9 +170,9 @@ Each entry in `retrieved` has: Every answer reports which chunks it was based on, in two places: - **Browser:** open the developer console (Chrome/Edge: `F12` or `Cmd`+`Option`+`I`, Firefox: `F12`) before sending a question. Each answer logs a collapsed group `[RAG] Retrieved N chunk(s)` with the full text, source file and page of every chunk. -- **Server terminal:** the terminal running `uvicorn` prints the rephrased standalone query, the retrieval query, the L2 distances, a 150-character preview of each chunk, and the raw LLM response. +- **Server terminal:** the terminal running `uvicorn` prints the search queries the LLM sent to the corpus search tool (or that it did not search), the scores, a 150-character preview of each chunk, and the raw LLM response. -Use this when an answer looks wrong: if the right chunks were never retrieved, tune `RETRIEVAL_K`, `QUERY_ADDITIONAL_INSTRUCTIONS` or the chunk size; if the chunks were correct but the answer was not, tune `ANSWER_PROMPT` (see [Configuration reference](#configuration-reference)). +Use this when an answer looks wrong: if the right chunks were never retrieved, tune `RETRIEVAL_K`, `QUERY_ADDITIONAL_INSTRUCTIONS` or the chunk size; if the chunks were correct but the answer was not, or the LLM searched when it should not (or vice versa), tune `SYSTEM_PROMPT` (see [Configuration reference](#configuration-reference)). --- @@ -194,7 +194,7 @@ It loads the existing vector store, lists the documents in it, and runs **one ha 2. **Chunk** – `RecursiveCharacterTextSplitter` (defaults in [local_rag.py](local_rag.py)). 3. **Embed** – Hugging Face `sentence-transformers/all-MiniLM-L6-v2`. 4. **Store** – FAISS; retrieval uses `similarity_search_with_score`. -5. **Query** – Optional rephrase with chat history, retrieval, then answer from the LLM. +5. **Query** – The corpus search is a tool the LLM may call (`search_kafka_corpus`). It decides per message whether to search, writes the query itself from the chat history, and answers from the excerpts it found; greetings or thanks are answered without a search. --- @@ -227,8 +227,8 @@ Changing the embedding model or chunk settings requires **rebuilding** the vecto |------|--------| | Default number of chunks retrieved (`k`) | Module-level `RETRIEVAL_K` (overrides: pass `k=` to `query` / `query_with_history`) | | Extra text appended only to the **FAISS query** (biases retrieval, not the final answer phrasing) | `QUERY_ADDITIONAL_INSTRUCTIONS` (concatenated to the search query before embedding) | -| **Rephrase** prompt (standalone question when there is chat history) | String `rephrase_prompt = f"""..."""` inside `query_with_history` | -| **Answer** prompt – opening instructions only | Module-level `ANSWER_PROMPT` (edit the role / style lines). The block from chat history through `Answer:` is built in `query_with_history` | +| **System** prompt – when to search, how to answer | Module-level `SYSTEM_PROMPT` | +| Search tool name, description, searches per message | `SEARCH_TOOL_NAME`, `SEARCH_TOOL_DESCRIPTION`, `MAX_TOOL_ROUNDS` | ### [add_pdfs.py](add_pdfs.py) diff --git a/add_pdfs.py b/add_pdfs.py index a1689bf..cc6a211 100644 --- a/add_pdfs.py +++ b/add_pdfs.py @@ -13,7 +13,7 @@ from local_rag import CHUNK_OVERLAP, CHUNK_SIZE, PDF_LOADER, PROCESSED_DIR, Loca # Folder to scan for PDFs (recursively). DATA_ROOT = Path("/Users/Philipp/Desktop/workspace/python/gpt_publikationen/data_vs") -# The chat (server.py) currently loads ./vectorstore_pypdf_2000; pass --index to build that one. +# The chat uses the vector stores named in personas.py (currently ./vectorstore_pypdf_2000); pass --index to build one. VECTORSTORE_PATH = "./vectorstore" # If True: delete the existing vector store folder, then index all PDFs from scratch. diff --git a/eval_retrieval.py b/eval_retrieval.py index 83e0e23..159b8e0 100644 --- a/eval_retrieval.py +++ b/eval_retrieval.py @@ -39,7 +39,7 @@ GOLDSET_PATH = Path("eval/goldset.csv") # Wohin der Bericht geschrieben wird. Diese Datei gehört ins Repository. REPORT_PATH = Path("eval/baseline.txt") -# Muss mit server.py übereinstimmen, sonst wird ein anderer Index gemessen. +# Muss mit dem Index in personas.py übereinstimmen, sonst wird ein anderer Index gemessen. VECTORSTORE_PATH = "./vectorstore_pypdf_2000" # Für diese k-Werte wird Recall@k berechnet. diff --git a/local_rag.py b/local_rag.py index f40195b..3e2cee7 100644 --- a/local_rag.py +++ b/local_rag.py @@ -66,10 +66,25 @@ QUERY_ADDITIONAL_INSTRUCTIONS = "" # Written next to every index; an index built with another embedding model is not loaded. INDEX_INFO_FILE = "index_info.json" -# Opening instructions for the final answer LLM call only. The rest (history, context, question) -# is assembled in query_with_history. -ANSWER_PROMPT = """You are an assistant for question-answering. Use the chat history (if any) and the retrieved context below to answer the current question. -If you don't know the answer, say so. Keep the conversation coherent.""" +# The corpus search is offered to the LLM as a tool; the LLM decides per message whether +# to search (content questions) or to answer directly (greetings, thanks, questions about +# the conversation). It also writes the search query itself, using the chat history. +SEARCH_TOOL_NAME = "search_kafka_corpus" +SEARCH_TOOL_DESCRIPTION = ( + "Durchsucht die Kafka-Bibliothek (Kafkas Texte und die Forschungsliteratur) und liefert die " + "passendsten Textauszüge mit Quelle und Seite. Die Suchanfrage sollte eine eigenständige, " + "präzise Frage oder Stichwortfolge auf Deutsch sein." +) +MAX_TOOL_ROUNDS = 3 # searches per message before the LLM must answer + +# System prompt for every chat turn (German, like the corpus and the questions). +SYSTEM_PROMPT = f"""Du beantwortest Fragen zu Franz Kafka, seinen Werken, seinem Leben und der Forschung. +Du hast ein Werkzeug, {SEARCH_TOOL_NAME}, das eine Bibliothek aus Kafkas Texten und Forschungsliteratur durchsucht. +- Für jede inhaltliche Frage zu Kafka, seinen Werken, Figuren, seiner Biografie, Sprache oder zur Forschung: Rufe zuerst das Werkzeug auf und stütze die Antwort auf die gefundenen Auszüge. Antworte nicht aus dem Gedächtnis. +- Bei Begrüßung, Dank, Smalltalk oder Fragen zum Gespräch selbst: Antworte direkt, ohne Werkzeug. +- Formuliere die Suchanfrage so, dass sie ohne den Gesprächsverlauf verständlich ist. +- Finden sich in den Auszügen keine Belege, sage das offen, statt zu raten. +- Nenne bei Aussagen aus den Auszügen die Quelle (Autor, Seite).""" class LocalRAG: @@ -77,7 +92,7 @@ class LocalRAG: self, vectorstore_path="./vectorstore", llm_provider="ollama", - ollama_model="gpt-oss:20b", + ollama_model="gemma4:12b", openai_model="gpt-6-luna", ollama_base_url="http://localhost:11434", pdf_loader=PDF_LOADER, @@ -277,18 +292,6 @@ class LocalRAG: return doc_list - def _format_history(self, chat_history): - """Format chat history as a string for prompts.""" - lines = [] - for turn in chat_history or []: - role = (turn.get("role") or "").lower() - content = (turn.get("content") or "").strip() - if role == "user": - lines.append(f"User: {content}") - elif role == "assistant": - lines.append(f"Assistant: {content}") - return "\n".join(lines) if lines else "" - def _docs_to_retrieved(self, docs): """Convert document list to retrieved chunks format for API.""" return [ @@ -376,73 +379,74 @@ class LocalRAG: k = RETRIEVAL_K return self.query_with_history(question, chat_history=[], k=k) - def query_with_history(self, question, chat_history=None, k=None): - """Query the RAG with conversation history: rephrase question using history for retrieval, - then answer with full conversation + retrieved context in the prompt. - Returns dict with 'answer' and 'retrieved' (list of chunks with content, source, page). - """ - if k is None: - k = RETRIEVAL_K + @staticmethod + def _text(message): + """Plain text of an LLM reply (some models return a list of content blocks).""" + content = getattr(message, "content", message) + if isinstance(content, list): + content = "".join(part.get("text", "") if isinstance(part, dict) else str(part) for part in content) + return (content or "").strip() + + def query_with_history(self, question, chat_history=None, k=None, system_prompt=None): + """Answer a chat message. The LLM decides whether to search the corpus: the search is + offered as a tool (SEARCH_TOOL_NAME), which the LLM calls with its own query, up to + MAX_TOOL_ROUNDS times. system_prompt defaults to SYSTEM_PROMPT (see personas.py for others). + Returns dict with 'answer', 'retrieved' (chunks the LLM saw, with content, source, page, + score) and 'searches' (the queries it used).""" + from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + from langchain_core.tools import StructuredTool + + k = k or RETRIEVAL_K if self.vectorstore is None: - return { - "answer": "Error: No documents loaded. Please add documents first.", - "retrieved": [], - } + return {"answer": "Error: No documents loaded. Please add documents first.", + "retrieved": [], "searches": []} - history_str = self._format_history(chat_history) - search_query = question + retrieved, searches = [], [] + def search_kafka_corpus(query: str) -> str: + retrieval_query = query + (f"\n\n{QUERY_ADDITIONAL_INSTRUCTIONS}" if QUERY_ADDITIONAL_INSTRUCTIONS else "") + hits = self.retrieve(retrieval_query, k=k) + searches.append(query) + retrieved.extend(self._docs_scores_to_retrieved(hits)) + print(f"[RAG] Tool search {query!r}: {len(hits)} chunk(s)") + if not hits: + return "Keine passenden Textstellen gefunden." + return "\n\n".join( + f"[{i}] {docling_loader.source_label(Path(d.metadata.get('source', '')).name)}" + f"{', S. ' + str(d.metadata['page'] + 1) if d.metadata.get('page') is not None else ''}\n" + f"{d.page_content.strip()}" + for i, (d, _) in enumerate(hits, start=1)) + + tool = StructuredTool.from_function(func=search_kafka_corpus, name=SEARCH_TOOL_NAME, + description=SEARCH_TOOL_DESCRIPTION) + llm_with_tools = self.llm.bind_tools([tool]) + + messages = [SystemMessage(system_prompt or SYSTEM_PROMPT)] + for turn in chat_history or []: + content = (turn.get("content") or "").strip() + if turn.get("role") == "user": + messages.append(HumanMessage(content)) + elif turn.get("role") == "assistant": + messages.append(AIMessage(content)) + messages.append(HumanMessage(question)) print(f"[RAG] User question: {question!r}") - # 1) If we have history, rephrase the question into a standalone query for better retrieval - if history_str.strip(): - rephrase_prompt = f"""Given this chat history and the latest user question, write a single standalone question that captures what the user is asking. - Do not answer it; only output the standalone question. If the latest question is already clear on its own, output it unchanged. - -Chat history: -{history_str} - -Latest user question: {question} - -Standalone question:""" - rephrase_response = self.llm.invoke(rephrase_prompt) - search_query = (rephrase_response.content if hasattr(rephrase_response, "content") else str(rephrase_response)).strip() or question - print(f"[RAG] Standalone search query (rephrased): {search_query!r}") - - retrieval_query = search_query - if QUERY_ADDITIONAL_INSTRUCTIONS: - retrieval_query += f"\n\n{QUERY_ADDITIONAL_INSTRUCTIONS}" - print(f"[RAG] Search query: {search_query!r}") - print(f"[RAG] Retrieval query sent to vector store: {retrieval_query!r}") - - # 2) Retrieve documents with scores (see retrieve() for what the score means) - docs_with_scores = self.retrieve(retrieval_query, k=k) - docs = [doc for doc, _ in docs_with_scores] - retrieved = self._docs_scores_to_retrieved(docs_with_scores) - if docs_with_scores: - scores = [f"{s:.3f}" for _, s in docs_with_scores] - print(f"[RAG] Retrieved {len(docs)} chunk(s), scores (L2 dist): [{', '.join(scores)}]") - context = "\n\n".join([doc.page_content for doc in docs]) - - # 3) Answer using conversation history + retrieved context - history_block = f"Chat history:\n{history_str}\n\n" if history_str else "" - answer_prompt = ( - ANSWER_PROMPT - + f""" - -{history_block}Relevant context from documents: - -{context} - -Current question: {question} - -Answer:""" - ) - print(f"[RAG] Composed answer prompt:\n{answer_prompt}") - response = self.llm.invoke(answer_prompt) - answer = response.content if hasattr(response, "content") else str(response) - - return {"answer": answer, "retrieved": retrieved} + answer = None + for _ in range(MAX_TOOL_ROUNDS): + reply = llm_with_tools.invoke(messages) + messages.append(reply) + if not reply.tool_calls: + answer = self._text(reply) + break + for n, call in enumerate(reply.tool_calls): + result = tool.invoke(call["args"]) + messages.append(ToolMessage(result, tool_call_id=call.get("id") or f"call_{len(searches)}_{n}")) + if answer is None: + # Search budget used up: answer with what was found, without further tool calls + answer = self._text(self.llm.invoke(messages + [HumanMessage( + "Beantworte jetzt die Frage mit den gefundenen Auszügen, ohne weitere Suche.")])) + print(f"[RAG] Searches: {searches or 'keine'}") + return {"answer": answer, "retrieved": retrieved, "searches": searches} def main(): @@ -452,7 +456,7 @@ def main(): print("=" * 60) # Initialize - rag = LocalRAG(ollama_model="gpt-oss:20b") + rag = LocalRAG(ollama_model="gemma4:12b") # Add documents (uncomment and add your file paths) # rag.add_documents([ diff --git a/personas.py b/personas.py new file mode 100644 index 0000000..47b07ab --- /dev/null +++ b/personas.py @@ -0,0 +1,126 @@ +""" +Gesprächspartner des Chats: Oberflächentexte, Systemprompt und Vektorspeicher je Figur. + +Die Oberfläche lädt die Einträge über /api/personas (ohne Systemprompt und Index); +server.py wählt pro Anfrage den Systemprompt und den Vektorspeicher der Figur. +Alle Figuren nutzen vorerst denselben Vektorspeicher (VECTORSTORE_PATH); für eigene +Korpora je Figur genügt es, "vectorstore" hier zu ändern. +""" + +from local_rag import SEARCH_TOOL_NAME, SYSTEM_PROMPT + +# Kafka-Korpus: PyPDFLoader, Chunks 2000/400 ohne Kopfzeilen, bge-m3, hybride Suche + Reranker. +# Docling war beim Retrieval nicht besser (eval/kafka_human_judge_*.txt), daher vorerst pypdf. +# Gebaut mit: add_pdfs.py --data data --index ./vectorstore_pypdf_2000 --loader pypdf --no-headers +VECTORSTORE_PATH = "./vectorstore_pypdf_2000" + +# Gemeinsame Regeln für das Such-Werkzeug (wie im Systemprompt der Forscherin) +_TOOL_RULES = f"""Du hast ein Werkzeug, {SEARCH_TOOL_NAME}, das eine Bibliothek aus Kafkas Texten und Forschungsliteratur durchsucht. +- Für jede inhaltliche Frage zu Kafka, seinen Werken, Figuren, seiner Biografie, Sprache oder zur Forschung: Rufe zuerst das Werkzeug auf und stütze die Antwort auf die gefundenen Auszüge. Antworte nicht aus dem Gedächtnis. +- Bei Begrüßung, Dank, Smalltalk oder Fragen zum Gespräch selbst: Antworte direkt, ohne Werkzeug. +- Formuliere die Suchanfrage so, dass sie ohne den Gesprächsverlauf verständlich ist. +- Finden sich in den Auszügen keine Belege, sage das offen, statt zu raten. +- Nenne bei Aussagen aus den Auszügen die Quelle (Autor, Seite) knapp in Klammern.""" + +PERSONAS = { + # Die Forscherin: unverändert gegenüber der ersten Fassung der Oberfläche + "researcher": { + "label": "Forscherin", + "name": "Dr. Klara Weiß", + "role": "Literaturwissenschaftlerin, Kafka-Forschung", + "place": "Arbeitszimmer · Prag, am Abend", + "status": "antwortet aus ihrer Kafka-Bibliothek", + "image": "/visuals/kafka_researcher_office.jpg", + "image_alt": "Eine Literaturwissenschaftlerin arbeitet am Abend in ihrem Arbeitszimmer in Prag: " + "Bücherstapel mit Kafkas Werken, ein Porträt Kafkas an der Wand, durchs Fenster " + "die Karlsbrücke in der Dämmerung.", + "image_position": "60% 40%", + "avatar": "/visuals/researcher_avatar.jpg", + "greeting": ( + "Guten Abend. Kommen Sie herein, und schieben Sie die Bücher ruhig ein wenig zur Seite.\n\n" + "Ich arbeite seit vielen Jahren zu Kafka, vor allem zur *Verwandlung*, zu seiner Sprache und zu " + "dem, was die Forschung daraus gemacht hat. Fragen Sie mich, was Sie interessiert: Ich antworte " + "aus den Texten, die hier auf meinem Schreibtisch liegen, und sage Ihnen, wo es steht." + ), + "suggestions": [ + "Was bedeutet der Name Kafka auf Tschechisch?", + "Wie lässt sich das Bild der Dame in Gregors Zimmer deuten?", + "Sprach Kafka Tschechisch?", + "Welche Rolle spielt der Vater in der Verwandlung?", + ], + "system_prompt": SYSTEM_PROMPT, + "vectorstore": VECTORSTORE_PATH, + }, + + # Ein erfundener Zeitgenosse: Prager Feuilletonist um 1915 + "contemporary": { + "label": "Zeitgenosse", + "name": "Viktor Hollmann", + "role": "Feuilletonist, ein Zeitgenosse Kafkas", + "place": "Redaktionsstube · Prag, um 1915", + "status": "erzählt aus dem Prag seiner Zeit", + "image": "/visuals/kafka_contemporary_office.jpg", + "image_alt": "Ein Herr im Tweedanzug mit runder Brille und Schnurrbart sitzt am Abend an seinem " + "Schreibtisch in Prag und erzählt gestikulierend; hinter ihm durchs Fenster die " + "Karlsbrücke und die Burg in der Dämmerung.", + "image_position": "62% 38%", + "avatar": "/visuals/contemporary_avatar.jpg", + "greeting": ( + "Guten Abend, treten Sie nur ein! Nehmen Sie Platz, der Kaffee ist noch warm.\n\n" + "Ich schreibe fürs Feuilleton und kenne das literarische Prag dieser Jahre recht gut, die " + "Kaffeehäuser, die Verlage, die Lesungen, und natürlich auch die merkwürdigen Erzählungen dieses " + "stillen Herrn Kafka. Fragen Sie nur; was ich nicht selbst weiß, schlage ich für Sie nach." + ), + "suggestions": [ + "Wie war das literarische Prag zu Kafkas Zeit?", + "Wie wurde die Verwandlung bei ihrem Erscheinen aufgenommen?", + "Was war Kafka für ein Mensch?", + "Welche Sprachen sprach man in Kafkas Familie?", + ], + "system_prompt": f"""Du bist Viktor Hollmann, ein erfundener Prager Feuilletonist um 1915 und Zeitgenosse Franz Kafkas. +Du sprichst in der Ich-Form, höflich und ein wenig altmodisch, im Ton des frühen 20. Jahrhunderts, und siezt dein Gegenüber. +Du kennst Prag, seine Kaffeehäuser und den Literaturbetrieb jener Jahre. +{_TOOL_RULES} +- Die Auszüge stammen teils aus viel späterer Forschung. Gib ihren Inhalt wieder, als hättest du davon gehört oder gelesen. Erfinde keine Begegnungen, Zitate oder Ereignisse, die nicht in den Auszügen stehen.""", + "vectorstore": VECTORSTORE_PATH, + }, + + # Kafka selbst: eine Annäherung aus seinen Texten, keine echte Stimme + "kafka": { + "label": "Autor", + "name": "Franz Kafka", + "role": "Schriftsteller, Prag", + "place": "Arbeitszimmer · Prag, in der Nacht", + "status": "eine Annäherung aus seinen Texten", + "image": "/visuals/kafka_office.jpg", + "image_alt": "Franz Kafka sitzt nachts in seinem Arbeitszimmer in Prag am Schreibtisch, den Kopf " + "in die Hand gestützt; an der Wand Käferzeichnungen, im Fenster eine Katze, dahinter " + "die Karlsbrücke im Mondlicht.", + "image_position": "68% 40%", + "avatar": "/visuals/kafka_avatar.jpg", + "greeting": ( + "Guten Abend. Sie finden mich am Schreibtisch, wie meistens um diese Stunde; nachts schreibt es " + "sich leichter, oder es schreibt sich überhaupt nicht.\n\n" + "Fragen Sie, wenn Sie wollen. Ob ich die richtigen Antworten habe, weiß ich nicht. Aber ich will " + "versuchen, sie in meinen Papieren zu finden." + ), + "suggestions": [ + "Wie ist die Verwandlung entstanden?", + "Was bedeutet Ihnen das Schreiben?", + "Wie stehen Sie zu Ihrem Vater?", + "Warum wird Gregor zu einem Ungeziefer?", + ], + "system_prompt": f"""Du sprichst als Franz Kafka (1883–1924), in einer behutsamen Annäherung aus seinen Texten. +Du sprichst in der Ich-Form, ruhig, genau, zweifelnd und selbstironisch, im Ton seiner Briefe und Tagebücher, und siezt dein Gegenüber. +{_TOOL_RULES} +- Stütze Aussagen über dein Leben und Werk auf die gefundenen Auszüge. Wo sie nichts hergeben, sag, dass du es nicht weißt oder nicht sagen kannst. Erfinde keine Zitate. +- Die Auszüge stammen teils aus der Forschung über dich. Du darfst darauf mit Befremden oder Ironie reagieren ("man schreibt, ich hätte …").""", + "vectorstore": VECTORSTORE_PATH, + }, +} + +DEFAULT_PERSONA = "researcher" + +# Felder, die die Oberfläche bekommt (Systemprompt und Index bleiben auf dem Server) +PUBLIC_FIELDS = ("label", "name", "role", "place", "status", "image", "image_alt", "image_position", + "avatar", "greeting", "suggestions") diff --git a/server.py b/server.py index 48f5845..1c60217 100644 --- a/server.py +++ b/server.py @@ -7,37 +7,39 @@ from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from local_rag import LocalRAG +from personas import DEFAULT_PERSONA, PERSONAS, PUBLIC_FIELDS -OLLAMA_MODEL = "gpt-oss:20b" +OLLAMA_MODEL = "gemma4:12b" OPENAI_MODEL = "gpt-6-luna" -# Kafka corpus: PyPDFLoader, chunks 2000/400 without headers, bge-m3, hybrid search + reranker. -# Docling was not better at retrieval (eval/kafka_human_judge_*.txt), so pypdf stays for now. -# built with: add_pdfs.py --data data --index ./vectorstore_pypdf_2000 --loader pypdf --no-headers -VECTORSTORE_PATH = "./vectorstore_pypdf_2000" - -# Dual RAG instances for on-the-fly provider switching -rag_ollama = LocalRAG( - vectorstore_path=VECTORSTORE_PATH, - llm_provider="ollama", - ollama_model=OLLAMA_MODEL, - openai_model=OPENAI_MODEL, -) -rag_openai = None -if os.environ.get("OPENAI_API_KEY"): - try: - rag_openai = LocalRAG( - vectorstore_path=VECTORSTORE_PATH, - llm_provider="openai", - ollama_model=OLLAMA_MODEL, - openai_model=OPENAI_MODEL, - ) - except Exception as e: - print(f"OpenAI RAG not available: {e}") +# One RAG instance per (LLM provider, vector store), for on-the-fly provider switching. +# Each persona names its vector store in personas.py; personas sharing one share the instance. +rags = {} +for vectorstore in sorted({p["vectorstore"] for p in PERSONAS.values()}): + rags[("ollama", vectorstore)] = LocalRAG( + vectorstore_path=vectorstore, + llm_provider="ollama", + ollama_model=OLLAMA_MODEL, + openai_model=OPENAI_MODEL, + ) + if os.environ.get("OPENAI_API_KEY"): + try: + rags[("openai", vectorstore)] = LocalRAG( + vectorstore_path=vectorstore, + llm_provider="openai", + ollama_model=OLLAMA_MODEL, + openai_model=OPENAI_MODEL, + ) + except Exception as e: + print(f"OpenAI RAG not available for {vectorstore}: {e}") +openai_available = any(provider == "openai" for provider, _ in rags) app = FastAPI(title="Local RAG Chat", version="1.0.0") +# Images for the chat GUI (researcher portrait, avatar) +app.mount("/visuals", StaticFiles(directory=Path(__file__).parent / "templates" / "visuals"), name="visuals") class ChatMessage(BaseModel): @@ -49,6 +51,7 @@ class ChatRequest(BaseModel): message: str history: list[ChatMessage] = [] # previous turns for conversation context llm_provider: str = "ollama" # "ollama" | "openai" + persona: str = DEFAULT_PERSONA # key in personas.PERSONAS class RetrievedChunk(BaseModel): @@ -62,6 +65,7 @@ class ChatResponse(BaseModel): answer: str error: str | None = None retrieved: list[RetrievedChunk] | None = None + searches: list[str] | None = None # queries the LLM sent to the corpus search tool @app.get("/", response_class=HTMLResponse) @@ -73,11 +77,11 @@ def chat_view(): return HTMLResponse(content=html_path.read_text(encoding="utf-8")) -def _get_rag(provider: str): - """Return the RAG instance for the given provider. Fall back to Ollama if OpenAI unavailable.""" - if provider == "openai" and rag_openai is not None: - return rag_openai - return rag_ollama +def _get_rag(provider: str, persona: str): + """Return the RAG instance for the provider and the persona's vector store. + Fall back to Ollama if OpenAI is unavailable.""" + vectorstore = PERSONAS[persona]["vectorstore"] + return rags.get((provider, vectorstore)) or rags[("ollama", vectorstore)] @app.post("/api/chat", response_model=ChatResponse) @@ -85,17 +89,22 @@ def chat(request: ChatRequest): """Handle a chat message and return the RAG answer.""" if not request.message or not request.message.strip(): return ChatResponse(answer="", error="Message cannot be empty") - if request.llm_provider == "openai" and rag_openai is None: + if request.llm_provider == "openai" and not openai_available: return ChatResponse(answer="", error="OpenAI not configured. Set OPENAI_API_KEY.") - rag = _get_rag(request.llm_provider) + if request.persona not in PERSONAS: + return ChatResponse(answer="", error=f"Unknown persona: {request.persona}") + persona = PERSONAS[request.persona] + rag = _get_rag(request.llm_provider, request.persona) try: chat_history = [{"role": m.role, "content": m.content} for m in request.history] result = rag.query_with_history( request.message.strip(), chat_history=chat_history, + system_prompt=persona["system_prompt"], ) answer = result["answer"] retrieved = result.get("retrieved", []) + searches = result.get("searches", []) # Server-side console trace: shorter chunk logs + raw LLM response if retrieved: @@ -105,12 +114,12 @@ def chat(request: ChatRequest): preview = (content[:150] + "...") if len(content) > 150 else content print(f" [{i + 1}] {chunk.get('source', '')} p.{chunk.get('page', '?')} s={chunk.get('score')} | {preview!r}") else: - print(f"\n[RAG] Retrieved 0 chunks") + print(f"\n[RAG] No corpus search for this message") provider_label = "OpenAI" if request.llm_provider == "openai" else "Ollama" model_name = OPENAI_MODEL if request.llm_provider == "openai" else OLLAMA_MODEL - print(f"[RAG] LLM response ({provider_label} / {model_name}):\n{answer}") + print(f"[RAG] LLM response ({provider_label} / {model_name}, {request.persona}):\n{answer}") - return ChatResponse(answer=answer, retrieved=retrieved) + return ChatResponse(answer=answer, retrieved=retrieved, searches=searches) except Exception as e: return ChatResponse(answer="", error=str(e)) @@ -118,14 +127,23 @@ def chat(request: ChatRequest): @app.get("/api/health") def health(): """Health check and vector store status.""" - has_docs = rag_ollama.vectorstore is not None + has_docs = all(rag.vectorstore is not None for rag in rags.values()) return {"status": "ok", "vectorstore_loaded": has_docs} @app.get("/api/providers") def providers(): """Return which LLM providers are available.""" - return {"ollama": True, "openai": rag_openai is not None} + return {"ollama": True, "openai": openai_available} + + +@app.get("/api/personas") +def personas(): + """Return the selectable conversation partners (texts and images for the GUI).""" + return { + "default": DEFAULT_PERSONA, + "personas": [{"id": key, **{f: p[f] for f in PUBLIC_FIELDS}} for key, p in PERSONAS.items()], + } if __name__ == "__main__": diff --git a/templates/chat.html b/templates/chat.html index 4258c46..bf99015 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -1,217 +1,559 @@ - + - Local RAG Chat + Im Arbeitszimmer · Gespräch über Kafka + + + -
-

RAG Chat

-

Ask questions about your documents. Answers are generated from a local vector store + Ollama / OpenAI.

-
+
+ +
+

+

+

+ +
+
-
+
+
+ +
+ + Gespräch über Franz Kafka +
+
+ + +
+
-
-
- - +
+ +
+
+ + +
+

Enter sendet · Shift + Enter für eine neue Zeile · Antworten stützen sich auf die Texte im lokalen Korpus

-
- - -
-
+