#!/usr/bin/env python3 """ Simplified RAG Learning Assistant Tracks learning progress across 17 subjects and provides AI tutoring guidance. """ import os import json import hashlib import asyncio from pathlib import Path from datetime import datetime from typing import Dict, List, Set, Optional from dataclasses import dataclass from rich.console import Console from prompt_toolkit import PromptSession from prompt_toolkit.styles import Style from langchain_community.document_loaders import UnstructuredMarkdownLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_ollama import OllamaEmbeddings, ChatOllama from langchain_chroma import Chroma from langchain_core.documents import Document from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser # ========================= # CONFIGURATION # ========================= console = Console(color_system="standard", force_terminal=True) session = PromptSession() style = Style.from_dict({"prompt": "bold #6a0dad"}) OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") MD_DIRECTORY = os.getenv("MD_FOLDER", "./notes") EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "mxbai-embed-large:latest") LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:7b-instruct-q8_0") CHROMA_PATH = "./.cache/chroma_db" KNOWLEDGE_STATE_PATH = "./.cache/knowledge_state.json" FILE_HASHES_PATH = "./.cache/file_hashes.json" CHUNK_SIZE = 400 CHUNK_OVERLAP = 50 TOP_K = 6 COLLECTION_NAME = "learning_rag" MAX_CONTEXT_CHARS = 8000 # ========================= # SUBJECT CONFIGURATION # ========================= SUBJECTS = { "computer_science": "Computer Science", "math": "Математика", "english": "Английский язык", "programming": "Основы программирования", "linux": "Операционные системы Linux", "windows": "Операционные системы Windows", "networking": "Сетевые технологии", "databases": "Базы данных и SQL", "web": "Веб-технологии", "cryptography": "Криптография", "cybersecurity": "Базовые принципы кибербезопасности", "pentest": "Тестирование на проникновение (Red Team)", "soc": "SOC и Blue Team", "devsecops": "DevSecOps", "tools": "Инструменты и практика", "certifications": "Сертификации и карьера", "professional": "Профессиональное развитие" } SUBJECT_KEYWORDS = { "computer_science": ["computer science", "алгоритмы", "data structures", "oop", "структуры данных"], "math": ["math", "математика", "алгебра", "геометрия", "дискретная", "logic", "логика", "теория чисел"], "english": ["english", "английский", "vocabulary", "grammar", "перевод", "словарь", "грамматика"], "programming": ["programming", "python", "код", "code", "разработка", "программирование"], "linux": ["linux", "kali", "bash", "terminal", "command line", "скрипт", "администрирование"], "windows": ["windows", "powershell", "администрирование windows"], "networking": ["network", "сеть", "tcp", "ip", "osi", "маршрутизация", "vlan", "протокол"], "databases": ["database", "sql", "база данных", "postgresql", "mysql"], "web": ["web", "html", "css", "javascript", "http", "frontend", "backend"], "cryptography": ["cryptography", "криптография", "шифрование", "rsa", "aes"], "cybersecurity": ["cybersecurity", "безопасность", "owasp", "уязвимость", "pentest"], "pentest": ["pentest", "pentesting", "red team", "тестирование на проникновение"], "soc": ["soc", "blue team", "security operations", "siem"], "devsecops": ["devsecops", "ci/cd", "security automation"], "tools": ["tools", "инструменты", "nmap", "burp", "metasploit", "wireshark"], "certifications": ["certification", "сертификация", "ceh", "oscp", "cissp"], "professional": ["github", "portfolio", "linkedin", "блог", "конференция"] } SYSTEM_PROMPT = """Ты — наставник-преподаватель по кибербезопасности. Твоя цель — довести ученика с уровня "пользователь ПК" до уровня junior в кибербезопасности. КУРСОВАЯ СТРУКТУРА (17 модулей): 1) Computer Science: с полного нуля до уровня стандарта мировых вузов 2) Математика: с полного нуля до уровня стандарта мировых вузов 3) Английский язык: с полного нуля до уровня B2 4) Основы программирования: с полного нуля до уровня стандарта мировых вузов 5) Операционные системы Linux: с полного нуля до уровня стандарта мировых вузов 6) Операционные системы Windows: с уровня пользователя до уровня стандарта мировых вузов 7) Сетевые технологии: с полного нуля до уровня стандарта мировых вузов 8) Базы данных и SQL: с полного нуля до уровня стандарта мировых вузов 9) Веб-технологии: с полного нуля до уровня стандарта мировых вузов 10) Криптография: с полного нуля до уровня стандарта мировых вузов 11) Базовые принципы кибербезопасности: с полного нуля до уровня стандарта мировых вузов 12) Тестирование на проникновение (Red Team): с полного нуля до уровня стандарта мировых вузов 13) SOC и Blue Team: с полного нуля до уровня стандарта мировых вузов 14) DevSecOps: с полного нуля до уровня стандарта мировых вузов 15) Инструменты и практика: список тем для изучения, без практических 16) Сертификации и карьера: список тем для изучения, без практических 17) Профессиональное развитие: GitHub портфолио, блог, нетворкинг, конференции МЕТОДОЛОГИЯ: - Каждый предмет = числовая прямая от 0 до ∞ - Темы = точки на прямой (например: "цифры" = 0.01, "дроби" = 0.04) - Без усвоения базы — не переходить дальше - Адаптация вектора обучения по прогрессу ФОРМАТ ОТВЕТА: "В [ПРЕДМЕТ] будем проходить [ТЕМА_1] и [ТЕМА_2]. [Дополнительные инструкции по структуре изучения]" ПРАВИЛА: - Проверяй усвоение предыдущих тем - Не суди по одному слову вне контекста - Учитывай межпредметные связи - Корректируй траекторию обучения динамически - Отвечай всегда на русском языке""" USER_PROMPT_TEMPLATE = """Текущий прогресс обучения: {progress} Контекст из заметок по предмету: {context} Вопрос ученика: {question}""" # ========================= # DATA STRUCTURES # ========================= @dataclass class SubjectProgress: name: str topics_covered: Set[str] last_studied: Optional[str] confidence_level: float = 0.0 def to_dict(self): return { "name": self.name, "topics_covered": list(self.topics_covered), "last_studied": self.last_studied, "confidence_level": self.confidence_level } @classmethod def from_dict(cls, data): return cls( name=data["name"], topics_covered=set(data.get("topics_covered", [])), last_studied=data.get("last_studied"), confidence_level=data.get("confidence_level", 0.0) ) @dataclass class KnowledgeState: subjects: Dict[str, SubjectProgress] last_analysis: str file_hashes: Dict[str, str] def to_dict(self): return { "subjects": {name: subject.to_dict() for name, subject in self.subjects.items()}, "last_analysis": self.last_analysis, "file_hashes": self.file_hashes } @classmethod def from_dict(cls, data): subjects = {} for name, subject_data in data.get("subjects", {}).items(): subjects[name] = SubjectProgress.from_dict(subject_data) return cls( subjects=subjects, last_analysis=data.get("last_analysis", ""), file_hashes=data.get("file_hashes", {}) ) # ========================= # UTILITY FUNCTIONS # ========================= def get_file_hash(file_path: str) -> str: return hashlib.md5(Path(file_path).read_bytes()).hexdigest() def load_json_cache(file_path: str) -> dict: Path(file_path).parent.mkdir(parents=True, exist_ok=True) if Path(file_path).exists(): try: return json.loads(Path(file_path).read_text()) except json.JSONDecodeError: return {} return {} def save_json_cache(data, file_path: str): try: Path(file_path).write_text(json.dumps(data, indent=2, ensure_ascii=False)) except Exception as e: console.print(f"[red]✗ Failed to save cache {file_path}: {e}[/red]") # ========================= # SUBJECT DETECTION # ========================= def detect_subjects_from_query(query: str) -> List[str]: query_lower = query.lower() detected = [] for subject, keywords in SUBJECT_KEYWORDS.items(): for keyword in keywords: if keyword.lower() in query_lower: if subject not in detected: detected.append(subject) break return detected def detect_subject_from_content(text: str) -> Optional[str]: text_lower = text.lower() subject_scores = {subject: 0 for subject in SUBJECT_KEYWORDS.keys()} for subject, keywords in SUBJECT_KEYWORDS.items(): for keyword in keywords: if keyword.lower() in text_lower: subject_scores[subject] += 1 best_subject = max(subject_scores.items(), key=lambda x: x[1]) return best_subject[0] if best_subject[1] > 0 else None # ========================= # LEARNING ASSISTANT # ========================= class LearningAssistant: def __init__(self): self.embeddings = OllamaEmbeddings( model=EMBEDDING_MODEL, base_url=OLLAMA_BASE_URL ) self.vectorstore = Chroma( collection_name=COLLECTION_NAME, persist_directory=CHROMA_PATH, embedding_function=self.embeddings ) self.llm = ChatOllama( model=LLM_MODEL, temperature=0.2, base_url=OLLAMA_BASE_URL ) self.prompt = ChatPromptTemplate.from_messages([ ("system", SYSTEM_PROMPT), ("human", USER_PROMPT_TEMPLATE) ]) self.chain = self.prompt | self.llm | StrOutputParser() self.text_splitter = RecursiveCharacterTextSplitter( chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, separators=["\n\n", "\n", ". ", " "] ) async def initialize(self): console.print("[bold cyan]🎓 RAG Learning System - Educational Assistant[/bold cyan]") console.print(f"📂 Notes Directory: {MD_DIRECTORY}") console.print(f"🧠 Model: {LLM_MODEL}\n") knowledge_state = await self.load_or_analyze_knowledge() console.print("[green]✓ System initialized successfully![/green]") console.print("[dim]💡 Tip: Просто напишите 'изучаем английский' или 'учим математику'[/dim]\n") return knowledge_state async def load_or_analyze_knowledge(self) -> KnowledgeState: file_hashes = self.get_file_hashes() state_data = load_json_cache(KNOWLEDGE_STATE_PATH) if state_data: knowledge_state = KnowledgeState.from_dict(state_data) if self.have_files_changed(file_hashes, knowledge_state.file_hashes): console.print("[yellow]📁 Files changed, re-analyzing knowledge...[/yellow]") knowledge_state = await self.analyze_all_notes(file_hashes) save_json_cache(knowledge_state.to_dict(), KNOWLEDGE_STATE_PATH) else: console.print("[green]✓ Knowledge state up to date[/green]") else: console.print("[yellow]📊 First time setup - analyzing all notes...[/yellow]") knowledge_state = await self.analyze_all_notes(file_hashes) save_json_cache(knowledge_state.to_dict(), KNOWLEDGE_STATE_PATH) return knowledge_state def get_file_hashes(self) -> Dict[str, str]: file_hashes = {} for root, _, files in os.walk(MD_DIRECTORY): for file in files: if file.endswith(".md"): file_path = os.path.join(root, file) try: file_hashes[file_path] = get_file_hash(file_path) except Exception as e: console.print(f"[red]✗ Error reading {file}: {e}[/red]") return file_hashes def have_files_changed(self, current: Dict[str, str], cached: Dict[str, str]) -> bool: if len(current) != len(cached): return True for path, hash_val in current.items(): if path not in cached or cached[path] != hash_val: return True return False async def analyze_all_notes(self, file_hashes: Dict[str, str]) -> KnowledgeState: console.print("[cyan]🔍 Analyzing all notes for learning progress...[/cyan]") subjects = { name: SubjectProgress(name=name, topics_covered=set(), last_studied=None) for name in SUBJECTS.keys() } try: db_data = await asyncio.to_thread(self.vectorstore.get) if db_data and db_data['documents']: for text, metadata in zip(db_data['documents'], db_data['metadatas']): if not metadata or 'source' not in metadata: continue subject = detect_subject_from_content(text) if subject: subjects[subject].topics_covered.add(text[:100]) file_path = metadata['source'] if file_path in file_hashes: subjects[subject].last_studied = file_hashes[file_path] for subject in subjects.values(): subject.confidence_level = min(len(subject.topics_covered) / 10.0, 1.0) studied_count = len([s for s in subjects.values() if s.topics_covered]) console.print(f"[green]✓ Analysis complete. Found progress in {studied_count} subjects[/green]") except Exception as e: console.print(f"[red]✗ Error during analysis: {e}[/red]") return KnowledgeState(subjects, datetime.now().isoformat(), file_hashes) async def get_relevant_context(self, subject: str) -> str: try: db_data = await asyncio.to_thread(self.vectorstore.get) if not db_data or not db_data['documents']: return "Нет доступных заметок для данного предмета." relevant_docs = [] for text, metadata in zip(db_data['documents'], db_data['metadatas']): detected_subject = detect_subject_from_content(text) if detected_subject == subject: relevant_docs.append({ "text": text, "source": Path(metadata.get('source', 'unknown')).name }) context = f"Найдено {len(relevant_docs)} заметок по предмету:\n" char_count = len(context) for doc in relevant_docs[:TOP_K]: doc_text = f"\n---\nИсточник: {doc['source']}\n{doc['text']}\n" if char_count + len(doc_text) > MAX_CONTEXT_CHARS: context += "\n[... Контекст обрезан из-за лимита ...]" break context += doc_text char_count += len(doc_text) if not relevant_docs: return f"Заметок по предмету '{SUBJECTS.get(subject, subject)}' не найдено." return context except Exception as e: console.print(f"[red]✗ Error getting context: {e}[/red]") return "Ошибка при получении контекста." def get_progress_summary(self, knowledge_state: KnowledgeState, subjects: List[str]) -> str: summary = "Текущий прогресс обучения:\n" for subject in subjects: if subject in knowledge_state.subjects: subj = knowledge_state.subjects[subject] if subj.topics_covered: summary += f"- {SUBJECTS[subject]}: {len(subj.topics_covered)} тем, уверенность {subj.confidence_level:.1%}\n" else: summary += f"- {SUBJECTS[subject]}: изучение с нуля\n" return summary async def process_learning_query(self, query: str, knowledge_state: KnowledgeState) -> str: subjects = detect_subjects_from_query(query) if not subjects: return "Пожалуйста, уточните предмет для изучения (например: 'изучаем английский', 'учим математику')." responses = [] for subject in subjects: context = await self.get_relevant_context(subject) progress = self.get_progress_summary(knowledge_state, [subject]) console.print(f"[blue]🔍 Анализирую прогресс по предмету: {SUBJECTS[subject]}[/blue]") response = "" console.print("[bold blue]Ассистент:[/bold blue] ", end="") async for chunk in self.chain.astream({ "context": context, "question": query, "progress": progress }): console.print(chunk, end="", style="blue") response += chunk console.print("\n") responses.append(response) return "\n\n".join(responses) if len(responses) > 1 else responses[0] async def index_files(self, file_paths: List[str]) -> bool: all_chunks = [] for file_path in file_paths: try: loader = UnstructuredMarkdownLoader(file_path) documents = loader.load() if documents: for doc in documents: doc.metadata["source"] = file_path chunks = self.text_splitter.split_documents(documents) all_chunks.extend(chunks) except Exception as e: console.print(f"[red]✗ Error processing {Path(file_path).name}: {e}[/red]") if not all_chunks: return False try: await asyncio.to_thread(self.vectorstore.reset_collection) batch_size = 20 for i in range(0, len(all_chunks), batch_size): batch = all_chunks[i:i + batch_size] await asyncio.to_thread(self.vectorstore.add_documents, batch) return True except Exception as e: console.print(f"[red]✗ Error indexing documents: {e}[/red]") return False # ========================= # MAIN APPLICATION # ========================= async def main(): Path(MD_DIRECTORY).mkdir(parents=True, exist_ok=True) assistant = LearningAssistant() try: knowledge_state = await assistant.initialize() while True: query = await session.prompt_async("> ", style=style) query = query.strip() if not query: continue if query.lower() in ['/exit', '/quit', 'exit', 'quit', 'выход']: console.print("\n👋 До свидания! Удачи в обучении!", style="yellow") break if query.lower() in ['/help', 'help', 'помощь']: await show_help() continue if query.lower() in ['/reindex', 'reindex']: console.print("[yellow]🔄 Переиндексирую все файлы...[/yellow]") files = [os.path.join(root, f) for root, _, files in os.walk(MD_DIRECTORY) for f in files if f.endswith(".md")] if not files: console.print("[yellow]⚠️ Markdown файлы не найдены[/yellow]") continue success = await assistant.index_files(files) if success: console.print("[cyan]📊 Анализирую знания...[/cyan]") knowledge_state = await assistant.analyze_all_notes( assistant.get_file_hashes() ) save_json_cache(knowledge_state.to_dict(), KNOWLEDGE_STATE_PATH) console.print("[green]✓ Индексация завершена![/green]") else: console.print("[red]✗ Ошибка индексации[/red]") continue await assistant.process_learning_query(query, knowledge_state) except KeyboardInterrupt: console.print("\n👋 До свидания! Удачи в обучении!", style="yellow") except Exception as e: console.print(f"[red]✗ Unexpected error: {e}[/red]") async def show_help(): console.print("\n[bold cyan]🎓 RAG Learning System - Справка[/bold cyan]") console.print("=" * 60, style="dim") console.print("\n[bold green]Использование:[/bold green]") console.print("Просто напишите, что хотите изучать:") console.print(" • 'изучаем английский'") console.print(" • 'учим математику и программирование'") console.print(" • 'давай по сетям'") console.print(" • 'пора изучать кибербезопасность'\n") console.print("[bold green]Доступные предметы:[/bold green]") for key, name in SUBJECTS.items(): console.print(f" • {name}") console.print("\n[bold green]Команды:[/bold green]") console.print(" • /help или помощь - показать эту справку") console.print(" • /reindex - переиндексировать все файлы") console.print(" • exit, quit, выход - выйти из программы") console.print("\n[bold green]Как работает система:[/bold green]") console.print("1. Система анализирует все ваши .md файлы при запуске") console.print("2. Определяет, по каким предметам у вас есть заметки") console.print("3. Когда вы указываете предмет, находит релевантные заметки") console.print("4. AI ассистент строит обучение на основе ваших заметок") console.print("5. Если заметок нет - начинает обучение с нуля\n") if __name__ == "__main__": import nest_asyncio nest_asyncio.apply() try: asyncio.run(main()) except KeyboardInterrupt: console.print("\n👋 До свидания! Удачи в обучении!", style="yellow")