import os import asyncio import json from pathlib import Path from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_chroma import Chroma from langchain_core.documents import Document from langchain.tools import tool from deepagents import create_deep_agent from deepagents.backends import FilesystemBackend, LocalShellBackend, CompositeBackend from langchain_core.messages import HumanMessage # DESIGN DECISION: Use ChromaDB for local vector store # NECESSITY: The assignment explicitly requires ChromaDB + Ollama embeddings. # OPTIMALITY: ChromaDB is lightweight, file-based, and integrates directly with LangChain. # ALTERNATIVES CONSIDERED: QDrant would need a separate server process and more setup. # Embeddings via OpenRouter embeddings = OpenAIEmbeddings( model="text-embedding-3-small", base_url="https://openrouter.ai/api/v1", api_key=os.getenv("OPENAI_API_KEY"), ) # Persist directory for Chroma CHROMA_DIR = Path("./chroma_faq") def load_faq_to_chroma(): """ Load .md files from data/ into ChromaDB. """ vector_store = Chroma( collection_name="faq", embedding_function=embeddings, persist_directory=str(CHROMA_DIR), ) if not CHROMA_DIR.exists() or not any(CHROMA_DIR.iterdir()): docs = [] for md_file in Path("data").glob("*.md"): content = md_file.read_text(encoding="utf-8") docs.append(Document(page_content=content, metadata={"source": md_file.name})) vector_store.add_documents(docs) vector_store.persist() return vector_store vector_store = load_faq_to_chroma() @tool def search_course_docs(query: str) -> str: """ Search the knowledge base for relevant information. """ docs = vector_store.similarity_search(query, k=3) return "\n\n".join(d.page_content for d in docs) if docs else "No results found." @tool def fetch_course_meta(query: str) -> str: """ Fetch course metadata from a static JSON file. """ meta_path = Path("meta.json") if not meta_path.exists(): return "Metadata file not found." data = json.loads(meta_path.read_text(encoding="utf-8")) # Simple case-insensitive search in keys and values matches = [] for key, value in data.items(): if isinstance(value, dict): for subkey, subvalue in value.items(): if query.lower() in subkey.lower() or query.lower() in str(subvalue).lower(): matches.append(f"{subkey}: {subvalue}") else: if query.lower() in key.lower() or query.lower() in str(value).lower(): matches.append(f"{key}: {value}") return "\n".join(matches) if matches else "No metadata matches your query." # LLM via OpenRouter llm = ChatOpenAI( model="openai/gpt-oss-20b:free", base_url="https://openrouter.ai/api/v1", api_key=os.getenv("OPENAI_API_KEY"), temperature=0.0, ) backend = CompositeBackend([ LocalShellBackend(workspace_dir="./workspace"), FilesystemBackend(), ]) system_prompt = ( "You are a helpful FAQ bot. Use search_course_docs for questions about course materials. " "Use fetch_course_meta for questions about schedule or metadata. " "Do not use both tools unless necessary. " "Indicate source in your answer: source: chroma | mcp_meta." ) agent = create_deep_agent( model=llm, tools=[search_course_docs, fetch_course_meta], backend=backend, system_prompt=system_prompt, ) async def ask_agent(question: str, thread_id: str = "session-1") -> str: result = await agent.ainvoke( {"messages": [HumanMessage(content=question)]}, {"configurable": {"thread_id": thread_id}}, ) return result["messages"][-1].content async def main(): preset_questions = [ "What is covered in Lecture 1?", "Explain supervised learning.", "When is Lecture 2 scheduled?", ] print("=== Preset questions ===") for q in preset_questions: answer = await ask_agent(q) print(f"\nQ: {q}\nA: {answer}\n") print("=== Interactive mode (type 'exit' to quit) ===") while True: user_input = input("\nYour question: ") if user_input.lower() in {"exit", "quit"}: break answer = await ask_agent(user_input) print(f"\nAnswer: {answer}") if __name__ == "__main__": asyncio.run(main())