Delete directory 'src'
This commit is contained in:
@@ -1 +0,0 @@
|
||||
# Package initialization
|
||||
@@ -1,63 +0,0 @@
|
||||
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||
import { OpenAI } from "langchain/llms/openai";
|
||||
import { RetrievalQAChain } from "langchain/chains";
|
||||
import { RetrievalQA } from "langchain/chains/retrieval_qa";
|
||||
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
|
||||
import { ChromaClient } from "chromadb";
|
||||
|
||||
const llm = new OpenAI({
|
||||
temperature: 0,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
const client = new ChromaClient({
|
||||
path: process.env.CHROMA_DB_PATH || "./chromadb",
|
||||
});
|
||||
|
||||
export async function createAgent(collectionName) {
|
||||
const collection = await client.getOrCreateCollection({
|
||||
name: collectionName,
|
||||
});
|
||||
|
||||
const retriever = {
|
||||
async getRelevantDocuments(query) {
|
||||
const embedding = await new OpenAIEmbeddings({
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
}).embedQuery(query);
|
||||
const results = await collection.query({
|
||||
queryEmbeddings: [embedding],
|
||||
nResults: 5,
|
||||
});
|
||||
return results.documents[0].map((doc, idx) => ({
|
||||
pageContent: doc,
|
||||
metadata: results.metadatas[0][idx],
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const qaChain = RetrievalQAChain.fromLLM(llm, retriever, {
|
||||
returnSourceDocuments: true,
|
||||
});
|
||||
|
||||
const agent = await initializeAgentExecutorWithOptions(
|
||||
[],
|
||||
llm,
|
||||
{
|
||||
agentType: "chat-conversational-react-description",
|
||||
memory: undefined,
|
||||
verbose: true,
|
||||
tools: [
|
||||
{
|
||||
name: "retrieval",
|
||||
func: async (input) => {
|
||||
const docs = await qaChain.call({ input });
|
||||
return docs.output;
|
||||
},
|
||||
description: "Use this tool to retrieve answers from the knowledge base",
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import os
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from langchain_community.llms import Ollama
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from langchain_core.runnables import RunnablePassthrough
|
||||
from langchain_core.tools import BaseTool
|
||||
from langchain.agents import AgentExecutor, create_openai_tools_agent
|
||||
from langchain.schema import HumanMessage, SystemMessage
|
||||
|
||||
from .tools import search_course_docs, fetch_course_meta
|
||||
|
||||
# Load tools
|
||||
TOOLS: List[BaseTool] = [search_course_docs, fetch_course_meta]
|
||||
|
||||
# System prompt guiding the agent
|
||||
SYSTEM_PROMPT = """
|
||||
You are a helpful assistant for a machine learning course. Your job is to answer user questions.
|
||||
|
||||
- If the question is about course materials, lecture slides, assignments, or any content that can be found in the FAQ documents, use the tool `search_course_docs`.
|
||||
- If the question is about course schedule, instructor information, or other metadata, use the tool `fetch_course_meta`.
|
||||
- Do not use both tools unless absolutely necessary.
|
||||
- In your answer, always include a source tag: `source: chroma` if you used the FAQ tool, or `source: mcp_meta` if you used the metadata tool.
|
||||
"""
|
||||
|
||||
def build_agent() -> AgentExecutor:
|
||||
"""
|
||||
Build and return a LangChain AgentExecutor with the defined tools and system prompt.
|
||||
"""
|
||||
llm = Ollama(model="llama3", temperature=0.0)
|
||||
|
||||
# Prompt template
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
SystemMessage(content=SYSTEM_PROMPT),
|
||||
MessagesPlaceholder(variable_name="history"),
|
||||
HumanMessage(content="{input}"),
|
||||
]
|
||||
)
|
||||
|
||||
# Create the agent
|
||||
agent = create_openai_tools_agent(llm=llm, tools=TOOLS, prompt=prompt)
|
||||
|
||||
# Wrap with AgentExecutor
|
||||
agent_executor = AgentExecutor(agent=agent, tools=TOOLS, verbose=True, handle_parsing_errors=True)
|
||||
return agent_executor
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Minimal Context‑Aware Prompt (MCP) tool.
|
||||
* Generates a prompt that can be used for vector search.
|
||||
*/
|
||||
export function generatePrompt(question) {
|
||||
return `Answer the following question based on the knowledge base: "${question}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a user query by generating a prompt, searching the vector store,
|
||||
* and returning the best answer.
|
||||
* @param {string} question
|
||||
* @param {ChromaVectorStore} vectorStore
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function answerQuestion(question, vectorStore) {
|
||||
const prompt = generatePrompt(question);
|
||||
const results = await vectorStore.similaritySearch(prompt, 1);
|
||||
if (results.length === 0) {
|
||||
return "I couldn't find an answer to that question.";
|
||||
}
|
||||
return results[0];
|
||||
}
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
"""
|
||||
FAQBot implementation.
|
||||
|
||||
The bot uses LangChain's RetrievalQA chain with a ChromaDB vector store
|
||||
and an LLM (OpenAI or a dummy fallback). It exposes a single method
|
||||
`ask(question: str) -> str` that returns the best answer from the FAQ.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import chromadb
|
||||
from langchain.chains import RetrievalQA
|
||||
from langchain.llms import OpenAI
|
||||
from langchain.vectorstores import Chroma
|
||||
|
||||
# Import the vector store helper
|
||||
from .vector_store import get_vector_store, FAQ_DATA
|
||||
|
||||
|
||||
class DummyLLM:
|
||||
"""
|
||||
A minimal LLM that simply echoes the prompt.
|
||||
Used when no OpenAI API key is available.
|
||||
"""
|
||||
|
||||
def __call__(self, prompt: str) -> str:
|
||||
return prompt
|
||||
|
||||
|
||||
class DummyEmbedding:
|
||||
"""
|
||||
Dummy embedding function that returns a fixed vector of zeros.
|
||||
This avoids the need for an external embedding service during runtime.
|
||||
"""
|
||||
|
||||
def __call__(self, texts):
|
||||
return [[0.0] * 768 for _ in texts]
|
||||
|
||||
|
||||
class FAQBot:
|
||||
"""
|
||||
FAQ Bot that answers user questions based on a predefined FAQ dataset.
|
||||
"""
|
||||
|
||||
def __init__(self, persist_dir: str, openai_api_key: Optional[str] = None):
|
||||
"""
|
||||
Initialize the bot.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
persist_dir : str
|
||||
Directory where the ChromaDB data is persisted.
|
||||
openai_api_key : Optional[str]
|
||||
OpenAI API key. If None, a DummyLLM is used.
|
||||
"""
|
||||
self.persist_dir = persist_dir
|
||||
self.openai_api_key = openai_api_key
|
||||
|
||||
# Load or create the vector store
|
||||
collection = get_vector_store(persist_dir)
|
||||
|
||||
# Wrap the collection with LangChain's Chroma wrapper
|
||||
self.vectorstore = Chroma(
|
||||
collection=collection,
|
||||
embedding_function=DummyEmbedding(),
|
||||
)
|
||||
|
||||
# Choose LLM
|
||||
if openai_api_key:
|
||||
self.llm = OpenAI(temperature=0, openai_api_key=openai_api_key)
|
||||
else:
|
||||
self.llm = DummyLLM()
|
||||
|
||||
# Build RetrievalQA chain
|
||||
self.chain = RetrievalQA.from_chain_type(
|
||||
llm=self.llm,
|
||||
chain_type="stuff",
|
||||
retriever=self.vectorstore.as_retriever(),
|
||||
return_source_documents=False,
|
||||
)
|
||||
|
||||
def ask(self, question: str) -> str:
|
||||
"""
|
||||
Ask the bot a question.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
question : str
|
||||
The user question.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The bot's answer.
|
||||
"""
|
||||
try:
|
||||
response = self.chain.run(question)
|
||||
if not response:
|
||||
return "I don't have an answer for that."
|
||||
return response.strip()
|
||||
except Exception as exc:
|
||||
return f"Error processing your question: {exc}"
|
||||
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
Chromadb client wrapper for storing and querying FAQ documents.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List, Dict, Any
|
||||
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
from chromadb.utils import embedding_functions
|
||||
|
||||
import openai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Ensure OpenAI API key is set
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
if not OPENAI_API_KEY:
|
||||
raise RuntimeError("OPENAI_API_KEY not set in environment")
|
||||
|
||||
openai.api_key = OPENAI_API_KEY
|
||||
|
||||
|
||||
class ChromadbClient:
|
||||
"""
|
||||
A simple wrapper around ChromaDB for storing FAQ documents and performing similarity searches.
|
||||
"""
|
||||
|
||||
def __init__(self, collection_name: str = "faq_collection", persist_directory: str = "chromadb"):
|
||||
"""
|
||||
Initialize the ChromaDB client and collection.
|
||||
|
||||
:param collection_name: Name of the collection to use.
|
||||
:param persist_directory: Directory to persist the database.
|
||||
"""
|
||||
self.client = chromadb.Client(Settings(
|
||||
chroma_db_impl="duckdb+parquet",
|
||||
persist_directory=persist_directory,
|
||||
))
|
||||
self.collection_name = collection_name
|
||||
self.collection = self.client.get_or_create_collection(name=collection_name)
|
||||
|
||||
def _embed_text(self, text: str) -> List[float]:
|
||||
"""
|
||||
Generate embeddings for a given text using OpenAI embeddings.
|
||||
|
||||
:param text: Text to embed.
|
||||
:return: List of floats representing the embedding.
|
||||
"""
|
||||
response = openai.Embedding.create(
|
||||
input=text,
|
||||
model="text-embedding-ada-002",
|
||||
)
|
||||
return response["data"][0]["embedding"]
|
||||
|
||||
def add_documents(self, documents: List[Dict[str, Any]]) -> None:
|
||||
"""
|
||||
Add a list of documents to the collection.
|
||||
|
||||
Each document should be a dict with keys:
|
||||
- id: unique identifier
|
||||
- text: the content of the document
|
||||
- metadata: optional dict of metadata
|
||||
|
||||
:param documents: List of document dicts.
|
||||
"""
|
||||
ids = []
|
||||
embeddings = []
|
||||
metadatas = []
|
||||
texts = []
|
||||
|
||||
for doc in documents:
|
||||
doc_id = str(doc["id"])
|
||||
text = doc["text"]
|
||||
metadata = doc.get("metadata", {})
|
||||
|
||||
ids.append(doc_id)
|
||||
embeddings.append(self._embed_text(text))
|
||||
metadatas.append(metadata)
|
||||
texts.append(text)
|
||||
|
||||
self.collection.add(
|
||||
ids=ids,
|
||||
embeddings=embeddings,
|
||||
metadatas=metadatas,
|
||||
documents=texts,
|
||||
)
|
||||
|
||||
def query(self, query_text: str, top_k: int = 3) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Query the collection for the most similar documents to the query_text.
|
||||
|
||||
:param query_text: The query string.
|
||||
:param top_k: Number of top results to return.
|
||||
:return: List of dicts containing id, score, metadata, and document text.
|
||||
"""
|
||||
query_embedding = self._embed_text(query_text)
|
||||
results = self.collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=top_k,
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
|
||||
# ChromaDB returns lists; we flatten them
|
||||
hits = []
|
||||
for i in range(len(results["ids"][0])):
|
||||
hit = {
|
||||
"id": results["ids"][0][i],
|
||||
"score": 1 - results["distances"][0][i], # convert distance to similarity
|
||||
"metadata": results["metadatas"][0][i],
|
||||
"document": results["documents"][0][i],
|
||||
}
|
||||
hits.append(hit)
|
||||
return hits
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from .agent import build_agent
|
||||
|
||||
PRESET_QUESTIONS = [
|
||||
{
|
||||
"question": "What is the deadline for Assignment 1?",
|
||||
"description": "Should use FAQ tool",
|
||||
},
|
||||
{
|
||||
"question": "How many lectures are there in the course?",
|
||||
"description": "Should use FAQ tool",
|
||||
},
|
||||
{
|
||||
"question": "What is the course schedule for next week?",
|
||||
"description": "Should use metadata tool",
|
||||
},
|
||||
]
|
||||
|
||||
def run_preset_questions(agent):
|
||||
print("\nRunning preset questions:\n")
|
||||
for idx, item in enumerate(PRESET_QUESTIONS, 1):
|
||||
print(f"Q{idx}: {item['question']}")
|
||||
response = agent.invoke({"input": item["question"]})
|
||||
print(f"A{idx}: {response['output']}\n")
|
||||
|
||||
def interactive_mode(agent):
|
||||
print("\nEnter your questions (type 'exit' to quit):")
|
||||
while True:
|
||||
try:
|
||||
user_input = input("\n> ")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nExiting.")
|
||||
break
|
||||
if user_input.lower() in {"exit", "quit"}:
|
||||
print("Goodbye!")
|
||||
break
|
||||
response = agent.invoke({"input": user_input})
|
||||
print(f"\n{response['output']}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="FAQ Bot CLI")
|
||||
parser.add_argument("--interactive", action="store_true", help="Start interactive mode")
|
||||
args = parser.parse_args()
|
||||
|
||||
agent = build_agent()
|
||||
|
||||
if args.interactive:
|
||||
interactive_mode(agent)
|
||||
else:
|
||||
run_preset_questions(agent)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,22 +0,0 @@
|
||||
import os
|
||||
from pydantic import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# ChromaDB configuration
|
||||
chroma_db_path: str = "./chroma_db"
|
||||
chroma_collection_name: str = "faq_collection"
|
||||
|
||||
# Ollama embedding configuration
|
||||
ollama_embed_model: str = "all-MiniLM-L6-v2"
|
||||
ollama_host: str = "http://localhost"
|
||||
ollama_port: int = 11434
|
||||
|
||||
# OpenAI LLM configuration
|
||||
openai_api_key: str = ""
|
||||
openai_model: str = "gpt-3.5-turbo"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
settings = Settings()
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
ChromaDB wrapper for storing and querying FAQ documents.
|
||||
"""
|
||||
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
from typing import List, Dict, Any
|
||||
|
||||
class ChromaDB:
|
||||
"""
|
||||
Wrapper around ChromaDB to handle FAQ documents.
|
||||
"""
|
||||
|
||||
def __init__(self, persist_path: str = "chromadb"):
|
||||
"""
|
||||
Initialize the ChromaDB client and collection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
persist_path : str, optional
|
||||
Directory to persist the database. Defaults to "chromadb".
|
||||
"""
|
||||
self.client = chromadb.Client(Settings(persist_directory=persist_path))
|
||||
self.collection_name = "faq"
|
||||
self.collection = self.client.get_or_create_collection(name=self.collection_name)
|
||||
|
||||
def add_document(self, text: str, embedding: List[float], doc_id: str = None):
|
||||
"""
|
||||
Add a single document to the collection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text : str
|
||||
The document text.
|
||||
embedding : List[float]
|
||||
The embedding vector for the document.
|
||||
doc_id : str, optional
|
||||
Optional document ID. If None, an auto-generated ID is used.
|
||||
"""
|
||||
if doc_id is None:
|
||||
# Generate a simple incremental ID
|
||||
existing_ids = self.collection.get()["ids"]
|
||||
doc_id = str(len(existing_ids))
|
||||
self.collection.add(
|
||||
documents=[text],
|
||||
embeddings=[embedding],
|
||||
ids=[doc_id]
|
||||
)
|
||||
|
||||
def query(self, embedding: List[float], k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Query the collection for the top-k most similar documents.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
embedding : List[float]
|
||||
The query embedding.
|
||||
k : int, optional
|
||||
Number of results to return. Defaults to 5.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[Dict[str, Any]]
|
||||
List of dictionaries containing 'id', 'document', and 'distance'.
|
||||
"""
|
||||
results = self.collection.query(
|
||||
query_embeddings=[embedding],
|
||||
n_results=k,
|
||||
include=["documents", "distances", "ids"]
|
||||
)
|
||||
docs = []
|
||||
for doc, dist, doc_id in zip(results["documents"][0], results["distances"][0], results["ids"][0]):
|
||||
docs.append({"id": doc_id, "document": doc, "distance": dist})
|
||||
return docs
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""
|
||||
Check if the collection has any documents.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if empty, False otherwise.
|
||||
"""
|
||||
return len(self.collection.get()["ids"]) == 0
|
||||
|
||||
def load_sample_data(self, sample_data: List[Dict[str, str]]):
|
||||
"""
|
||||
Load a list of sample documents into the collection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sample_data : List[Dict[str, str]]
|
||||
List of dictionaries with keys 'text' and optional 'id'.
|
||||
"""
|
||||
for item in sample_data:
|
||||
text = item["text"]
|
||||
doc_id = item.get("id")
|
||||
embedding = embed_text(text)
|
||||
self.add_document(text, embedding, doc_id=doc_id)
|
||||
@@ -1,28 +0,0 @@
|
||||
"""
|
||||
Embedding utilities using Ollama's embed-text model.
|
||||
"""
|
||||
|
||||
import ollama
|
||||
from typing import List
|
||||
|
||||
def embed_text(text: str, model: str = "embed-text") -> List[float]:
|
||||
"""
|
||||
Generate an embedding for the given text using Ollama's embed-text model.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text : str
|
||||
The input text to embed.
|
||||
model : str, optional
|
||||
The Ollama model name. Defaults to "embed-text".
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[float]
|
||||
The embedding vector.
|
||||
"""
|
||||
try:
|
||||
result = ollama.embeddings(model=model, prompt=text)
|
||||
return result["embedding"]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to embed text: {e}") from e
|
||||
@@ -1,14 +0,0 @@
|
||||
from langchain_ollama import OllamaEmbeddings
|
||||
from src.config import settings
|
||||
|
||||
# Instantiate the Ollama embeddings once for reuse
|
||||
ollama_embeddings = OllamaEmbeddings(
|
||||
model=settings.ollama_embed_model,
|
||||
base_url=f"{settings.ollama_host}:{settings.ollama_port}"
|
||||
)
|
||||
|
||||
def get_embedding(text: str):
|
||||
"""
|
||||
Return the embedding vector for a single text string.
|
||||
"""
|
||||
return ollama_embeddings.embed_query(text)
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
FAQ Bot entry point.
|
||||
|
||||
The bot loads FAQ documents from the `data/` directory, stores them in
|
||||
ChromaDB, and then enters an interactive loop where the user can ask
|
||||
questions. The bot returns the top 3 most relevant answers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.vector_store import VectorStore
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helper functions
|
||||
# --------------------------------------------------------------------------- #
|
||||
def load_documents(folder: Path) -> list:
|
||||
"""
|
||||
Load all .txt files from the given folder as documents.
|
||||
|
||||
Each file becomes a single document with its content as text.
|
||||
"""
|
||||
docs = []
|
||||
for file in folder.glob("*.txt"):
|
||||
text = file.read_text(encoding="utf-8")
|
||||
docs.append({"text": text, "metadata": {"source": file.name}})
|
||||
return docs
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Main logic
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main() -> None:
|
||||
# Load environment variables (e.g. OPENAI_API_KEY)
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Resolve data directory relative to the project root
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
data_dir = project_root / "data"
|
||||
|
||||
# Initialize vector store
|
||||
store = VectorStore()
|
||||
|
||||
# If the collection is empty, load documents
|
||||
if store.collection.count() == 0:
|
||||
print("Loading documents into ChromaDB...")
|
||||
docs = load_documents(data_dir)
|
||||
if not docs:
|
||||
print(f"No .txt files found in {data_dir}. Exiting.")
|
||||
sys.exit(1)
|
||||
store.add_documents(docs)
|
||||
print(f"Added {len(docs)} documents.")
|
||||
|
||||
print("\nFAQ Bot is ready. Type your question (or 'exit' to quit).")
|
||||
|
||||
while True:
|
||||
try:
|
||||
query = input("\nQ: ")
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
if query.lower() in ("exit", "quit"):
|
||||
break
|
||||
|
||||
results = store.query(query, top_k=3)
|
||||
if not results:
|
||||
print("No answer found.")
|
||||
continue
|
||||
|
||||
print("\nTop answers:")
|
||||
for i, res in enumerate(results, 1):
|
||||
snippet = res["text"][:200].replace("\n", " ")
|
||||
print(f"{i}. {snippet}... (distance: {res['distance']:.4f})")
|
||||
|
||||
print("\nGoodbye!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,43 +0,0 @@
|
||||
import readlineSync from 'readline-sync';
|
||||
import ChromaVectorStore from './vectorStore.js';
|
||||
import { answerQuestion } from './bot.js';
|
||||
|
||||
/**
|
||||
* Sample FAQ dataset.
|
||||
* In a real application this would be loaded from a file or database.
|
||||
*/
|
||||
const faqData = [
|
||||
{ id: '1', text: 'What is ChromaDB?', metadata: { category: 'database' } },
|
||||
{ id: '2', text: 'How do I install ChromaDB?', metadata: { category: 'installation' } },
|
||||
{ id: '3', text: 'What is an MCP-tool?', metadata: { category: 'concept' } },
|
||||
{ id: '4', text: 'How to use the FAQ bot?', metadata: { category: 'usage' } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
*/
|
||||
async function main() {
|
||||
const vectorStore = new ChromaVectorStore();
|
||||
await vectorStore.init('faq');
|
||||
|
||||
// Load data into the collection if it is empty.
|
||||
// For simplicity we always add the data; in production you would check existence.
|
||||
await vectorStore.addDocuments(faqData);
|
||||
|
||||
console.log('FAQ bot is ready. Type your question (or "exit" to quit).');
|
||||
|
||||
while (true) {
|
||||
const question = readlineSync.question('> ');
|
||||
if (question.trim().toLowerCase() === 'exit') {
|
||||
console.log('Goodbye!');
|
||||
break;
|
||||
}
|
||||
const answer = await answerQuestion(question, vectorStore);
|
||||
console.log(`Answer: ${answer}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
-223
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FAQ Bot using QDrant as the vector store.
|
||||
|
||||
This script provides:
|
||||
- Data ingestion from a text file into QDrant.
|
||||
- Querying the vector store to retrieve relevant FAQ answers.
|
||||
- A simple CLI interface for ingestion and querying.
|
||||
|
||||
Author: Artur Kuzakhmetov
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
import openai
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qdrant_models
|
||||
from qdrant_client.http.models import PointStruct
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Configuration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Environment variables
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
|
||||
QDRANT_API_KEY = os.getenv("QDRANT_API_KEY") # Optional, if QDrant requires auth
|
||||
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "faq_collection")
|
||||
|
||||
# OpenAI embedding model
|
||||
EMBEDDING_MODEL = "text-embedding-ada-002"
|
||||
EMBEDDING_DIM = 1536 # Dimension of Ada-002 embeddings
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helper functions
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def split_text_into_chunks(text: str, max_chunk_size: int = 500) -> List[str]:
|
||||
"""
|
||||
Split a large text into smaller chunks suitable for embedding.
|
||||
Splits on paragraph boundaries and ensures each chunk is <= max_chunk_size.
|
||||
"""
|
||||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
for para in paragraphs:
|
||||
if len(current_chunk) + len(para) + 1 <= max_chunk_size:
|
||||
current_chunk += (" " if current_chunk else "") + para
|
||||
else:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = para
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
return chunks
|
||||
|
||||
def embed_texts(texts: List[str]) -> List[List[float]]:
|
||||
"""
|
||||
Generate embeddings for a list of texts using OpenAI's embedding API.
|
||||
"""
|
||||
if not OPENAI_API_KEY:
|
||||
raise RuntimeError("OPENAI_API_KEY environment variable is not set.")
|
||||
openai.api_key = OPENAI_API_KEY
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
response = openai.Embedding.create(
|
||||
input=text,
|
||||
model=EMBEDDING_MODEL
|
||||
)
|
||||
embeddings.append(response["data"][0]["embedding"])
|
||||
return embeddings
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# QDrant Vector Store Wrapper
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class QdrantVectorStore:
|
||||
def __init__(self, url: str = QDRANT_URL, api_key: str = QDRANT_API_KEY, collection_name: str = QDRANT_COLLECTION):
|
||||
self.client = QdrantClient(url=url, api_key=api_key)
|
||||
self.collection_name = collection_name
|
||||
self._ensure_collection()
|
||||
|
||||
def _ensure_collection(self):
|
||||
"""
|
||||
Create the collection if it does not exist.
|
||||
"""
|
||||
collections = self.client.get_collections()
|
||||
if self.collection_name not in [c.name for c in collections.collections]:
|
||||
self.client.create_collection(
|
||||
collection_name=self.collection_name,
|
||||
vectors_config=qdrant_models.VectorParams(
|
||||
size=EMBEDDING_DIM,
|
||||
distance="Cosine"
|
||||
)
|
||||
)
|
||||
|
||||
def upsert(self, texts: List[str], embeddings: List[List[float]]):
|
||||
"""
|
||||
Upsert a batch of texts and their embeddings into QDrant.
|
||||
"""
|
||||
points = []
|
||||
for idx, (text, embedding) in enumerate(zip(texts, embeddings)):
|
||||
point_id = f"{self.collection_name}_{idx}_{hash(text) % 1000000}"
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=point_id,
|
||||
vector=embedding,
|
||||
payload={"text": text}
|
||||
)
|
||||
)
|
||||
self.client.upsert(
|
||||
collection_name=self.collection_name,
|
||||
points=points
|
||||
)
|
||||
|
||||
def search(self, query_embedding: List[float], top_k: int = 5) -> List[Tuple[str, float]]:
|
||||
"""
|
||||
Search the collection for the most similar vectors to the query embedding.
|
||||
Returns a list of (text, score) tuples.
|
||||
"""
|
||||
search_result = self.client.search(
|
||||
collection_name=self.collection_name,
|
||||
query_vector=query_embedding,
|
||||
limit=top_k,
|
||||
with_payload=True,
|
||||
score=True
|
||||
)
|
||||
results = []
|
||||
for hit in search_result:
|
||||
text = hit.payload.get("text", "")
|
||||
score = hit.score
|
||||
results.append((text, score))
|
||||
return results
|
||||
|
||||
def delete_collection(self):
|
||||
"""
|
||||
Delete the entire collection. Use with caution.
|
||||
"""
|
||||
self.client.delete_collection(self.collection_name)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bot Logic
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def ingest_data(file_path: str, vector_store: QdrantVectorStore):
|
||||
"""
|
||||
Read a text file, split into chunks, embed, and store in QDrant.
|
||||
"""
|
||||
if not Path(file_path).is_file():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
raw_text = f.read()
|
||||
|
||||
chunks = split_text_into_chunks(raw_text)
|
||||
embeddings = embed_texts(chunks)
|
||||
vector_store.upsert(chunks, embeddings)
|
||||
print(f"Ingested {len(chunks)} chunks into collection '{vector_store.collection_name}'.")
|
||||
|
||||
def query_faq(question: str, vector_store: QdrantVectorStore, top_k: int = 5) -> str:
|
||||
"""
|
||||
Query the FAQ bot with a question and return a formatted answer.
|
||||
"""
|
||||
query_embedding = embed_texts([question])[0]
|
||||
results = vector_store.search(query_embedding, top_k=top_k)
|
||||
if not results:
|
||||
return "Sorry, I couldn't find an answer to your question."
|
||||
|
||||
answer_parts = []
|
||||
for idx, (text, score) in enumerate(results, start=1):
|
||||
answer_parts.append(f"{idx}. (Score: {score:.4f})\n{text}\n")
|
||||
return "\n".join(answer_parts)
|
||||
|
||||
def get_response(question: str, top_k: int = 5) -> str:
|
||||
"""
|
||||
Public API for external tools (e.g., MCP-tool) to get a bot response.
|
||||
"""
|
||||
vector_store = QdrantVectorStore()
|
||||
return query_faq(question, vector_store, top_k=top_k)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI Interface
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="FAQ Bot CLI")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
ingest_parser = subparsers.add_parser("ingest", help="Ingest a text file into QDrant")
|
||||
ingest_parser.add_argument("file", help="Path to the text file to ingest")
|
||||
|
||||
query_parser = subparsers.add_parser("query", help="Query the FAQ bot")
|
||||
query_parser.add_argument("question", help="Your question")
|
||||
query_parser.add_argument("--top_k", type=int, default=5, help="Number of top results to return")
|
||||
|
||||
delete_parser = subparsers.add_parser("delete", help="Delete the QDrant collection (use with caution)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
vector_store = QdrantVectorStore()
|
||||
|
||||
if args.command == "ingest":
|
||||
ingest_data(args.file, vector_store)
|
||||
elif args.command == "query":
|
||||
answer = query_faq(args.question, vector_store, top_k=args.top_k)
|
||||
print(answer)
|
||||
elif args.command == "delete":
|
||||
confirm = input(f"Are you sure you want to delete collection '{vector_store.collection_name}'? (yes/no): ")
|
||||
if confirm.lower() == "yes":
|
||||
vector_store.delete_collection()
|
||||
print("Collection deleted.")
|
||||
else:
|
||||
print("Deletion aborted.")
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,55 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { OpenAI } = require('openai');
|
||||
const { ChromaClient } = require('chromadb');
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
const chroma = new ChromaClient({ path: 'chromadb' });
|
||||
|
||||
const COLLECTION_NAME = 'faq_collection';
|
||||
const FAQ_FILE = path.join(__dirname, '..', 'faq.json');
|
||||
|
||||
async function ingest() {
|
||||
try {
|
||||
const rawData = fs.readFileSync(FAQ_FILE, 'utf-8');
|
||||
const faqEntries = JSON.parse(rawData);
|
||||
|
||||
const collection = await chroma.getOrCreateCollection({
|
||||
name: COLLECTION_NAME,
|
||||
metadata: { description: 'FAQ embeddings' }
|
||||
});
|
||||
|
||||
const documents = [];
|
||||
const embeddings = [];
|
||||
const ids = [];
|
||||
const metadatas = [];
|
||||
|
||||
for (let i = 0; i < faqEntries.length; i++) {
|
||||
const { question, answer } = faqEntries[i];
|
||||
const embeddingResponse = await openai.embeddings.create({
|
||||
model: 'text-embedding-ada-002',
|
||||
input: question
|
||||
});
|
||||
const embedding = embeddingResponse.data[0].embedding;
|
||||
|
||||
documents.push(question);
|
||||
embeddings.push(embedding);
|
||||
ids.push(`faq-${i}`);
|
||||
metadatas.push({ answer });
|
||||
}
|
||||
|
||||
await collection.add({
|
||||
documents,
|
||||
embeddings,
|
||||
ids,
|
||||
metadatas
|
||||
});
|
||||
|
||||
console.log(`Ingested ${faqEntries.length} FAQ entries into collection '${COLLECTION_NAME}'.`);
|
||||
} catch (err) {
|
||||
console.error('Error during ingestion:', err);
|
||||
}
|
||||
}
|
||||
|
||||
ingest();
|
||||
@@ -1,72 +0,0 @@
|
||||
"""
|
||||
Ingestion logic for FAQ documents into ChromaDB.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from chromadb import Client
|
||||
from chromadb.api.types import Documents, EmbeddingFunction
|
||||
from chromadb.config import Settings
|
||||
|
||||
from langchain.embeddings.openai import OpenAIEmbeddings
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
|
||||
def _load_faq_pairs(file_path: Path) -> List[tuple]:
|
||||
"""
|
||||
Load FAQ pairs from a text file.
|
||||
Expected format:
|
||||
Q: <question>
|
||||
A: <answer>
|
||||
Each pair separated by a blank line.
|
||||
"""
|
||||
pairs = []
|
||||
with file_path.open("r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
raw_pairs = content.strip().split("\n\n")
|
||||
for raw in raw_pairs:
|
||||
lines = raw.strip().splitlines()
|
||||
if len(lines) < 2:
|
||||
continue
|
||||
q_line = lines[0].strip()
|
||||
a_line = lines[1].strip()
|
||||
if q_line.lower().startswith("q:") and a_line.lower().startswith("a:"):
|
||||
question = q_line[2:].strip()
|
||||
answer = a_line[2:].strip()
|
||||
pairs.append((question, answer))
|
||||
return pairs
|
||||
|
||||
def ingest_faq(file_path: Path, client: Client, collection_name: str):
|
||||
"""
|
||||
Ingest FAQ pairs into the specified ChromaDB collection.
|
||||
"""
|
||||
pairs = _load_faq_pairs(file_path)
|
||||
if not pairs:
|
||||
raise ValueError("No valid FAQ pairs found in the file.")
|
||||
|
||||
# Prepare documents and metadata
|
||||
documents = []
|
||||
metadatas = []
|
||||
ids = []
|
||||
|
||||
for idx, (q, a) in enumerate(pairs):
|
||||
# Combine question and answer for embedding
|
||||
doc = f"Q: {q}\nA: {a}"
|
||||
documents.append(doc)
|
||||
metadatas.append({"question": q, "answer": a})
|
||||
ids.append(str(idx))
|
||||
|
||||
# Use OpenAI embeddings
|
||||
embedding = OpenAIEmbeddings()
|
||||
|
||||
# Create or get collection
|
||||
collection = client.get_or_create_collection(name=collection_name)
|
||||
|
||||
# Add documents to collection
|
||||
collection.add(
|
||||
documents=documents,
|
||||
metadatas=metadatas,
|
||||
ids=ids,
|
||||
embedding_function=embedding
|
||||
)
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
Main entry point for the FAQ bot using ChromaDB and a single MCP-tool.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
from typing import List, Dict, Any
|
||||
|
||||
import openai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from chromadb_client import ChromadbClient
|
||||
from mcp_tool import MCPTool
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Ensure OpenAI API key is set
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
if not OPENAI_API_KEY:
|
||||
print("Error: OPENAI_API_KEY not set in environment.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
openai.api_key = OPENAI_API_KEY
|
||||
|
||||
# Initialize the ChromaDB client
|
||||
db_client = ChromadbClient()
|
||||
|
||||
# Load FAQ documents from a local file (JSON lines format)
|
||||
FAQ_FILE = os.getenv("FAQ_FILE", "data/faq.jsonl")
|
||||
|
||||
def load_faq_documents(file_path: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Load FAQ documents from a JSON lines file.
|
||||
|
||||
Each line should be a JSON object with keys:
|
||||
- id: unique identifier
|
||||
- text: the content of the FAQ
|
||||
- metadata: optional dict
|
||||
"""
|
||||
docs = []
|
||||
if not os.path.exists(file_path):
|
||||
print(f"FAQ file {file_path} not found. Skipping load.", file=sys.stderr)
|
||||
return docs
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
try:
|
||||
doc = json.loads(line.strip())
|
||||
docs.append(doc)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return docs
|
||||
|
||||
# Load and add documents to the collection if not already present
|
||||
if not db_client.collection.count():
|
||||
print("Loading FAQ documents into ChromaDB...")
|
||||
faq_docs = load_faq_documents(FAQ_FILE)
|
||||
if faq_docs:
|
||||
db_client.add_documents(faq_docs)
|
||||
print(f"Added {len(faq_docs)} documents.")
|
||||
else:
|
||||
print("No FAQ documents loaded.", file=sys.stderr)
|
||||
|
||||
# Instantiate the MCP-tool
|
||||
mcp_tool = MCPTool()
|
||||
|
||||
# Define the function schema for OpenAI function calling
|
||||
function_schema = {
|
||||
"name": mcp_tool.name,
|
||||
"description": mcp_tool.description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
def ask_question(question: str) -> str:
|
||||
"""
|
||||
Ask a question to the bot. The bot will:
|
||||
1. Retrieve relevant FAQ documents from ChromaDB.
|
||||
2. Use OpenAI LLM to generate an answer, possibly invoking the MCP-tool.
|
||||
"""
|
||||
# Retrieve top 3 relevant documents
|
||||
hits = db_client.query(question, top_k=3)
|
||||
|
||||
# Build context from hits
|
||||
context = "\n\n".join([f"Document {hit['id']}:\n{hit['document']}" for hit in hits])
|
||||
|
||||
# Construct the prompt for the LLM
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an FAQ assistant. Use the provided documents to answer questions."},
|
||||
{"role": "user", "content": f"Question: {question}\n\nContext:\n{context}"},
|
||||
]
|
||||
|
||||
# Call OpenAI with function calling enabled
|
||||
response = openai.ChatCompletion.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=messages,
|
||||
functions=[function_schema],
|
||||
function_call="auto",
|
||||
)
|
||||
|
||||
# Parse the response
|
||||
reply = response["choices"][0]["message"]
|
||||
if reply.get("function_call"):
|
||||
# The model wants to call the MCP-tool
|
||||
func_name = reply["function_call"]["name"]
|
||||
if func_name == mcp_tool.name:
|
||||
# Execute the tool
|
||||
tool_response = mcp_tool({})
|
||||
# Send the tool response back to the model
|
||||
tool_message = {
|
||||
"role": "tool",
|
||||
"name": func_name,
|
||||
"content": json.dumps(tool_response),
|
||||
}
|
||||
# Re-send the conversation with the tool response
|
||||
messages.append(reply)
|
||||
messages.append(tool_message)
|
||||
# Get the final answer
|
||||
final_response = openai.ChatCompletion.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=messages,
|
||||
)
|
||||
return final_response["choices"][0]["message"]["content"]
|
||||
else:
|
||||
return f"Unknown function call: {func_name}"
|
||||
else:
|
||||
return reply["content"]
|
||||
|
||||
def main():
|
||||
print("FAQ Bot (ChromaDB + MCP-tool). Type 'exit' to quit.")
|
||||
while True:
|
||||
try:
|
||||
user_input = input("\nYou: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nGoodbye!")
|
||||
break
|
||||
|
||||
if user_input.lower() in {"exit", "quit"}:
|
||||
print("Goodbye!")
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
answer = ask_question(user_input)
|
||||
print(f"\nBot: {answer}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,24 +0,0 @@
|
||||
"""
|
||||
A single MCP-tool implementation for the FAQ bot.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
class MCPTool:
|
||||
"""
|
||||
Example MCP-tool that returns the current UTC datetime.
|
||||
"""
|
||||
|
||||
name = "get_current_utc_time"
|
||||
description = "Returns the current UTC datetime in ISO 8601 format."
|
||||
|
||||
def __call__(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute the tool.
|
||||
|
||||
:param arguments: Dictionary of arguments (unused in this simple tool).
|
||||
:return: Dictionary with the result.
|
||||
"""
|
||||
now = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
return {"current_time": now}
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
Integration with MCP-tools for generating answers.
|
||||
"""
|
||||
|
||||
import mcp_tools
|
||||
from typing import List
|
||||
|
||||
def generate_answer(context: List[str], question: str) -> str:
|
||||
"""
|
||||
Generate an answer using MCP-tools given context and a question.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context : List[str]
|
||||
List of context strings retrieved from the database.
|
||||
question : str
|
||||
The user's question.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The generated answer.
|
||||
"""
|
||||
# Combine context into a single string
|
||||
context_text = "\n".join(context)
|
||||
# Construct a prompt for MCP-tools
|
||||
prompt = f"Question: {question}\nContext:\n{context_text}\nAnswer:"
|
||||
# Use MCP-tools to generate the answer
|
||||
try:
|
||||
response = mcp_tools.generate(prompt=prompt)
|
||||
return response
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to generate answer with MCP-tools: {e}") from e
|
||||
@@ -1,23 +0,0 @@
|
||||
const moderate = require('moderate-censor');
|
||||
|
||||
/**
|
||||
* Moderates user input using moderate-censor.
|
||||
* @param {string} text
|
||||
* @returns {Promise<{allowed: boolean, reasons: string[]}>}
|
||||
*/
|
||||
async function moderateInput(text) {
|
||||
try {
|
||||
const result = await moderate.moderate(text);
|
||||
if (result.isAllowed) {
|
||||
return { allowed: true, reasons: [] };
|
||||
} else {
|
||||
return { allowed: false, reasons: result.reasons || [] };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Moderation error:', err);
|
||||
// If moderation fails, default to allowing to avoid blocking legitimate queries
|
||||
return { allowed: true, reasons: [] };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { moderateInput };
|
||||
@@ -1,42 +0,0 @@
|
||||
"""
|
||||
Retrieval and answer generation logic using LangChain.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from chromadb import Client
|
||||
from chromadb.config import Settings
|
||||
|
||||
from langchain.embeddings.openai import OpenAIEmbeddings
|
||||
from langchain.llms.openai import OpenAIChat
|
||||
from langchain.chains import RetrievalQA
|
||||
from langchain.vectorstores import Chroma
|
||||
|
||||
def get_answer(question: str, client: Client, collection_name: str, k: int = 3) -> str:
|
||||
"""
|
||||
Retrieve relevant FAQ chunks and generate an answer using OpenAIChat.
|
||||
"""
|
||||
# Set up embeddings and LLM
|
||||
embedding = OpenAIEmbeddings()
|
||||
llm = OpenAIChat(temperature=0)
|
||||
|
||||
# Load vector store
|
||||
vectorstore = Chroma(
|
||||
client=client,
|
||||
collection_name=collection_name,
|
||||
embedding_function=embedding
|
||||
)
|
||||
|
||||
# Build RetrievalQA chain
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
chain_type="stuff",
|
||||
retriever=vectorstore.as_retriever(search_kwargs={"k": k}),
|
||||
return_source_documents=True
|
||||
)
|
||||
|
||||
# Run chain
|
||||
result = qa_chain({"question": question})
|
||||
answer = result.get("answer", "")
|
||||
return answer.strip()
|
||||
@@ -1,85 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
import httpx
|
||||
from langchain_community.document_loaders import TextLoader
|
||||
from langchain_community.embeddings import OllamaEmbeddings
|
||||
from langchain_community.vectorstores import Chroma
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.tools import tool
|
||||
|
||||
# Path to the data directory
|
||||
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||
CHROMA_DIR = Path(__file__).parent.parent / "chroma_faq"
|
||||
|
||||
def load_faq_to_chroma() -> Chroma:
|
||||
"""
|
||||
Load all .md files from the data directory, chunk them, embed with Ollama,
|
||||
and persist into a Chroma vector store.
|
||||
"""
|
||||
# Check if the Chroma collection already exists
|
||||
if CHROMA_DIR.exists():
|
||||
# Load existing collection
|
||||
return Chroma(persist_directory=str(CHROMA_DIR), embedding_function=OllamaEmbeddings(model="nomic-embed-text"))
|
||||
|
||||
# Gather all markdown files
|
||||
md_files = list(DATA_DIR.glob("*.md"))
|
||||
documents: List[Document] = []
|
||||
|
||||
for md_file in md_files:
|
||||
loader = TextLoader(str(md_file), encoding="utf-8")
|
||||
docs = loader.load()
|
||||
documents.extend(docs)
|
||||
|
||||
# Create embeddings
|
||||
embeddings = OllamaEmbeddings(model="nomic-embed-text")
|
||||
|
||||
# Create Chroma vector store
|
||||
chroma = Chroma.from_documents(
|
||||
documents=documents,
|
||||
embedding=embeddings,
|
||||
persist_directory=str(CHROMA_DIR),
|
||||
)
|
||||
return chroma
|
||||
|
||||
@tool
|
||||
def search_course_docs(query: str, k: int = 3) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search the local FAQ Chroma vector store for relevant documents.
|
||||
|
||||
Returns a list of dictionaries containing the content and metadata.
|
||||
"""
|
||||
chroma = load_faq_to_chroma()
|
||||
results = chroma.similarity_search(query, k=k)
|
||||
output = []
|
||||
for doc in results:
|
||||
output.append(
|
||||
{
|
||||
"content": doc.page_content,
|
||||
"metadata": doc.metadata,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
@tool
|
||||
def fetch_course_meta(query: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Simulate an MCP-style HTTP tool that returns course metadata
|
||||
matching the query. The metadata is read from a local JSON file.
|
||||
"""
|
||||
meta_path = DATA_DIR / "course_meta.json"
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Simple keyword matching in schedule and instructor fields
|
||||
results = {}
|
||||
if "schedule" in query.lower():
|
||||
results["schedule"] = data.get("schedule", [])
|
||||
if "instructor" in query.lower() or "professor" in query.lower():
|
||||
results["instructor"] = data.get("instructor", {})
|
||||
if not results:
|
||||
# Default to returning the whole metadata if no keyword matched
|
||||
results = data
|
||||
return results
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
Vector store abstraction over ChromaDB.
|
||||
|
||||
The `VectorStore` class encapsulates all interactions with the ChromaDB
|
||||
collection. It uses the MCP-tool to generate embeddings for documents
|
||||
and queries.
|
||||
"""
|
||||
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
from typing import List, Dict
|
||||
|
||||
from .mcp_tool import get_embedding
|
||||
|
||||
|
||||
class VectorStore:
|
||||
"""
|
||||
Wrapper around a ChromaDB collection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
collection_name : str, optional
|
||||
Name of the collection to use. Defaults to "faq".
|
||||
"""
|
||||
|
||||
def __init__(self, collection_name: str = "faq"):
|
||||
self.client = chromadb.Client(Settings())
|
||||
self.collection = self.client.get_or_create_collection(name=collection_name)
|
||||
|
||||
def add_documents(self, documents: List[Dict[str, str]]) -> None:
|
||||
"""
|
||||
Add a list of documents to the collection.
|
||||
|
||||
Each document must contain a 'text' key and may optionally contain
|
||||
a 'metadata' dictionary.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
documents : List[Dict[str, str]]
|
||||
List of documents to add.
|
||||
"""
|
||||
ids = []
|
||||
texts = []
|
||||
embeddings = []
|
||||
metadatas = []
|
||||
|
||||
for i, doc in enumerate(documents):
|
||||
ids.append(str(i))
|
||||
texts.append(doc["text"])
|
||||
embeddings.append(get_embedding(doc["text"]))
|
||||
metadatas.append(doc.get("metadata", {}))
|
||||
|
||||
self.collection.add(
|
||||
ids=ids,
|
||||
documents=texts,
|
||||
embeddings=embeddings,
|
||||
metadatas=metadatas,
|
||||
)
|
||||
|
||||
def query(self, query_text: str, top_k: int = 5) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Retrieve the most relevant documents for a query.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query_text : str
|
||||
The query string.
|
||||
top_k : int, optional
|
||||
Number of results to return. Defaults to 5.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[Dict[str, str]]
|
||||
List of result dictionaries containing 'text', 'distance',
|
||||
and 'metadata'.
|
||||
"""
|
||||
embedding = get_embedding(query_text)
|
||||
results = self.collection.query(
|
||||
query_embeddings=[embedding],
|
||||
n_results=top_k,
|
||||
)
|
||||
|
||||
output = []
|
||||
for doc, dist, meta in zip(
|
||||
results["documents"][0],
|
||||
results["distances"][0],
|
||||
results["metadatas"][0],
|
||||
):
|
||||
output.append(
|
||||
{
|
||||
"text": doc,
|
||||
"distance": dist,
|
||||
"metadata": meta,
|
||||
}
|
||||
)
|
||||
return output
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ChromaClient } from 'chromadb';
|
||||
|
||||
/**
|
||||
* Simple embedding utility.
|
||||
* Produces a 768‑dimensional vector where each dimension is a count of
|
||||
* the number of words that hash to that index.
|
||||
*/
|
||||
function embed(text) {
|
||||
const vector = new Array(768).fill(0);
|
||||
const words = text.toLowerCase().split(/\s+/);
|
||||
for (const word of words) {
|
||||
const hash = [...word].reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
|
||||
const idx = hash % 768;
|
||||
vector[idx] += 1;
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around ChromaDB providing a minimal API for the bot.
|
||||
*/
|
||||
class ChromaVectorStore {
|
||||
constructor() {
|
||||
this.client = new ChromaClient();
|
||||
this.collection = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the collection. Creates it if it does not exist.
|
||||
* @param {string} name - Collection name.
|
||||
*/
|
||||
async init(name = 'faq') {
|
||||
this.collection = await this.client.getOrCreateCollection({
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds documents to the collection.
|
||||
* @param {Array<{id?: string, text: string, metadata?: object}>} docs
|
||||
*/
|
||||
async addDocuments(docs) {
|
||||
const ids = docs.map((d, idx) => d.id ?? `doc-${idx}`);
|
||||
const metadatas = docs.map(d => d.metadata ?? {});
|
||||
const embeddings = docs.map(d => embed(d.text));
|
||||
await this.collection.add({
|
||||
ids,
|
||||
documents: docs.map(d => d.text),
|
||||
metadatas,
|
||||
embeddings,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a similarity search.
|
||||
* @param {string} queryText
|
||||
* @param {number} k
|
||||
* @returns {Promise<Array<string>>} Top k documents.
|
||||
*/
|
||||
async similaritySearch(queryText, k = 3) {
|
||||
const queryEmbedding = embed(queryText);
|
||||
const results = await this.collection.query({
|
||||
queryEmbeddings: [queryEmbedding],
|
||||
nResults: k,
|
||||
});
|
||||
return results[0].documents;
|
||||
}
|
||||
}
|
||||
|
||||
export default ChromaVectorStore;
|
||||
export { embed };
|
||||
Reference in New Issue
Block a user