diff --git a/.env.example b/.env.example index dfe55bb..25a9215 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,9 @@ OLLAMA_URL=http://192.168.1.14:11434 OLLAMA_HOST=http://192.168.1.14:11434 # ChromaDB Configuration -CHROMADB_PATH=/path/to/chromadb +# For Docker: This is automatically set to /app/data/chromadb +# For local development: Set to a local directory path +CHROMADB_PATH=./data/chromadb # OpenAI Configuration OPENAI_API_KEY=your-openai-api-key diff --git a/.gitignore b/.gitignore index 049f9ef..7c1950e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,7 @@ wheels/ # Database files chromadb/ +chromadb_openai/ +chroma_db/ database/ *.db diff --git a/classifier.py b/classifier.py new file mode 100644 index 0000000..e49886a --- /dev/null +++ b/classifier.py @@ -0,0 +1,13 @@ +import os + +from llm import LLMClient + +USE_OPENAI = os.getenv("OLLAMA_URL") + + +class Classifier: + def __init__(self): + self.llm_client = LLMClient() + + def classify_query_by_action(self, query): + _prompt = "Classify the query into one of the following options: " diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a6ad8f7..4716f9e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,7 +15,7 @@ services: timeout: 5s retries: 5 - raggr-backend: + raggr: build: context: ./services/raggr dockerfile: Dockerfile.dev @@ -28,7 +28,7 @@ services: - PAPERLESS_TOKEN=${PAPERLESS_TOKEN} - BASE_URL=${BASE_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - - CHROMADB_PATH=/app/chromadb + - CHROMADB_PATH=/app/data/chromadb - OPENAI_API_KEY=${OPENAI_API_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY} - OIDC_ISSUER=${OIDC_ISSUER} @@ -39,64 +39,28 @@ services: - DATABASE_URL=postgres://raggr:raggr_dev_password@postgres:5432/raggr - FLASK_ENV=development - PYTHONUNBUFFERED=1 + - NODE_ENV=development depends_on: postgres: condition: service_healthy volumes: - # Persist data only - - chromadb_data:/app/chromadb - # Share frontend dist with frontend container - - frontend_dist:/app/raggr-frontend/dist + - chromadb_data:/app/data/chromadb develop: watch: - # Sync Python source files - - action: sync + # Sync+restart on any file change under services/raggr + - action: sync+restart path: ./services/raggr target: /app ignore: - - raggr-frontend/ - __pycache__/ - "*.pyc" - "*.pyo" - "*.pyd" - .git/ - chromadb/ - # Sync+restart on frontend dist changes - - action: sync+restart - path: ./services/raggr/raggr-frontend/dist - target: /app/raggr-frontend/dist - # Restart on dependency changes - - action: rebuild - path: ./services/raggr/pyproject.toml - - action: rebuild - path: ./services/raggr/uv.lock - - raggr-frontend: - build: - context: ./services/raggr/raggr-frontend - dockerfile: Dockerfile.dev - environment: - - NODE_ENV=development - volumes: - # Share dist folder with backend - - frontend_dist:/app/dist - develop: - watch: - # Sync frontend source files - - action: sync - path: ./services/raggr/raggr-frontend - target: /app - ignore: - node_modules/ - - dist/ - - .git/ - # Rebuild on dependency changes - - action: rebuild - path: ./services/raggr/raggr-frontend/package.json - - action: rebuild - path: ./services/raggr/raggr-frontend/yarn.lock + - raggr-frontend/dist/ volumes: chromadb_data: postgres_data: - frontend_dist: diff --git a/docker-compose.yml b/docker-compose.yml index e35e5f2..ecf22d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: - PAPERLESS_TOKEN=${PAPERLESS_TOKEN} - BASE_URL=${BASE_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - - CHROMADB_PATH=/app/chromadb + - CHROMADB_PATH=/app/data/chromadb - OPENAI_API_KEY=${OPENAI_API_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY} - OIDC_ISSUER=${OIDC_ISSUER} @@ -39,7 +39,7 @@ services: postgres: condition: service_healthy volumes: - - chromadb_data:/app/chromadb + - chromadb_data:/app/data/chromadb restart: unless-stopped volumes: diff --git a/services/raggr/Dockerfile.dev b/services/raggr/Dockerfile.dev index b5984af..0f6ed00 100644 --- a/services/raggr/Dockerfile.dev +++ b/services/raggr/Dockerfile.dev @@ -2,10 +2,13 @@ FROM python:3.13-slim WORKDIR /app -# Install system dependencies and uv +# Install system dependencies, Node.js, uv, and yarn RUN apt-get update && apt-get install -y \ build-essential \ curl \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + && npm install -g yarn \ && rm -rf /var/lib/apt/lists/* \ && curl -LsSf https://astral.sh/uv/install.sh | sh @@ -18,15 +21,23 @@ COPY pyproject.toml ./ # Install Python dependencies using uv RUN uv pip install --system -e . -# Create ChromaDB and database directories -RUN mkdir -p /app/chromadb /app/database - -# Expose port -EXPOSE 8080 +# Copy frontend package files and install dependencies +COPY raggr-frontend/package.json raggr-frontend/yarn.lock* raggr-frontend/ +WORKDIR /app/raggr-frontend +RUN yarn install # Copy application source code +WORKDIR /app COPY . . +# Build frontend +WORKDIR /app/raggr-frontend +RUN yarn build + +# Create ChromaDB and database directories +WORKDIR /app +RUN mkdir -p /app/chromadb /app/database + # Make startup script executable RUN chmod +x /app/startup-dev.sh @@ -35,5 +46,8 @@ ENV PYTHONPATH=/app ENV CHROMADB_PATH=/app/chromadb ENV PYTHONUNBUFFERED=1 +# Expose port +EXPOSE 8080 + # Default command CMD ["/app/startup-dev.sh"] diff --git a/services/raggr/VECTORSTORE.md b/services/raggr/VECTORSTORE.md new file mode 100644 index 0000000..645f4f0 --- /dev/null +++ b/services/raggr/VECTORSTORE.md @@ -0,0 +1,97 @@ +# Vector Store Management + +This document describes how to manage the ChromaDB vector store used for RAG (Retrieval-Augmented Generation). + +## Configuration + +The vector store location is controlled by the `CHROMADB_PATH` environment variable: + +- **Development (local)**: Set in `.env` to a local path (e.g., `/path/to/chromadb`) +- **Docker**: Automatically set to `/app/data/chromadb` and persisted via Docker volume + +## Management Commands + +### CLI (Command Line) + +Use the `manage_vectorstore.py` script for vector store operations: + +```bash +# Show statistics +python manage_vectorstore.py stats + +# Index documents from Paperless-NGX (incremental) +python manage_vectorstore.py index + +# Clear and reindex all documents +python manage_vectorstore.py reindex + +# List documents +python manage_vectorstore.py list 10 +python manage_vectorstore.py list 20 --show-content +``` + +### Docker + +Run commands inside the Docker container: + +```bash +# Show statistics +docker compose -f docker-compose.dev.yml exec -T raggr python manage_vectorstore.py stats + +# Reindex all documents +docker compose -f docker-compose.dev.yml exec -T raggr python manage_vectorstore.py reindex +``` + +### API Endpoints + +The following authenticated endpoints are available: + +- `GET /api/rag/stats` - Get vector store statistics +- `POST /api/rag/index` - Trigger indexing of new documents +- `POST /api/rag/reindex` - Clear and reindex all documents + +## How It Works + +1. **Document Fetching**: Documents are fetched from Paperless-NGX via the API +2. **Chunking**: Documents are split into chunks of ~1000 characters with 200 character overlap +3. **Embedding**: Chunks are embedded using OpenAI's `text-embedding-3-large` model +4. **Storage**: Embeddings are stored in ChromaDB with metadata (filename, document type, date) +5. **Retrieval**: User queries are embedded and similar chunks are retrieved for RAG + +## Troubleshooting + +### "Error creating hnsw segment reader" + +This indicates a corrupted index. Solution: + +```bash +python manage_vectorstore.py reindex +``` + +### Empty results + +Check if documents are indexed: + +```bash +python manage_vectorstore.py stats +``` + +If count is 0, run: + +```bash +python manage_vectorstore.py index +``` + +### Different results in Docker vs local + +Docker and local environments use separate ChromaDB instances. To sync: + +1. Index inside Docker: `docker compose exec -T raggr python manage_vectorstore.py reindex` +2. Or mount the same volume for both environments + +## Production Considerations + +1. **Volume Persistence**: Use Docker volumes or persistent storage for ChromaDB +2. **Backup**: Regularly backup the ChromaDB data directory +3. **Reindexing**: Schedule periodic reindexing to keep data fresh +4. **Monitoring**: Monitor the `/api/rag/stats` endpoint for document counts diff --git a/services/raggr/app.py b/services/raggr/app.py index cc18e0e..401dd47 100644 --- a/services/raggr/app.py +++ b/services/raggr/app.py @@ -6,6 +6,7 @@ from tortoise.contrib.quart import register_tortoise import blueprints.conversation import blueprints.conversation.logic +import blueprints.rag import blueprints.users import blueprints.users.models from main import consult_simba_oracle @@ -22,6 +23,7 @@ jwt = JWTManager(app) # Register blueprints app.register_blueprint(blueprints.users.user_blueprint) app.register_blueprint(blueprints.conversation.conversation_blueprint) +app.register_blueprint(blueprints.rag.rag_blueprint) # Database configuration with environment variable support diff --git a/services/raggr/blueprints/conversation/__init__.py b/services/raggr/blueprints/conversation/__init__.py index dbb38ed..81f8461 100644 --- a/services/raggr/blueprints/conversation/__init__.py +++ b/services/raggr/blueprints/conversation/__init__.py @@ -1,6 +1,6 @@ import datetime -from quart import Blueprint, jsonify +from quart import Blueprint, jsonify, request from quart_jwt_extended import ( get_jwt_identity, jwt_refresh_token_required, @@ -8,7 +8,13 @@ from quart_jwt_extended import ( import blueprints.users.models -from .logic import rename_conversation +from .agents import main_agent +from .logic import ( + add_message_to_conversation, + get_conversation_by_id, + get_conversation_transcript, + rename_conversation, +) from .models import ( Conversation, PydConversation, @@ -20,6 +26,51 @@ conversation_blueprint = Blueprint( ) +@conversation_blueprint.post("/query") +@jwt_refresh_token_required +async def query(): + current_user_uuid = get_jwt_identity() + user = await blueprints.users.models.User.get(id=current_user_uuid) + data = await request.get_json() + query = data.get("query") + conversation_id = data.get("conversation_id") + conversation = await get_conversation_by_id(conversation_id) + await conversation.fetch_related("messages") + await add_message_to_conversation( + conversation=conversation, + message=query, + speaker="user", + user=user, + ) + + transcript = await get_conversation_transcript(user=user, conversation=conversation) + + transcript_prompt = f"Here is the message transcript thus far {transcript}." + prompt = f"""Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive. + {transcript_prompt if len(transcript) > 0 else ""} + Respond to this prompt: {query}""" + + payload = { + "messages": [ + { + "role": "system", + "content": "You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.\n\nIMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.", + }, + {"role": "user", "content": prompt}, + ] + } + + response = await main_agent.ainvoke(payload) + message = response.get("messages", [])[-1].content + await add_message_to_conversation( + conversation=conversation, + message=message, + speaker="simba", + user=user, + ) + return jsonify({"response": message}) + + @conversation_blueprint.route("/") @jwt_refresh_token_required async def get_conversation(conversation_id: str): diff --git a/services/raggr/blueprints/conversation/agents.py b/services/raggr/blueprints/conversation/agents.py new file mode 100644 index 0000000..02d5394 --- /dev/null +++ b/services/raggr/blueprints/conversation/agents.py @@ -0,0 +1,36 @@ +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_openai import ChatOpenAI + +from blueprints.rag.logic import query_vector_store + +openai_gpt_5_mini = ChatOpenAI(model="gpt-5-mini") + + +@tool(response_format="content_and_artifact") +async def simba_search(query: str): + """Search through Simba's medical records, veterinary documents, and personal information. + + Use this tool whenever the user asks questions about: + - Simba's health history, medical records, or veterinary visits + - Medications, treatments, or diagnoses + - Weight, diet, or physical characteristics over time + - Veterinary recommendations or advice + - Ryan's (the owner's) information related to Simba + - Any factual information that would be found in documents + + Args: + query: The user's question or information need about Simba + + Returns: + Relevant information from Simba's documents + """ + print(f"[SIMBA SEARCH] Tool called with query: {query}") + serialized, docs = await query_vector_store(query=query) + print(f"[SIMBA SEARCH] Found {len(docs)} documents") + print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}") + print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}") + return serialized, docs + + +main_agent = create_agent(model=openai_gpt_5_mini, tools=[simba_search]) diff --git a/services/raggr/blueprints/conversation/logic.py b/services/raggr/blueprints/conversation/logic.py index 5359386..8129ffc 100644 --- a/services/raggr/blueprints/conversation/logic.py +++ b/services/raggr/blueprints/conversation/logic.py @@ -74,7 +74,7 @@ async def rename_conversation( prompt = f"Summarize the following conversation into a sassy one-liner title:\n\n{messages}" response = structured_llm.invoke(prompt) - new_name: str = response.get("title") + new_name: str = response.get("title", "") conversation.name = new_name await conversation.save() return new_name diff --git a/services/raggr/blueprints/rag/__init__.py b/services/raggr/blueprints/rag/__init__.py new file mode 100644 index 0000000..d039af5 --- /dev/null +++ b/services/raggr/blueprints/rag/__init__.py @@ -0,0 +1,46 @@ +from quart import Blueprint, jsonify +from quart_jwt_extended import jwt_refresh_token_required + +from .logic import get_vector_store_stats, index_documents, vector_store + +rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag") + + +@rag_blueprint.get("/stats") +@jwt_refresh_token_required +async def get_stats(): + """Get vector store statistics.""" + stats = get_vector_store_stats() + return jsonify(stats) + + +@rag_blueprint.post("/index") +@jwt_refresh_token_required +async def trigger_index(): + """Trigger indexing of documents from Paperless-NGX.""" + try: + await index_documents() + stats = get_vector_store_stats() + return jsonify({"status": "success", "stats": stats}) + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 500 + + +@rag_blueprint.post("/reindex") +@jwt_refresh_token_required +async def trigger_reindex(): + """Clear and reindex all documents.""" + try: + # Clear existing documents + collection = vector_store._collection + all_docs = collection.get() + + if all_docs["ids"]: + collection.delete(ids=all_docs["ids"]) + + # Reindex + await index_documents() + stats = get_vector_store_stats() + return jsonify({"status": "success", "stats": stats}) + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 500 diff --git a/services/raggr/blueprints/rag/fetchers.py b/services/raggr/blueprints/rag/fetchers.py new file mode 100644 index 0000000..a544f76 --- /dev/null +++ b/services/raggr/blueprints/rag/fetchers.py @@ -0,0 +1,75 @@ +import os +import tempfile + +import httpx + + +class PaperlessNGXService: + def __init__(self): + self.base_url = os.getenv("BASE_URL") + self.token = os.getenv("PAPERLESS_TOKEN") + self.url = f"http://{os.getenv('BASE_URL')}/api/documents/?tags__id=8" + self.headers = {"Authorization": f"Token {os.getenv('PAPERLESS_TOKEN')}"} + + def get_data(self): + print(f"Getting data from: {self.url}") + r = httpx.get(self.url, headers=self.headers) + results = r.json()["results"] + + nextLink = r.json().get("next") + + while nextLink: + r = httpx.get(nextLink, headers=self.headers) + results += r.json()["results"] + nextLink = r.json().get("next") + + return results + + def get_doc_by_id(self, doc_id: int): + url = f"http://{os.getenv('BASE_URL')}/api/documents/{doc_id}/" + r = httpx.get(url, headers=self.headers) + return r.json() + + def download_pdf_from_id(self, id: int) -> str: + download_url = f"http://{os.getenv('BASE_URL')}/api/documents/{id}/download/" + response = httpx.get( + download_url, headers=self.headers, follow_redirects=True, timeout=30 + ) + response.raise_for_status() + # Use a temporary file for the downloaded PDF + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") + temp_file.write(response.content) + temp_file.close() + temp_pdf_path = temp_file.name + pdf_to_process = temp_pdf_path + return pdf_to_process + + def upload_cleaned_content(self, document_id, data): + PUTS_URL = f"http://{os.getenv('BASE_URL')}/api/documents/{document_id}/" + r = httpx.put(PUTS_URL, headers=self.headers, data=data) + r.raise_for_status() + + def upload_description(self, description_filepath, file, title, exif_date: str): + POST_URL = f"http://{os.getenv('BASE_URL')}/api/documents/post_document/" + files = {"document": ("description_filepath", file, "application/txt")} + data = { + "title": title, + "create": exif_date, + "document_type": 3, + "tags": [7], + } + + r = httpx.post(POST_URL, headers=self.headers, data=data, files=files) + r.raise_for_status() + + def get_tags(self): + GET_URL = f"http://{os.getenv('BASE_URL')}/api/tags/" + r = httpx.get(GET_URL, headers=self.headers) + data = r.json() + return {tag["id"]: tag["name"] for tag in data["results"]} + + def get_doctypes(self): + GET_URL = f"http://{os.getenv('BASE_URL')}/api/document_types/" + r = httpx.get(GET_URL, headers=self.headers) + data = r.json() + return {doctype["id"]: doctype["name"] for doctype in data["results"]} diff --git a/services/raggr/blueprints/rag/logic.py b/services/raggr/blueprints/rag/logic.py new file mode 100644 index 0000000..a858c33 --- /dev/null +++ b/services/raggr/blueprints/rag/logic.py @@ -0,0 +1,101 @@ +import datetime +import os + +from langchain_chroma import Chroma +from langchain_core.documents import Document +from langchain_openai import OpenAIEmbeddings +from langchain_text_splitters import RecursiveCharacterTextSplitter + +from .fetchers import PaperlessNGXService + +embeddings = OpenAIEmbeddings(model="text-embedding-3-large") + +vector_store = Chroma( + collection_name="simba_docs", + embedding_function=embeddings, + persist_directory=os.getenv("CHROMADB_PATH", ""), +) + +text_splitter = RecursiveCharacterTextSplitter( + chunk_size=1000, # chunk size (characters) + chunk_overlap=200, # chunk overlap (characters) + add_start_index=True, # track index in original document +) + + +def date_to_epoch(date_str: str) -> float: + split_date = date_str.split("-") + date = datetime.datetime( + int(split_date[0]), + int(split_date[1]), + int(split_date[2]), + 0, + 0, + 0, + ) + + return date.timestamp() + + +async def fetch_documents_from_paperless_ngx() -> list[Document]: + ppngx = PaperlessNGXService() + data = ppngx.get_data() + doctypes = ppngx.get_doctypes() + documents = [] + for doc in data: + metadata = { + "created_date": date_to_epoch(doc["created_date"]), + "filename": doc["original_file_name"], + "document_type": doctypes.get(doc["document_type"], ""), + } + documents.append(Document(page_content=doc["content"], metadata=metadata)) + + return documents + + +async def index_documents(): + documents = await fetch_documents_from_paperless_ngx() + + splits = text_splitter.split_documents(documents) + await vector_store.aadd_documents(documents=splits) + + +async def query_vector_store(query: str): + retrieved_docs = vector_store.similarity_search(query, k=2) + serialized = "\n\n".join( + (f"Source: {doc.metadata}\nContent: {doc.page_content}") + for doc in retrieved_docs + ) + return serialized, retrieved_docs + + +def get_vector_store_stats(): + """Get statistics about the vector store.""" + collection = vector_store._collection + count = collection.count() + return { + "total_documents": count, + "collection_name": collection.name, + } + + +def list_all_documents(limit: int = 10): + """List documents in the vector store with their metadata.""" + collection = vector_store._collection + results = collection.get(limit=limit, include=["metadatas", "documents"]) + + documents = [] + for i, doc_id in enumerate(results["ids"]): + documents.append( + { + "id": doc_id, + "metadata": results["metadatas"][i] + if results.get("metadatas") + else None, + "content_preview": results["documents"][i][:200] + if results.get("documents") + else None, + } + ) + + return documents diff --git a/services/raggr/blueprints/rag/models.py b/services/raggr/blueprints/rag/models.py new file mode 100644 index 0000000..e69de29 diff --git a/services/raggr/index_immich.py b/services/raggr/index_immich.py index e87f0ce..fbf7756 100644 --- a/services/raggr/index_immich.py +++ b/services/raggr/index_immich.py @@ -1,18 +1,16 @@ -import httpx -import os -from pathlib import Path import logging -import tempfile +import os +import sqlite3 + +import httpx +from dotenv import load_dotenv from image_process import describe_simba_image from request import PaperlessNGXService -import sqlite3 logging.basicConfig(level=logging.INFO) -from dotenv import load_dotenv - load_dotenv() # Configuration from environment variables @@ -89,7 +87,7 @@ if __name__ == "__main__": image_date = description.image_date description_filepath = os.path.join( - "/Users/ryanchen/Programs/raggr", f"SIMBA_DESCRIBE_001.txt" + "/Users/ryanchen/Programs/raggr", "SIMBA_DESCRIBE_001.txt" ) file = open(description_filepath, "w+") file.write(image_description) diff --git a/services/raggr/inspect_vector_store.py b/services/raggr/inspect_vector_store.py new file mode 100644 index 0000000..66897d7 --- /dev/null +++ b/services/raggr/inspect_vector_store.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""CLI tool to inspect the vector store contents.""" + +import argparse +import os + +from dotenv import load_dotenv + +from blueprints.rag.logic import ( + get_vector_store_stats, + index_documents, + list_all_documents, +) + +# Load .env from the root directory +root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +env_path = os.path.join(root_dir, ".env") +load_dotenv(env_path) + + +def print_stats(): + """Print vector store statistics.""" + stats = get_vector_store_stats() + print("=== Vector Store Statistics ===") + print(f"Collection Name: {stats['collection_name']}") + print(f"Total Documents: {stats['total_documents']}") + print() + + +def print_documents(limit: int = 10, show_content: bool = False): + """Print documents in the vector store.""" + docs = list_all_documents(limit=limit) + print(f"=== Documents (showing {len(docs)} of {limit} requested) ===\n") + + for i, doc in enumerate(docs, 1): + print(f"Document {i}:") + print(f" ID: {doc['id']}") + print(f" Metadata: {doc['metadata']}") + if show_content: + print(f" Content Preview: {doc['content_preview']}") + print() + + +async def run_index(): + """Run the indexing process.""" + print("Starting indexing process...") + await index_documents() + print("Indexing complete!") + print_stats() + + +def main(): + import asyncio + + parser = argparse.ArgumentParser(description="Inspect the vector store contents") + parser.add_argument( + "--stats", action="store_true", help="Show vector store statistics" + ) + parser.add_argument( + "--list", type=int, metavar="N", help="List N documents from the vector store" + ) + parser.add_argument( + "--show-content", + action="store_true", + help="Show content preview when listing documents", + ) + parser.add_argument( + "--index", + action="store_true", + help="Index documents from Paperless-NGX into the vector store", + ) + + args = parser.parse_args() + + # Handle indexing first if requested + if args.index: + asyncio.run(run_index()) + return + + # If no arguments provided, show stats by default + if not any([args.stats, args.list]): + args.stats = True + + if args.stats: + print_stats() + + if args.list: + print_documents(limit=args.list, show_content=args.show_content) + + +if __name__ == "__main__": + main() diff --git a/services/raggr/main.py b/services/raggr/main.py index 5ee4740..838a072 100644 --- a/services/raggr/main.py +++ b/services/raggr/main.py @@ -1,23 +1,19 @@ +import argparse import datetime import logging import os import sqlite3 - -import argparse -import chromadb -import ollama - import time +import ollama +from dotenv import load_dotenv -from request import PaperlessNGXService +import chromadb from chunker import Chunker from cleaner import pdf_to_image, summarize_pdf_image from llm import LLMClient from query import QueryGenerator - - -from dotenv import load_dotenv +from request import PaperlessNGXService _dotenv_loaded = load_dotenv() @@ -186,7 +182,7 @@ def consult_oracle( def llm_chat(input: str, transcript: str = "") -> str: system_prompt = "You are a helpful assistant that understands veterinary terms." transcript_prompt = f"Here is the message transcript thus far {transcript}." - prompt = f"""Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive. + prompt = f"""Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive. {transcript_prompt if len(transcript) > 0 else ""} Respond to this prompt: {input}""" output = llm_client.chat(prompt=prompt, system_prompt=system_prompt) diff --git a/services/raggr/manage_vectorstore.py b/services/raggr/manage_vectorstore.py new file mode 100644 index 0000000..91b1e20 --- /dev/null +++ b/services/raggr/manage_vectorstore.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Management script for vector store operations.""" + +import argparse +import asyncio +import sys + +from blueprints.rag.logic import ( + get_vector_store_stats, + index_documents, + list_all_documents, + vector_store, +) + + +def stats(): + """Show vector store statistics.""" + stats = get_vector_store_stats() + print("=== Vector Store Statistics ===") + print(f"Collection: {stats['collection_name']}") + print(f"Total Documents: {stats['total_documents']}") + + +async def index(): + """Index documents from Paperless-NGX.""" + print("Starting indexing process...") + print("Fetching documents from Paperless-NGX...") + await index_documents() + print("āœ“ Indexing complete!") + stats() + + +async def reindex(): + """Clear and reindex all documents.""" + print("Clearing existing documents...") + collection = vector_store._collection + all_docs = collection.get() + + if all_docs["ids"]: + print(f"Deleting {len(all_docs['ids'])} existing documents...") + collection.delete(ids=all_docs["ids"]) + print("āœ“ Cleared") + else: + print("Collection is already empty") + + await index() + + +def list_docs(limit: int = 10, show_content: bool = False): + """List documents in the vector store.""" + docs = list_all_documents(limit=limit) + print(f"\n=== Documents (showing {len(docs)}) ===\n") + + for i, doc in enumerate(docs, 1): + print(f"Document {i}:") + print(f" ID: {doc['id']}") + print(f" Metadata: {doc['metadata']}") + if show_content: + print(f" Content: {doc['content_preview']}") + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Manage vector store for RAG system", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s stats # Show vector store statistics + %(prog)s index # Index new documents from Paperless-NGX + %(prog)s reindex # Clear and reindex all documents + %(prog)s list 10 # List first 10 documents + %(prog)s list 20 --show-content # List 20 documents with content preview + """, + ) + + subparsers = parser.add_subparsers(dest="command", help="Command to execute") + + # Stats command + subparsers.add_parser("stats", help="Show vector store statistics") + + # Index command + subparsers.add_parser("index", help="Index documents from Paperless-NGX") + + # Reindex command + subparsers.add_parser("reindex", help="Clear and reindex all documents") + + # List command + list_parser = subparsers.add_parser("list", help="List documents in vector store") + list_parser.add_argument( + "limit", type=int, default=10, nargs="?", help="Number of documents to list" + ) + list_parser.add_argument( + "--show-content", action="store_true", help="Show content preview" + ) + + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + try: + if args.command == "stats": + stats() + elif args.command == "index": + asyncio.run(index()) + elif args.command == "reindex": + asyncio.run(reindex()) + elif args.command == "list": + list_docs(limit=args.limit, show_content=args.show_content) + except KeyboardInterrupt: + print("\n\nOperation cancelled by user") + sys.exit(1) + except Exception as e: + print(f"\nāŒ Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/services/raggr/pyproject.toml b/services/raggr/pyproject.toml index dd96d70..b795328 100644 --- a/services/raggr/pyproject.toml +++ b/services/raggr/pyproject.toml @@ -31,6 +31,9 @@ dependencies = [ "asyncpg>=0.30.0", "langchain-openai>=1.1.6", "langchain>=1.2.0", + "langchain-chroma>=1.0.0", + "langchain-community>=0.4.1", + "jq>=1.10.0", ] [tool.aerich] diff --git a/services/raggr/raggr-frontend/src/api/conversationService.ts b/services/raggr/raggr-frontend/src/api/conversationService.ts index 939a6f6..1f9d429 100644 --- a/services/raggr/raggr-frontend/src/api/conversationService.ts +++ b/services/raggr/raggr-frontend/src/api/conversationService.ts @@ -37,7 +37,7 @@ class ConversationService { conversation_id: string, ): Promise { const response = await userService.fetchWithRefreshToken( - `${this.baseUrl}/query`, + `${this.conversationBaseUrl}/query`, { method: "POST", body: JSON.stringify({ query, conversation_id }), diff --git a/services/raggr/raggr-frontend/src/components/ChatScreen.tsx b/services/raggr/raggr-frontend/src/components/ChatScreen.tsx index 58c4e36..de48f33 100644 --- a/services/raggr/raggr-frontend/src/components/ChatScreen.tsx +++ b/services/raggr/raggr-frontend/src/components/ChatScreen.tsx @@ -40,6 +40,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { const [selectedConversation, setSelectedConversation] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [isLoading, setIsLoading] = useState(false); const messagesEndRef = useRef(null); const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; @@ -131,11 +132,12 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { }, [selectedConversation?.id]); const handleQuestionSubmit = async () => { - if (!query.trim()) return; // Don't submit empty messages + if (!query.trim() || isLoading) return; // Don't submit empty messages or while loading const currMessages = messages.concat([{ text: query, speaker: "user" }]); setMessages(currMessages); setQuery(""); // Clear input immediately after submission + setIsLoading(true); if (simbaMode) { console.log("simba mode activated"); @@ -150,6 +152,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { }, ]), ); + setIsLoading(false); return; } @@ -170,6 +173,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { if (error instanceof Error && error.message.includes("Session expired")) { setAuthenticated(false); } + } finally { + setIsLoading(false); } }; @@ -281,6 +286,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { } return ; })} + {isLoading && }
@@ -294,6 +300,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { handleKeyDown={handleKeyDown} handleQuestionSubmit={handleQuestionSubmit} setSimbaMode={setSimbaMode} + isLoading={isLoading} /> diff --git a/services/raggr/raggr-frontend/src/components/MessageInput.tsx b/services/raggr/raggr-frontend/src/components/MessageInput.tsx index 0ad7a00..6b37623 100644 --- a/services/raggr/raggr-frontend/src/components/MessageInput.tsx +++ b/services/raggr/raggr-frontend/src/components/MessageInput.tsx @@ -1,43 +1,56 @@ import { useEffect, useState, useRef } from "react"; type MessageInputProps = { - handleQueryChange: (event: React.ChangeEvent) => void; - handleKeyDown: (event: React.ChangeEvent) => void; - handleQuestionSubmit: () => void; - setSimbaMode: (sdf: boolean) => void; - query: string; -} + handleQueryChange: (event: React.ChangeEvent) => void; + handleKeyDown: (event: React.ChangeEvent) => void; + handleQuestionSubmit: () => void; + setSimbaMode: (sdf: boolean) => void; + query: string; + isLoading: boolean; +}; -export const MessageInput = ({query, handleKeyDown, handleQueryChange, handleQuestionSubmit, setSimbaMode}: MessageInputProps) => { - return ( -
-
-