Files

167 lines
6.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""main.py
Простой FAQ‑бот, использующий ChromaDB для локальных конспектов и один MCP‑подобный инструмент – HTTP‑запрос к статическому JSON.
Требования:
- Python 3.10+
- Ollama модели: `nomic-embed-text` и `llama3`
- Пакеты из requirements.txt
Запуск:
```bash
python main.py
```
В интерактивном режиме можно задавать вопросы. В примере уже есть три готовых вопроса – два из Chroma, один из MCP‑тул.
"""
import json
import os
import sys
from pathlib import Path
from typing import List
import httpx
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_chroma import Chroma
from langchain_core.tools import BaseTool, tool
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import Runnable
from langchain.agents import create_agent, AgentExecutor, Tool
# ---------------------------------------------------------------------------
# 1. Векторная база
# ---------------------------------------------------------------------------
CHROMA_PATH = Path("./chroma_faq")
DATA_PATH = Path("./data")
# Создаём embeddings
embeddings = OllamaEmbeddings(model="nomic-embed-text")
# ---------------------------------------------------------------------------
# Чтение markdown‑файлов и загрузка в Chroma
# ---------------------------------------------------------------------------
def load_faq_to_chroma() -> Chroma:
"""Загружает все .md файлы из DATA_PATH в Chroma.
Если коллекция уже существует – просто возвращаем её.
"""
if CHROMA_PATH.exists():
return Chroma(
collection_name="faq",
embedding_function=embeddings,
persist_directory=str(CHROMA_PATH),
)
# Если нет, создаём новую коллекцию
from langchain_text_splitters import MarkdownTextSplitter
splitter = MarkdownTextSplitter(chunk_size=500, chunk_overlap=50)
docs = []
for md_file in DATA_PATH.glob("*.md"):
text = md_file.read_text(encoding="utf-8")
docs.extend(splitter.split_text(text))
vector_store = Chroma.from_texts(
texts=docs,
embedding=embeddings,
collection_name="faq",
persist_directory=str(CHROMA_PATH),
)
return vector_store
# ---------------------------------------------------------------------------
# 2. Tool: поиск по Chroma
# ---------------------------------------------------------------------------
@tool
def search_course_docs(query: str, k: int = 3) -> str:
"""Возвращает кортеж из k наиболее релевантных фрагментов.
Формат ответа: ``source: chroma`` + текст.
"""
vector_store = load_faq_to_chroma()
results = vector_store.similarity_search(query, k=k)
snippets = "\n---\n".join([r.page_content for r in results])
return f"source: chroma\n{snippets}"
# ---------------------------------------------------------------------------
# 3. MCP‑подобный инструмент – HTTP‑запрос к статическому JSON
# ---------------------------------------------------------------------------
MCP_JSON_PATH = Path("./course_meta.json")
@tool
def fetch_course_meta(query: str) -> str:
"""Имитирует вызов MCP‑сервера. Читает локальный JSON и возвращает
информацию, содержащуюся в ключе, совпадающем с query.
"""
if not MCP_JSON_PATH.exists():
return "source: mcp_meta\nMeta file not found."
data = json.loads(MCP_JSON_PATH.read_text(encoding="utf-8"))
# простая логика: ищем ключ, содержащий query (caseinsensitive)
for key, value in data.items():
if query.lower() in key.lower():
return f"source: mcp_meta\n{key}: {value}"
return "source: mcp_meta\nNo matching metadata found."
# ---------------------------------------------------------------------------
# 4. Создание агента
# ---------------------------------------------------------------------------
# Системный промпт, который заставляет агент выбирать нужный инструмент
SYSTEM_PROMPT = (
"You are a helpful assistant for a course FAQ. Use the provided tools to answer the user. "
"If the question is about course materials, use search_course_docs. "
"If it is about schedule or metadata, use fetch_course_meta. "
"Do not use both tools unless necessary. "
"Always prefix your answer with the source: chroma or source: mcp_meta."
)
# Создаём LLM
llm = ChatOllama(model="llama3", temperature=0.2)
# Prompt template
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("human", "{input}"),
])
# Создаём Runnable, который будет использовать инструменты
agent = create_agent(
llm=llm,
tools=[search_course_docs, fetch_course_meta],
system_message=SYSTEM_PROMPT,
verbose=True,
)
# ---------------------------------------------------------------------------
# 5. CLI
# ---------------------------------------------------------------------------
PREDEFINED_QUESTIONS = [
"What topics are covered in the first lecture?", # Chroma
"Explain the concept of polymorphism in the context of the course.", # Chroma
"What is the schedule for the next week?", # MCP
]
def run_cli():
print("--- FAQ Bot ---")
print("Predefined questions:")
for i, q in enumerate(PREDEFINED_QUESTIONS, 1):
print(f"{i}. {q}")
print("\nEnter 1-3 to ask a predefined question, or type your own question.")
while True:
user_input = input("\n> ")
if user_input.lower() in {"exit", "quit"}:
print("Goodbye!")
break
if user_input.isdigit() and 1 <= int(user_input) <= len(PREDEFINED_QUESTIONS):
question = PREDEFINED_QUESTIONS[int(user_input) - 1]
else:
question = user_input
try:
response = agent.invoke({"input": question})
print("\nAnswer:\n", response)
except Exception as e:
print("Error:", e)
if __name__ == "__main__":
# Убедимся, что данные загружены
load_faq_to_chroma()
run_cli()