feat: solution for 'Повторный экзамен: FAQ-бот — ChromaDB + один MCP-tool'
This commit is contained in:
+120
-118
@@ -1,139 +1,141 @@
|
||||
import os
|
||||
from typing import List, Dict
|
||||
|
||||
import openai
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qdrant_models
|
||||
import re
|
||||
import click
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from langchain_ollama import Ollama, OllamaEmbeddings
|
||||
from langchain.vectorstores import Chroma
|
||||
from langchain.chains import RetrievalQA
|
||||
from langchain.prompts import PromptTemplate
|
||||
from langchain.tools import Tool
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
|
||||
CHROMA_DB_PATH = os.getenv("CHROMA_DB_PATH", "./chromadb")
|
||||
FAQ_DATA_PATH = Path("data/faq.csv")
|
||||
|
||||
# Configuration
|
||||
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
|
||||
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
|
||||
QDRANT_API_KEY = os.getenv("QDRANT_API_KEY", None)
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
# Initialize embeddings and LLM
|
||||
embeddings = OllamaEmbeddings(model=OLLAMA_MODEL)
|
||||
llm = Ollama(model=OLLAMA_MODEL)
|
||||
|
||||
if not OPENAI_API_KEY:
|
||||
raise RuntimeError("OPENAI_API_KEY environment variable not set")
|
||||
# Initialize Chroma client and collection
|
||||
from chromadb import Client
|
||||
client = Client(path=CHROMA_DB_PATH)
|
||||
collection = client.get_or_create_collection(name="faq")
|
||||
|
||||
openai.api_key = OPENAI_API_KEY
|
||||
# Create vector store
|
||||
vectorstore = Chroma(collection=collection, embedding=embeddings)
|
||||
|
||||
# Collection name
|
||||
COLLECTION_NAME = "faq_collection"
|
||||
# Prompt template for RetrievalQA
|
||||
prompt = PromptTemplate(
|
||||
input_variables=["context", "question"],
|
||||
template=(
|
||||
"You are a helpful FAQ bot. Use the following context to answer the question.\n"
|
||||
"Context: {context}\n"
|
||||
"Question: {question}\n"
|
||||
"Answer:"
|
||||
),
|
||||
)
|
||||
|
||||
# Embedding dimension for text-embedding-ada-002
|
||||
EMBEDDING_DIM = 1536
|
||||
# RetrievalQA chain
|
||||
retrieval_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
chain_type="stuff",
|
||||
retriever=vectorstore.as_retriever(),
|
||||
chain_type_kwargs={"prompt": prompt},
|
||||
)
|
||||
|
||||
# Sample FAQ data
|
||||
FAQ_DATA = [
|
||||
{
|
||||
"question": "What is the return policy?",
|
||||
"answer": "You can return any item within 30 days of purchase."
|
||||
},
|
||||
{
|
||||
"question": "How do I track my order?",
|
||||
"answer": "Use the tracking link sent to your email after shipping."
|
||||
},
|
||||
{
|
||||
"question": "Do you offer international shipping?",
|
||||
"answer": "Yes, we ship to most countries worldwide."
|
||||
},
|
||||
{
|
||||
"question": "What payment methods are accepted?",
|
||||
"answer": "We accept credit cards, PayPal, and bank transfers."
|
||||
},
|
||||
{
|
||||
"question": "How can I contact customer support?",
|
||||
"answer": "Email us at support@example.com or call 1-800-123-4567."
|
||||
}
|
||||
]
|
||||
# MCP-tool: Current Time Tool
|
||||
def get_current_time(_input: str) -> str:
|
||||
"""Return the current system time."""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def get_embedding(text: str) -> List[float]:
|
||||
"""
|
||||
Generate an embedding for the given text using OpenAI's embedding model.
|
||||
"""
|
||||
response = openai.Embedding.create(
|
||||
input=text,
|
||||
model="text-embedding-ada-002"
|
||||
time_tool = Tool(
|
||||
name="CurrentTime",
|
||||
description="Returns the current system time. Useful when the user asks about the time or date.",
|
||||
func=get_current_time,
|
||||
)
|
||||
|
||||
def ingest_faq():
|
||||
"""Read FAQ data from CSV and upsert into Chroma collection."""
|
||||
if not FAQ_DATA_PATH.exists():
|
||||
click.echo(f"FAQ data file not found at {FAQ_DATA_PATH}")
|
||||
return
|
||||
df = pd.read_csv(FAQ_DATA_PATH)
|
||||
if "question" not in df.columns or "answer" not in df.columns:
|
||||
click.echo("FAQ CSV must contain 'question' and 'answer' columns.")
|
||||
return
|
||||
# Prepare documents
|
||||
docs = df["answer"].tolist()
|
||||
metadatas = [{"question": q} for q in df["question"]]
|
||||
ids = [str(i) for i in range(len(docs))]
|
||||
# Upsert into collection
|
||||
collection.upsert(
|
||||
documents=docs,
|
||||
metadatas=metadatas,
|
||||
ids=ids,
|
||||
)
|
||||
return response["data"][0]["embedding"]
|
||||
click.echo(f"Ingested {len(docs)} FAQ entries into Chroma collection.")
|
||||
|
||||
def create_or_recreate_collection(client: QdrantClient) -> None:
|
||||
"""
|
||||
Create a new collection or recreate it if it already exists.
|
||||
"""
|
||||
client.recreate_collection(
|
||||
collection_name=COLLECTION_NAME,
|
||||
vectors_config=qdrant_models.VectorParams(
|
||||
size=EMBEDDING_DIM,
|
||||
distance=qdrant_models.Distance.COSINE
|
||||
)
|
||||
)
|
||||
def is_collection_empty() -> bool:
|
||||
"""Check if the Chroma collection has any documents."""
|
||||
return len(collection.get(ids=None)["ids"]) == 0
|
||||
|
||||
def ingest_faqs(client: QdrantClient, faqs: List[Dict[str, str]]) -> None:
|
||||
"""
|
||||
Ingest FAQ data into Qdrant.
|
||||
"""
|
||||
points = []
|
||||
for idx, faq in enumerate(faqs):
|
||||
vector = get_embedding(faq["question"])
|
||||
point = qdrant_models.PointStruct(
|
||||
id=idx,
|
||||
vector=vector,
|
||||
payload={
|
||||
"question": faq["question"],
|
||||
"answer": faq["answer"]
|
||||
}
|
||||
)
|
||||
points.append(point)
|
||||
def answer_query(question: str) -> str:
|
||||
"""Determine whether to use the time tool or the retrieval chain."""
|
||||
if re.search(r"\b(time|date)\b", question, re.I):
|
||||
return time_tool.run(question)
|
||||
else:
|
||||
return retrieval_chain.run(question)
|
||||
|
||||
# Upsert points in batches
|
||||
batch_size = 100
|
||||
for i in range(0, len(points), batch_size):
|
||||
batch = points[i:i+batch_size]
|
||||
client.upsert(
|
||||
collection_name=COLLECTION_NAME,
|
||||
points=batch
|
||||
)
|
||||
# CLI implementation
|
||||
@click.group()
|
||||
def cli():
|
||||
"""FAQ Bot CLI."""
|
||||
pass
|
||||
|
||||
def query_faq(client: QdrantClient, question: str, top_k: int = 1) -> str:
|
||||
"""
|
||||
Query the FAQ collection for the most relevant answer.
|
||||
"""
|
||||
query_vector = get_embedding(question)
|
||||
search_result = client.search(
|
||||
collection_name=COLLECTION_NAME,
|
||||
query_vector=query_vector,
|
||||
limit=top_k,
|
||||
with_payload=True
|
||||
)
|
||||
if not search_result:
|
||||
return "Sorry, I couldn't find an answer to your question."
|
||||
# Return the answer from the top result
|
||||
return search_result[0].payload.get("answer", "Answer not found.")
|
||||
@cli.command()
|
||||
@click.argument("question", nargs=-1, required=True)
|
||||
@click.option("--init", is_flag=True, help="Ingest FAQ data before answering.")
|
||||
def ask(question, init):
|
||||
"""Ask a question to the FAQ bot."""
|
||||
if init or is_collection_empty():
|
||||
ingest_faq()
|
||||
query = " ".join(question)
|
||||
answer = answer_query(query)
|
||||
click.echo(f"Answer: {answer}")
|
||||
|
||||
def main() -> None:
|
||||
client = QdrantClient(
|
||||
host=QDRANT_HOST,
|
||||
port=QDRANT_PORT,
|
||||
api_key=QDRANT_API_KEY
|
||||
)
|
||||
@cli.command()
|
||||
@click.option("--init", is_flag=True, help="Ingest FAQ data before starting the server.")
|
||||
def serve(init):
|
||||
"""Start the FastAPI web server."""
|
||||
if init or is_collection_empty():
|
||||
ingest_faq()
|
||||
import uvicorn
|
||||
uvicorn.run("src.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
# Ingest FAQs (only if collection is empty or you want to refresh)
|
||||
print("Ingesting FAQ data into Qdrant...")
|
||||
create_or_recreate_collection(client)
|
||||
ingest_faqs(client, FAQ_DATA)
|
||||
print("Ingestion complete.")
|
||||
# FastAPI web interface
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
print("\nFAQ Bot is ready. Type your question (or 'exit' to quit).")
|
||||
while True:
|
||||
user_input = input("\nYour question: ").strip()
|
||||
if user_input.lower() in {"exit", "quit"}:
|
||||
print("Goodbye!")
|
||||
break
|
||||
answer = query_faq(client, user_input)
|
||||
print(f"Answer: {answer}")
|
||||
app = FastAPI(title="FAQ Bot API")
|
||||
|
||||
class QuestionRequest(BaseModel):
|
||||
question: str
|
||||
|
||||
class AnswerResponse(BaseModel):
|
||||
answer: str
|
||||
|
||||
@app.post("/ask", response_model=AnswerResponse)
|
||||
async def ask_endpoint(req: QuestionRequest):
|
||||
if not req.question:
|
||||
raise HTTPException(status_code=400, detail="Question cannot be empty.")
|
||||
answer = answer_query(req.question)
|
||||
return AnswerResponse(answer=answer)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
cli()
|
||||
Reference in New Issue
Block a user