add main.py
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Main entry point for the FAQ‑bot assignment.
|
||||||
|
|
||||||
|
The bot answers questions about course materials stored in a local ChromaDB
|
||||||
|
vector store and, when necessary, calls an external MCP‑style tool that
|
||||||
|
returns metadata such as the course schedule. The implementation uses
|
||||||
|
LangChain v1+ (``langchain>=1.0.0``) together with ``langchain-chroma``
|
||||||
|
and ``langchain-ollama`` for embeddings.
|
||||||
|
|
||||||
|
The file contains:
|
||||||
|
* Utility functions to load markdown files into ChromaDB.
|
||||||
|
* Two tools – one that searches the vector store and another that mocks an
|
||||||
|
MCP call.
|
||||||
|
* A simple LangChain agent that decides which tool to use based on the
|
||||||
|
content of the user query.
|
||||||
|
* Three example queries executed when the script is run as ``__main__``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dependencies – all are declared in requirements.txt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
from langchain_ollama import ChatOllama
|
||||||
|
from langchain_chroma import Chroma
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langchain.agents import create_agent
|
||||||
|
from langchain.tools import tool
|
||||||
|
from langchain.embeddings.ollama import OllamaEmbeddings
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DATA_DIR = pathlib.Path("data") # directory with .md files
|
||||||
|
CHROMA_PATH = pathlib.Path("./chroma_faq") # persistent store location
|
||||||
|
MCP_ENDPOINT = "http://localhost:8000/course_meta" # mock endpoint
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper – load markdown files into ChromaDB
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tool(name="load_faq_to_chroma", description="Load all .md files from data/ into a persistent Chroma vector store.")
|
||||||
|
def load_faq_to_chroma() -> str:
|
||||||
|
"""Create or update the Chroma collection with local markdown files.
|
||||||
|
|
||||||
|
The function is idempotent – it will overwrite existing documents
|
||||||
|
with the same IDs. It returns a short JSON string describing how many
|
||||||
|
documents were added.
|
||||||
|
"""
|
||||||
|
# Ensure data directory exists
|
||||||
|
if not DATA_DIR.exists():
|
||||||
|
return json.dumps({"error": f"{DATA_DIR} does not exist."})
|
||||||
|
|
||||||
|
# Gather all markdown files
|
||||||
|
md_files = list(DATA_DIR.glob("*.md"))
|
||||||
|
if not md_files:
|
||||||
|
return json.dumps({"error": "No .md files found in data/"})
|
||||||
|
|
||||||
|
# Prepare documents for Chroma
|
||||||
|
docs: List[Dict[str, str]] = []
|
||||||
|
for file_path in md_files:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
docs.append({"id": file_path.stem, "content": content})
|
||||||
|
|
||||||
|
# Create embeddings and store
|
||||||
|
embedding = OllamaEmbeddings(model="nomic-embed-text")
|
||||||
|
chroma = Chroma.from_documents(
|
||||||
|
documents=[doc["content"] for doc in docs],
|
||||||
|
ids=[doc["id"] for doc in docs],
|
||||||
|
embedding=embedding,
|
||||||
|
persist_directory=str(CHROMA_PATH),
|
||||||
|
)
|
||||||
|
|
||||||
|
return json.dumps({"status": "loaded", "documents": len(docs)})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool – search the vector store
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tool(name="search_course_docs", description="Search local course documents for a query. Returns top k results.")
|
||||||
|
def search_course_docs(query: str, k: int = 3) -> str:
|
||||||
|
"""Return the most relevant chunks from the Chroma store.
|
||||||
|
|
||||||
|
The function loads the persistent collection and performs a similarity
|
||||||
|
search. Results are returned as a numbered list of snippets.
|
||||||
|
"""
|
||||||
|
if not CHROMA_PATH.exists():
|
||||||
|
return json.dumps({"error": f"Chroma store {CHROMA_PATH} not found."})
|
||||||
|
|
||||||
|
embedding = OllamaEmbeddings(model="nomic-embed-text")
|
||||||
|
chroma = Chroma(persist_directory=str(CHROMA_PATH), embedding_function=embedding)
|
||||||
|
docs = chroma.similarity_search(query, k=k)
|
||||||
|
if not docs:
|
||||||
|
return json.dumps({"result": "No relevant documents found."})
|
||||||
|
|
||||||
|
snippets = [f"{i+1}. {doc.page_content[:200]}..." for i, doc in enumerate(docs)]
|
||||||
|
return json.dumps({"source": "chroma", "snippets": snippets})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool – mock MCP call (could be replaced with a real HTTP request)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tool(name="fetch_course_meta", description="Retrieve course metadata such as schedule. Returns JSON.")
|
||||||
|
def fetch_course_meta(query: str) -> str:
|
||||||
|
"""Simulate an external MCP tool by returning static data.
|
||||||
|
|
||||||
|
In a real deployment this would perform an HTTP GET to the MCP server.
|
||||||
|
For the purposes of the assignment we simply return a hard‑coded JSON
|
||||||
|
payload that mimics what an MCP endpoint might provide.
|
||||||
|
"""
|
||||||
|
# Static mock data – in practice replace with httpx.get(MCP_ENDPOINT)
|
||||||
|
mock_data = {
|
||||||
|
"schedule": [
|
||||||
|
{"day": "Monday", "time": "10:00-12:00", "topic": "Introduction"},
|
||||||
|
{"day": "Wednesday", "time": "14:00-16:00", "topic": "Advanced Topics"},
|
||||||
|
],
|
||||||
|
"instructor": "Prof. Alexei Petrov",
|
||||||
|
}
|
||||||
|
return json.dumps({"source": "mcp_meta", "data": mock_data})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent – decides which tool to use based on the query content
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System prompt guides the agent not to call both tools unnecessarily.
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"You are an assistant that answers questions about course materials."
|
||||||
|
" If the question is about lecture notes or FAQs, use the search_course_docs tool."
|
||||||
|
" If the question asks for schedule, instructor info, or other metadata, use fetch_course_meta."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the agent once – it will be reused in examples.
|
||||||
|
llm = ChatOllama(model="llama3", temperature=0.0)
|
||||||
|
agent = create_agent(
|
||||||
|
llm=llm,
|
||||||
|
tools=[search_course_docs, fetch_course_meta],
|
||||||
|
system_prompt=SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Example usage – three predefined queries and an interactive prompt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
EXAMPLES: List[str] = [
|
||||||
|
"What topics are covered in the first lecture?",
|
||||||
|
"When is the next class scheduled?",
|
||||||
|
"Can you give me a summary of the course material?",
|
||||||
|
]
|
||||||
|
|
||||||
|
def run_example(query: str) -> None:
|
||||||
|
print(f"\nUser: {query}")
|
||||||
|
result = agent.invoke(
|
||||||
|
{"messages": [HumanMessage(content=query)]},
|
||||||
|
{"configurable": {"thread_id": "example-session"}},
|
||||||
|
)
|
||||||
|
# The agent returns a list of messages – the last one is the answer.
|
||||||
|
answer = result["messages"][-1].content
|
||||||
|
print(f"Assistant: {answer}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Ensure data directory exists for demo purposes
|
||||||
|
if not DATA_DIR.exists():
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Create a tiny FAQ file as an example.
|
||||||
|
(DATA_DIR / "faq.md").write_text(
|
||||||
|
"# Course FAQ\n" \
|
||||||
|
"## What is the course about?\n" \
|
||||||
|
"The course covers advanced topics in AI and machine learning."
|
||||||
|
)
|
||||||
|
# Load data into Chroma (idempotent)
|
||||||
|
print(load_faq_to_chroma())
|
||||||
|
|
||||||
|
for q in EXAMPLES:
|
||||||
|
run_example(q)
|
||||||
|
|
||||||
|
# Interactive mode – optional
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = input("\nAsk a question (or 'quit'): ")
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
if user_input.lower() in {"quit", "exit"}:
|
||||||
|
break
|
||||||
|
run_example(user_input)
|
||||||
|
"""
|
||||||
Reference in New Issue
Block a user