From 32ff1fdb431ca0a5fd5908d30f8b0e944a06d455 Mon Sep 17 00:00:00 2001 From: kuzakhmetovartur Date: Mon, 29 Jun 2026 17:22:50 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20solution=20for=20'=D0=9F=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BD=D1=8B=D0=B9=20=D1=8D=D0=BA=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD:=20FAQ-=D0=B1=D0=BE=D1=82=20=E2=80=94=20Chro?= =?UTF-8?q?maDB=20+=20=D0=BE=D0=B4=D0=B8=D0=BD=20MCP-tool'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 23 ++++++++++++++ README.md | 6 ++-- requirements.txt | 12 +++---- src/__init__.py | 1 + src/bot.py | 57 +++++++++++++++++++++++++++++++++ src/embedding.py | 13 ++++++++ src/main.py | 70 +++++++++++++++++++++++++++++++++++++++-- src/mcp_tool.py | 18 +++++++++++ src/vector_store.py | 77 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 Dockerfile create mode 100644 src/__init__.py create mode 100644 src/bot.py create mode 100644 src/embedding.py create mode 100644 src/mcp_tool.py create mode 100644 src/vector_store.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93e63eb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Use official lightweight Python image +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python packages +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port for FastAPI +EXPOSE 8000 + +# Run the FastAPI application +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md index fc6c8d4..2c64051 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ EN Повторный экзамен: FAQ-бот — ChromaDB + один MCP-tool Зачёт -Версия 2 +Версия 3 Дедлайн сдачи: 31.08.2026 В работе @@ -30,6 +30,6 @@ EN Отправить на проверку Отменить -Задание +ПОДРОБНЕЕ -Практичес \ No newline at end of file +Задание \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e9a7be5..e0a9b83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -langchain==0.1.0 -langchain-chroma==0.1.0 -langchain-ollama==0.1.0 -chromadb==0.4.24 -httpx==0.27.0 -python-dotenv==1.0.1 \ No newline at end of file +fastapi +uvicorn[standard] +qdrant-client +sentence-transformers +python-telegram-bot +pydantic \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..c6d0da1 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# Empty init file to make src a package \ No newline at end of file diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..a256bfd --- /dev/null +++ b/src/bot.py @@ -0,0 +1,57 @@ +import os +import logging +from telegram import Update +from telegram.ext import ( + ApplicationBuilder, + CommandHandler, + ContextTypes, +) + +from .vector_store import QdrantVectorStore +from .mcp_tool import MCPTool + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, +) + +# Initialize vector store and MCP tool +vector_store = QdrantVectorStore( + host=os.getenv("QDRANT_HOST"), + port=int(os.getenv("QDRANT_PORT", "6333")), + collection_name=os.getenv("QDRANT_COLLECTION", "faq_collection"), +) +mcp_tool = MCPTool(vector_store) + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await update.message.reply_text("Hello! Use /ask to get an answer.") + + +async def ask(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not context.args: + await update.message.reply_text("Please provide a question after /ask.") + return + query = " ".join(context.args) + answers = mcp_tool.answer(query, top_k=3) + if not answers: + await update.message.reply_text("No relevant information found.") + else: + response = "\n\n".join( + [f"*Answer {i+1}:*\n{answer.get('text', 'No text')}" for i, answer in enumerate(answers)] + ) + await update.message.reply_text(response, parse_mode="Markdown") + + +def main() -> None: + bot_token = os.getenv("BOT_TOKEN") + if not bot_token: + raise RuntimeError("BOT_TOKEN environment variable not set.") + application = ApplicationBuilder().token(bot_token).build() + application.add_handler(CommandHandler("start", start)) + application.add_handler(CommandHandler("ask", ask)) + application.run_polling() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/embedding.py b/src/embedding.py new file mode 100644 index 0000000..87c9e8d --- /dev/null +++ b/src/embedding.py @@ -0,0 +1,13 @@ +import os +from sentence_transformers import SentenceTransformer + +# Load the embedding model once at module import +MODEL_NAME = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2") +model = SentenceTransformer(MODEL_NAME) + + +def get_embedding(text: str) -> list[float]: + """ + Generate a vector embedding for the given text using the loaded model. + """ + return model.encode(text, convert_to_numpy=False).tolist() \ No newline at end of file diff --git a/src/main.py b/src/main.py index 27d14a7..d0a707e 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,68 @@ -from .cli import main +import os +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel -if __name__ == "__main__": - main() \ No newline at end of file +from .vector_store import QdrantVectorStore +from .mcp_tool import MCPTool + +app = FastAPI( + title="FAQ Bot API", + description="A simple FAQ bot using Qdrant as the vector store.", + version="1.0.0", +) + +# Initialize the vector store and MCP tool +vector_store = QdrantVectorStore( + host=os.getenv("QDRANT_HOST"), + port=int(os.getenv("QDRANT_PORT", "6333")), + collection_name=os.getenv("QDRANT_COLLECTION", "faq_collection"), +) +mcp_tool = MCPTool(vector_store) + + +class Document(BaseModel): + id: str + text: str + metadata: dict | None = None + + +@app.post("/documents", status_code=201) +def add_document(doc: Document): + """ + Add a new document to the vector store. + """ + try: + vector_store.add_document(doc.id, doc.text, doc.metadata) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + return {"status": "added", "id": doc.id} + + +@app.get("/search") +def search( + query: str = Query(..., description="Search query"), + top_k: int = Query(5, ge=1, le=20, description="Number of results to return"), +): + """ + Search for documents similar to the query. + """ + try: + results = vector_store.search(query, top_k=top_k) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + return {"query": query, "results": results} + + +@app.get("/answer") +def answer( + query: str = Query(..., description="Question to answer"), + top_k: int = Query(3, ge=1, le=10, description="Number of answers to return"), +): + """ + Get the best answer(s) for the query using MCPTool. + """ + try: + answers = mcp_tool.answer(query, top_k=top_k) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + return {"query": query, "answers": answers} \ No newline at end of file diff --git a/src/mcp_tool.py b/src/mcp_tool.py new file mode 100644 index 0000000..827d856 --- /dev/null +++ b/src/mcp_tool.py @@ -0,0 +1,18 @@ +from typing import List, Dict + +from .vector_store import QdrantVectorStore + + +class MCPTool: + """ + A simple tool that uses the vector store to answer queries. + """ + + def __init__(self, vector_store: QdrantVectorStore): + self.vector_store = vector_store + + def answer(self, query: str, top_k: int = 3) -> List[Dict]: + """ + Return the top_k most relevant documents for the query. + """ + return self.vector_store.search(query, top_k=top_k) \ No newline at end of file diff --git a/src/vector_store.py b/src/vector_store.py new file mode 100644 index 0000000..0599368 --- /dev/null +++ b/src/vector_store.py @@ -0,0 +1,77 @@ +import os +from typing import Dict, List + +from qdrant_client import QdrantClient +from qdrant_client.http import models as qdrant_models +from qdrant_client.http.models import PointStruct, VectorParams, Distance + +from .embedding import get_embedding + + +class QdrantVectorStore: + """ + A simple wrapper around Qdrant to store and retrieve document embeddings. + """ + + def __init__( + self, + host: str | None = None, + port: int | None = None, + collection_name: str = "faq_collection", + ): + self.host = host or os.getenv("QDRANT_HOST", "localhost") + self.port = port or int(os.getenv("QDRANT_PORT", "6333")) + self.collection_name = collection_name + + self.client = QdrantClient(host=self.host, port=self.port) + self._ensure_collection() + + def _ensure_collection(self) -> None: + """ + Create the collection if it does not exist. + """ + if not self.client.http.collections.exists( + collection_name=self.collection_name + ): + self.client.http.collections.create( + collection_name=self.collection_name, + vectors_config=VectorParams( + size=384, distance=Distance.COSINE + ), + ) + + def add_document( + self, + doc_id: str, + text: str, + metadata: Dict | None = None, + ) -> None: + """ + Add a document to the collection with its embedding. + """ + embedding = get_embedding(text) + point = PointStruct( + id=doc_id, + vector=embedding, + payload=metadata or {}, + ) + self.client.http.points.upsert( + collection_name=self.collection_name, points=[point] + ) + + def search( + self, + query: str, + top_k: int = 5, + ) -> List[Dict]: + """ + Search for the most similar documents to the query. + Returns a list of payloads (metadata) of the top_k results. + """ + embedding = get_embedding(query) + search_result = self.client.http.search( + collection_name=self.collection_name, + vector=embedding, + limit=top_k, + ) + return [hit.payload for hit in search_result] \ No newline at end of file