diff --git a/README.md b/README.md index cb49da2c..d7e7095f 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ innovation-lab-examples/ |---------|-------------|------------|------------| | [contributors/community_agent](contributors/community_agent/) | AI community growth agent for events and hackathons | Python, uAgents, ASI:One, Tavily | 🟡 Intermediate | | [contributors/news-summarizer-agent](contributors/news-summarizer-agent/) | Fetches top headlines for a topic via NewsAPI and summarizes them with ASI:One, via Chat Protocol | Python, uAgents, NewsAPI, ASI:One | 🟡 Intermediate | +| [contributors/rag-document-qa-agent](contributors/rag-document-qa-agent/) | RAG-powered Document Q&A with LangChain + ChromaDB + Gemini 2.0 Flash | Python, uAgents, LangChain, ChromaDB, Gemini | 🟡 Intermediate | ### 🌐 Web3 & Blockchain diff --git a/contributors/CHANGELOG.md b/contributors/CHANGELOG.md index e3f5b8ab..e569dac9 100644 --- a/contributors/CHANGELOG.md +++ b/contributors/CHANGELOG.md @@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `rag-document-qa-agent/`: RAG-powered Document Q&A Agent using uAgents + LangChain + Gemini 2.0 Flash + ChromaDB (@davi713albano-coder) - `gemini-research-agent/`: Added Gemini-powered research assistant demonstrating the standard Agent Chat Protocol (@Kavurubuvanesh) - `contributors/` folder and contribution guide for community agent examples - `contributors/community_agent/` — moved from repository root; AI community growth agent for events and hackathons diff --git a/contributors/rag-document-qa-agent/.env.example b/contributors/rag-document-qa-agent/.env.example new file mode 100644 index 00000000..ea968f21 --- /dev/null +++ b/contributors/rag-document-qa-agent/.env.example @@ -0,0 +1,8 @@ +# Required: Google Gemini API key for LLM responses +GEMINI_API_KEY=your_gemini_api_key_here + +# Optional: path to a document (PDF or .txt) to auto-ingest on startup +DOCUMENT_PATH= + +# Optional: Agentverse mailbox key (if deploying to Agentverse) +AGENTVERSE_API_KEY=your_agentverse_key_here diff --git a/contributors/rag-document-qa-agent/.gitignore b/contributors/rag-document-qa-agent/.gitignore new file mode 100644 index 00000000..7d655e78 --- /dev/null +++ b/contributors/rag-document-qa-agent/.gitignore @@ -0,0 +1,6 @@ +chroma_db/ +downloads/ +.env +__pycache__/ +*.pyc +.venv/ diff --git a/contributors/rag-document-qa-agent/README.md b/contributors/rag-document-qa-agent/README.md new file mode 100644 index 00000000..fbd650e9 --- /dev/null +++ b/contributors/rag-document-qa-agent/README.md @@ -0,0 +1,207 @@ +# RAG-powered Document Q&A Agent + +![uAgents](https://img.shields.io/badge/uAgents-chat--protocol-blue) +![RAG](https://img.shields.io/badge/RAG-LangChain%20%2B%20ChromaDB-green) +![LLM](https://img.shields.io/badge/LLM-Gemini%202.0%20Flash-orange) +![Python](https://img.shields.io/badge/python-3.10+-blue) + +A Retrieval-Augmented Generation (RAG) agent that ingests PDF or plain-text documents, chunks and embeds them using HuggingFace sentence-transformers, stores vectors in ChromaDB, and answers natural-language questions about the documents via the uAgents Chat Protocol — powered by Google Gemini 2.0 Flash. + +- **Category:** `RAG`, `LLM`, `Integration` +- **Difficulty:** Intermediate + +--- + +## What it does + +1. You provide a document (PDF, .txt, .md, or .csv) via the `ingest` command or a `DOCUMENT_PATH` env var. +2. The document is split into chunks, embedded with `all-MiniLM-L6-v2`, and stored in a local ChromaDB vector store. +3. You ask questions via the Chat Protocol, and the agent retrieves relevant chunks and generates grounded answers using Gemini 2.0 Flash. +4. Answers are strictly based on document content — no hallucinated information. + +--- + +## Tech stack + +| Layer | Technology | +|-------|------------| +| Agent runtime | [uAgents](https://docs.fetch.ai/agents/uaagents/) + Chat Protocol | +| RAG framework | [LangChain](https://python.langchain.com/) | +| LLM | [Google Gemini 2.0 Flash](https://ai.google.dev/) (free tier) | +| Embeddings | [HuggingFace sentence-transformers](https://www.sbert.net/) (`all-MiniLM-L6-v2`) | +| Vector store | [ChromaDB](https://www.trychroma.com/) (local) | +| PDF parsing | [pypdf](https://pypdf.readthedocs.io/) | +| Language | Python 3.10+ | + +**Flow:** User question → ChromaDB retrieval (top-4 chunks) → context injected into prompt → Gemini 2.0 Flash generates answer → plain-text reply. + +--- + +## Prerequisites + +- **Python 3.10+** +- **Google Gemini API key** — [Get one here](https://aistudio.google.com/apikey) (free tier available) + +--- + +## Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `GEMINI_API_KEY` | Yes | Google Gemini API key for LLM responses | +| `DOCUMENT_PATH` | No | Path to a document to auto-ingest on agent startup | +| `AGENTVERSE_API_KEY` | No | Agentverse key for mailbox deployment | + +--- + +## Installation + +```bash +# Navigate to this folder +cd contributors/rag-document-qa-agent + +# Create a virtual environment +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt +``` + +--- + +## Setup + +### 1. Set up environment variables + +```bash +cp .env.example .env +``` + +Edit `.env` and fill in your API key: + +```env +GEMINI_API_KEY=your_gemini_api_key_here +DOCUMENT_PATH= +``` + +### 2. (Optional) Pre-ingest a document + +You can ingest a document before starting the agent: + +```bash +python ingest.py path/to/your/document.pdf +``` + +Or set `DOCUMENT_PATH` in your `.env` to auto-ingest on startup. + +--- + +## Run the Agent + +```bash +python agent.py +``` + +The agent registers on Agentverse (if `AGENTVERSE_API_KEY` is set) or runs locally. Send it a Chat Protocol message to interact. + +--- + +## Usage + +Once the agent is running, interact via the Chat Protocol: + +| Command | Description | +|---------|-------------| +| `ingest ` | Load a PDF or text document into the vector store | +| `status` | Check if a document is currently loaded | +| `` | Ask a question about the loaded document | + +### Example interaction + +```text +You: ingest research-paper.pdf +Agent: Document ingested successfully! + Chunks stored: 47 + You can now ask questions about the document. + +You: What is the main contribution of this paper? +Agent: Based on the document, the main contribution is a novel + approach to retrieval-augmented generation that reduces + hallucination rates by 40% compared to baseline methods... + +You: What methodology was used? +Agent: The paper uses a combination of quantitative evaluation + on standard benchmarks and qualitative analysis of + generated outputs... +``` + +--- + +## Project structure + +``` +contributors/rag-document-qa-agent/ +├── agent.py # uAgent with Chat Protocol +├── rag.py # RAG pipeline (loading, chunking, embedding, QA) +├── ingest.py # Standalone document ingestion script +├── requirements.txt # Python dependencies +├── .env.example # Environment variable template +├── assets/ +│ └── demo.png # Demo screenshot +└── README.md # This file +``` + +--- + +## Architecture + +```text +Document ──► pypdf / text loader + │ + ▼ + RecursiveCharacterTextSplitter + │ + ▼ + HuggingFaceEmbeddings (all-MiniLM-L6-v2) + │ + ▼ + ChromaDB (local) + │ + ▼ + Retriever (top-4 chunks) + │ + ▼ + ChatPromptTemplate + Gemini 2.0 Flash + │ + ▼ + Answer +``` + +--- + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| `GEMINI_API_KEY is not set` | Add your Gemini API key to `.env` | +| `No document is loaded` | Run `ingest ` or set `DOCUMENT_PATH` in `.env` | +| `File not found` | Use an absolute path or path relative to the working directory | +| `ImportError: sentence_transformers` | Run `pip install sentence-transformers` | +| ChromaDB persistence errors | Delete the `chroma_db/` folder and re-ingest | + +--- + +## Demo + +![RAG Document Q&A Demo](./assets/demo.png) + +## Agent Profile + +[View Agent Profile](https://agentverse.ai/) + +--- + +## License + +Apache 2.0 (see repository root [LICENSE](../../LICENSE)). diff --git a/contributors/rag-document-qa-agent/agent.py b/contributors/rag-document-qa-agent/agent.py new file mode 100644 index 00000000..b6203b69 --- /dev/null +++ b/contributors/rag-document-qa-agent/agent.py @@ -0,0 +1,184 @@ +""" +RAG-powered Document Q&A Agent + +A uAgent that ingests PDF or plain-text documents, embeds them with +HuggingFace sentence-transformers, stores vectors in ChromaDB, and +answers natural-language questions via the uAgents Chat Protocol +using Google Gemini 2.0 Flash as the LLM backbone. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from uuid import uuid4 + +from dotenv import load_dotenv +from uagents import Agent, Context, Protocol +from uagents_core.contrib.protocols.chat import ( + ChatAcknowledgement, + ChatMessage, + TextContent, + chat_protocol_spec, +) + +from rag import get_answer, index_document, is_ready + +load_dotenv() + +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") +DOCUMENT_PATH = os.getenv("DOCUMENT_PATH", "") + +SYSTEM_PROMPT = ( + "You are a document Q&A assistant. Answer questions strictly based on " + "the retrieved document context. If the answer is not in the context, " + "say you don't know. Do not hallucinate information. Cite relevant " + "sections when possible." +) + +chat_proto = Protocol(spec=chat_protocol_spec) + + +@chat_proto.on_message(ChatMessage) +async def handle_chat(ctx: Context, sender: str, msg: ChatMessage) -> None: + await ctx.send( + sender, + ChatAcknowledgement( + timestamp=datetime.now(timezone.utc), + acknowledged_msg_id=msg.msg_id, + ), + ) + + user_text = "\n".join( + item.text for item in msg.content if isinstance(item, TextContent) and item.text + ).strip() + + if not user_text: + welcome = ( + "Hi! I'm your Document Q&A Agent powered by RAG.\n\n" + "Before asking questions, make sure a document has been ingested.\n" + "You can ask me anything about the loaded document and I'll " + "answer based on its content.\n\n" + "Commands:\n" + " ingest — load a PDF or .txt file\n" + " status — check if a document is loaded\n" + " — ask a question about the document\n" + ) + await ctx.send( + sender, + ChatMessage( + timestamp=datetime.now(timezone.utc), + msg_id=uuid4(), + content=[TextContent(type="text", text=welcome)], + ), + ) + return + + if user_text.lower().startswith("ingest "): + doc_path = user_text[7:].strip() + if not doc_path: + reply = "Please provide a file path after `ingest`." + else: + try: + chunk_count = index_document(doc_path) + reply = ( + f"Document ingested successfully!\n" + f"Chunks stored: {chunk_count}\n" + f"You can now ask questions about the document." + ) + except FileNotFoundError: + reply = f"File not found: {doc_path}" + except Exception as exc: + reply = f"Error ingesting document: {exc}" + await ctx.send( + sender, + ChatMessage( + timestamp=datetime.now(timezone.utc), + msg_id=uuid4(), + content=[TextContent(type="text", text=reply)], + ), + ) + return + + if user_text.lower().strip() == "status": + if is_ready(): + reply = "A document is loaded and ready for questions." + else: + reply = ( + "No document is currently loaded. " + "Use `ingest ` to load one, or set " + "DOCUMENT_PATH in your .env file." + ) + await ctx.send( + sender, + ChatMessage( + timestamp=datetime.now(timezone.utc), + msg_id=uuid4(), + content=[TextContent(type="text", text=reply)], + ), + ) + return + + if not is_ready(): + reply = ( + "No document is loaded yet. Please ingest a document first " + "using `ingest ` or set DOCUMENT_PATH in your .env." + ) + await ctx.send( + sender, + ChatMessage( + timestamp=datetime.now(timezone.utc), + msg_id=uuid4(), + content=[TextContent(type="text", text=reply)], + ), + ) + return + + try: + answer = get_answer(user_text, system_prompt=SYSTEM_PROMPT) + except Exception as exc: + ctx.logger.exception("RAG query failed") + answer = f"Sorry, I encountered an error: {exc}" + + await ctx.send( + sender, + ChatMessage( + timestamp=datetime.now(timezone.utc), + msg_id=uuid4(), + content=[TextContent(type="text", text=answer)], + ), + ) + + +@chat_proto.on_message(ChatAcknowledgement) +async def handle_ack(ctx: Context, sender: str, msg: ChatAcknowledgement) -> None: + ctx.logger.info( + f"Received acknowledgement from {sender} for message {msg.acknowledged_msg_id}" + ) + + +agent = Agent() + +agent.include(chat_proto, publish_manifest=True) + + +@agent.on_event("startup") +async def on_startup(ctx: Context) -> None: + ctx.logger.info(f"RAG Document Q&A Agent started at {agent.address}") + ctx.logger.info(f"GEMINI_API_KEY configured: {bool(GEMINI_API_KEY)}") + + if DOCUMENT_PATH: + try: + chunk_count = index_document(DOCUMENT_PATH) + ctx.logger.info( + f"Auto-ingested document from DOCUMENT_PATH: " + f"{DOCUMENT_PATH} ({chunk_count} chunks)" + ) + except Exception as exc: + ctx.logger.warning(f"Auto-ingest failed for {DOCUMENT_PATH}: {exc}") + else: + ctx.logger.info("No DOCUMENT_PATH set. Use `ingest ` to load a document.") + + +if __name__ == "__main__": + agent.run() diff --git a/contributors/rag-document-qa-agent/assets/demo.png b/contributors/rag-document-qa-agent/assets/demo.png new file mode 100644 index 00000000..08cd6f2b Binary files /dev/null and b/contributors/rag-document-qa-agent/assets/demo.png differ diff --git a/contributors/rag-document-qa-agent/ingest.py b/contributors/rag-document-qa-agent/ingest.py new file mode 100644 index 00000000..38f112fc --- /dev/null +++ b/contributors/rag-document-qa-agent/ingest.py @@ -0,0 +1,37 @@ +""" +Document ingestion utility. + +Run this script to pre-load a document into the ChromaDB vector store +before starting the agent. +""" + +from __future__ import annotations + +import sys + +from rag import index_document + + +def main() -> None: + if len(sys.argv) < 2: + print("Usage: python ingest.py ") + print("Supported formats: .pdf, .txt, .md, .csv") + sys.exit(1) + + path = sys.argv[1] + print(f"Ingesting document: {path}") + + try: + chunk_count = index_document(path) + print(f"Successfully ingested {chunk_count} chunks into ChromaDB.") + print("You can now start the agent and ask questions.") + except FileNotFoundError: + print(f"Error: File not found: {path}") + sys.exit(1) + except Exception as exc: + print(f"Error: {exc}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/contributors/rag-document-qa-agent/rag.py b/contributors/rag-document-qa-agent/rag.py new file mode 100644 index 00000000..63e2c6b7 --- /dev/null +++ b/contributors/rag-document-qa-agent/rag.py @@ -0,0 +1,152 @@ +""" +RAG pipeline: document loading, chunking, embedding, vector storage, +and retrieval-augmented question answering using LangChain + Gemini. +""" + +from __future__ import annotations + +import os +from typing import Any + +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from langchain_core.output_parsers import StrOutputParser +from langchain_google_genai import ChatGoogleGenerativeAI +from langchain_community.embeddings import HuggingFaceEmbeddings +from langchain_community.vectorstores import Chroma +from langchain_text_splitters import RecursiveCharacterTextSplitter +from pypdf import PdfReader + +PERSIST_DIR = "chroma_db" +CHUNK_SIZE = 1000 +CHUNK_OVERLAP = 200 +EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" +LLM_MODEL = "gemini-2.0-flash" + +_vectorstore: Chroma | None = None +_retriever: Any = None +_chat_history: list = [] + + +def _load_pdf(path: str) -> str: + reader = PdfReader(path) + pages = [] + for page in reader.pages: + text = page.extract_text() + if text: + pages.append(text) + return "\n\n".join(pages) + + +def _load_text(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def load_document(path: str) -> str: + ext = os.path.splitext(path)[1].lower() + if ext == ".pdf": + return _load_pdf(path) + if ext in (".txt", ".md", ".csv"): + return _load_text(path) + raise ValueError(f"Unsupported file type: {ext}. Use .pdf, .txt, .md, or .csv") + + +def index_document(path: str) -> int: + global _vectorstore, _retriever, _chat_history + + text = load_document(path) + if not text.strip(): + raise ValueError("Document is empty or could not be read.") + + splitter = RecursiveCharacterTextSplitter( + chunk_size=CHUNK_SIZE, + chunk_overlap=CHUNK_OVERLAP, + length_function=len, + ) + chunks = splitter.create_documents([text]) + + if not chunks: + raise ValueError("No chunks produced from document.") + + embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL) + + _vectorstore = Chroma.from_documents( + documents=chunks, + embedding=embeddings, + persist_directory=PERSIST_DIR, + ) + _retriever = _vectorstore.as_retriever(search_kwargs={"k": 4}) + _chat_history = [] + + return len(chunks) + + +def is_ready() -> bool: + global _vectorstore, _retriever + if _vectorstore is not None and _retriever is not None: + return True + if os.path.isdir(PERSIST_DIR): + try: + embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL) + _vectorstore = Chroma( + persist_directory=PERSIST_DIR, + embedding_function=embeddings, + ) + _retriever = _vectorstore.as_retriever(search_kwargs={"k": 4}) + return True + except Exception: + return False + return False + + +def get_answer(question: str, system_prompt: str | None = None) -> str: + global _chat_history + + if not is_ready(): + return "No document is loaded. Please ingest a document first." + + gemini_api_key = os.getenv("GEMINI_API_KEY", "") + if not gemini_api_key: + raise RuntimeError("GEMINI_API_KEY is not set") + + default_system = ( + "You are a document Q&A assistant. Answer questions based strictly " + "on the provided context. If the answer is not in the context, " + "say 'I don't have enough information in the document to answer " + "that question.' Do not make up information." + ) + system_text = system_prompt or default_system + + docs = _retriever.invoke(question) + context_text = "\n\n---\n\n".join(doc.page_content for doc in docs) + + prompt = ChatPromptTemplate.from_messages( + [ + ("system", system_text), + ("system", "Document context:\n\n{context}"), + MessagesPlaceholder("chat_history"), + ("human", "{question}"), + ] + ) + + llm = ChatGoogleGenerativeAI( + model=LLM_MODEL, + google_api_key=gemini_api_key, + temperature=0.2, + ) + + chain = prompt | llm | StrOutputParser() + + _chat_history.append(("human", question)) + + result = chain.invoke( + { + "context": context_text, + "question": question, + "chat_history": _chat_history[-6:], + } + ) + + _chat_history.append(("ai", result)) + + return result diff --git a/contributors/rag-document-qa-agent/requirements.txt b/contributors/rag-document-qa-agent/requirements.txt new file mode 100644 index 00000000..89837e17 --- /dev/null +++ b/contributors/rag-document-qa-agent/requirements.txt @@ -0,0 +1,10 @@ +uagents>=0.20.0 +uagents_core>=0.2.0 +python-dotenv>=1.0.0 +langchain>=0.3.0 +langchain-community>=0.3.0 +langchain-google-genai>=2.0.0 +langchain-text-splitters>=0.3.0 +chromadb>=0.5.0 +sentence-transformers>=3.0.0 +pypdf>=4.0.0