From 3bc8e27c193986b9489e455b4b7be84f9f0d5172 Mon Sep 17 00:00:00 2001 From: y9938 Date: Wed, 31 Dec 2025 05:41:22 +0300 Subject: [PATCH] refactor --- main.py | 1333 ++++++++++++++++++------------------------------ pyproject.toml | 3 + uv.lock | 182 +++++++ 3 files changed, 674 insertions(+), 844 deletions(-) diff --git a/main.py b/main.py index d34501f..64759fd 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 """ -RAG Learning System -A dual-mode RAG system designed for progressive learning with AI guidance. -Tracks your knowledge, suggests new topics, and helps identify learning gaps. +RAG Learning System - Simplified Educational Assistant +Tracks learning progress across subjects and provides AI tutoring guidance. """ import os @@ -11,23 +10,20 @@ import json import hashlib import asyncio import re -import yaml from pathlib import Path -from collections import deque, defaultdict -from typing import List, Dict, Set -from datetime import datetime, timedelta +from datetime import datetime +from typing import Dict, List, Set, Optional +from dataclasses import dataclass, asdict from dotenv import load_dotenv from rich.console import Console from rich.panel import Panel -from rich.table import Table -from rich.prompt import Prompt, Confirm +from rich.prompt import Prompt from rich.progress import Progress, SpinnerColumn, TextColumn from prompt_toolkit import PromptSession from prompt_toolkit.styles import Style from langchain_community.document_loaders import UnstructuredMarkdownLoader -from langchain_community.vectorstores.utils import filter_complex_metadata from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_ollama import OllamaEmbeddings, ChatOllama from langchain_chroma import Chroma @@ -35,9 +31,6 @@ from langchain_core.documents import Document from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler - # ========================= # CONFIGURATION # ========================= @@ -51,46 +44,84 @@ style = Style.from_dict({"prompt": "bold #6a0dad"}) OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") ANSWER_COLOR = os.getenv("ANSWER_COLOR", "blue") -# Enhanced System Prompts -SYSTEM_PROMPT_SEARCH = os.getenv("SYSTEM_PROMPT", - "You are a precise technical assistant. Use the provided context to answer questions accurately. " - "Cite sources using [filename]. If the context doesn't contain the answer, say so.") +# Subject-specific keywords for filtering +SUBJECT_KEYWORDS = { + "english": ["english", "английский", "vocabulary", "grammar", "перевод", "словарь", "грамматика"], + "math": ["math", "математика", "алгебра", "геометрия", "calculus", "дискретная", "logic", "логика"], + "cs": ["computer science", "алгоритмы", "data structures", "oop", "python", "programming", "код"], + "electronics": ["electronics", "электротехника", "circuit", "микроконтроллер", "arduino", "цифровая"], + "linux": ["linux", "kali", "bash", "terminal", "command line", "скрипт", "администрирование"], + "networking": ["network", "сеть", "tcp", "ip", "osi", "маршрутизация", "vlan", "протокол"], + "cybersecurity": ["cybersecurity", "безопасность", "owasp", "уязвимость", "pentest", "hack", "хак"], + "sql": ["sql"] +} -SYSTEM_PROMPT_ANALYSIS = ( - "You are an expert learning analytics tutor. Your task is to analyze a student's knowledge base " - "and provide insights about their learning progress.\n\n" - "When analyzing, consider:\n" - "1. What topics/subjects are covered in the notes\n" - "2. The depth and complexity of understanding demonstrated\n" - "3. Connections between different concepts\n" - "4. Gaps or missing fundamental concepts\n" - "5. Progression from beginner to advanced topics\n\n" - "Provide specific, actionable feedback about:\n" - "- What the student has learned well\n" - "- Areas that need more attention\n" - "- Recommended next topics to study\n" - "- How new topics connect to existing knowledge\n\n" - "Be encouraging but honest. Format your response clearly with sections." -) +# System Prompt for Educational Assistant +SYSTEM_PROMPT = """Ты — наставник-преподаватель по кибербезопасности. Твоя цель — довести ученика с уровня "пользователь ПК" до уровня junior в кибербезопасности. -SYSTEM_PROMPT_SUGGESTION = ( - "You are a learning path advisor. Based on a student's current knowledge (shown in their notes), " - "suggest the next logical topics or skills to learn.\n\n" - "Your suggestions should:\n" - "1. Build upon existing knowledge\n" - "2. Fill identified gaps in understanding\n" - "3. Progress naturally from basics to advanced\n" - "4. Be specific and actionable\n\n" - "Format your response with:\n" - "- Recommended topics (with brief explanations)\n" - "- Prerequisites needed\n" - "- Why each topic is important\n" - "- Estimated difficulty level\n" - "- How it connects to what they already know" -) +КУРСОВАЯ СТРУКТУРА +Модули (6 независимых курсов): +1. Computer Science (фундамент) +2. Математика +3. Основы электротехники +4. Linux + Kali Linux +5. Основы сетей +6. Введение в кибербезопасность +7. Английский язык -USER_PROMPT_TEMPLATE = os.getenv("USER_PROMPT_TEMPLATE", - "Previous Conversation:\n{history}\n\nContext from Docs:\n{context}\n\nCurrent Question: {question}") +СТРУКТУРА КАЖДОГО МОДУЛЯ +• Цель урока +• Темы в хронологическом порядке (от простого к сложному) +• Практические задания +• Прогресс-бар (по нормам Минобрнауки РФ) +• Блок вопросов для самопроверки +• Названия тем для поиска в YouTube/статьях + +ОТСЛЕЖИВАНИЕ ПРОГРЕССА +Методология: +• Каждый предмет = числовая прямая от 0 до ∞ +• Темы = точки на прямой (например: "цифры" = 0.01, "дроби" = 0.04) +• Без усвоения базы — не переходить дальше +• Адаптация вектора обучения по прогрессу + +Критерии Junior-уровня: +• CS: Алгоритмы, структуры данных, ООП +• Математика: Дискретная математика, логика, теория чисел +• Электротехника: Цифровая логика, микроконтроллеры +• Linux: CLI, bash-скрипты, системное администрирование +• Сети: OSI, TCP/IP, маршрутизация, VLAN +• Кибербезопасность: OWASP Top 10, базовые уязвимости, инструменты +• Английский: Технический английский, терминология + +РАБОЧИЙ ПРОЦЕСС +Ответ пользователю: +1. Определи стартовую точку по заметкам Obsidian +2. Построй фундамент текущего урока +3. Сверяйся с заметками ученика +4. Комбинируй стиль живого наставника и учебника + +Формат ответа: +"В [ПРЕДМЕТ] будем проходить [ТЕМА_1] и [ТЕМА_2]. +[Дополнительные инструкции по структуре изучения]" + +ПРАВИЛА ПРОГРЕССИИ +• Проверяй усвоение предыдущих тем +• Не суди по одному слову вне контекста +• Учитывай межпредметные связи +• Корректируй траекторию обучения динамически + +ПОИСКОВЫЕ ЗАПРОСЫ +Формируй темы для поиска в формате: +"[ПРЕДМЕТ] [УРОВЕНЬ] [ТЕМА] [ЯЗЫК]" Пример: "Computer Science beginner algorithms Russian" +""" + +USER_PROMPT_TEMPLATE = """Текущий прогресс обучения: +{progress} + +Контекст из заметок: +{context} + +Вопрос ученика: {question}""" # Paths and Models MD_DIRECTORY = os.getenv("MD_FOLDER", "./notes") @@ -98,23 +129,68 @@ 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" -HASH_CACHE = "./.cache/file_hashes.json" -PROGRESS_CACHE = "./.cache/learning_progress.json" +KNOWLEDGE_STATE_PATH = "./.cache/knowledge_state.json" +FILE_HASHES_PATH = "./.cache/file_hashes.json" # Processing Configuration -MAX_EMBED_CHARS = 380 -CHUNK_SIZE = 1200 -CHUNK_OVERLAP = 200 +CHUNK_SIZE = 400 +CHUNK_OVERLAP = 50 TOP_K = 6 -COLLECTION_NAME = "md_rag" +COLLECTION_NAME = "learning_rag" +MAX_CONTEXT_CHARS = 8000 -MAX_ANALYSIS_CONTEXT_CHARS = 24000 -BATCH_SIZE = 10 -MAX_PARALLEL_FILES = 3 +# ========================= +# DATA STRUCTURES +# ========================= +@dataclass +class SubjectProgress: + """Track progress for a specific subject""" + name: str + topics_covered: Set[str] + last_studied: Optional[str] + confidence_level: float = 0.0 # 0.0 to 1.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) + ) -# Learning Configuration -MAX_SUGGESTIONS = 5 -PROGRESS_SUMMARY_DAYS = 7 +@dataclass +class KnowledgeState: + """Complete learning state across all subjects""" + 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 @@ -134,629 +210,353 @@ def load_json_cache(file_path: str) -> dict: return {} return {} -def save_json_cache(cache: dict, file_path: str): +def save_json_cache(data, file_path: str): """Save JSON cache with error handling""" try: - Path(file_path).write_text(json.dumps(cache, indent=2)) + 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]") -def load_hash_cache() -> dict: - """Load file hash cache""" - return load_json_cache(HASH_CACHE) +# ========================= +# SUBJECT DETECTION +# ========================= +def detect_subject_from_query(query: str) -> Optional[str]: + """Detect which subject the user wants to study""" + query_lower = query.lower() + + # Check for explicit subject mentions + for subject, keywords in SUBJECT_KEYWORDS.items(): + for keyword in keywords: + if keyword.lower() in query_lower: + return subject + + return None -def save_hash_cache(cache: dict): - """Save file hash cache""" - save_json_cache(cache, HASH_CACHE) - -def load_progress_cache() -> dict: - """Load learning progress cache""" - return load_json_cache(PROGRESS_CACHE) - -def save_progress_cache(cache: dict): - """Save learning progress cache""" - save_json_cache(cache, PROGRESS_CACHE) - -def format_file_size(size_bytes: int) -> str: - """Format file size for human reading""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024 * 1024):.1f} MB" +def detect_subject_from_content(text: str) -> Optional[str]: + """Detect subject from note content""" + 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 + + # Return subject with highest score, if any matches + best_subject = max(subject_scores.items(), key=lambda x: x[1]) + return best_subject[0] if best_subject[1] > 0 else None # ========================= -# INTENT CLASSIFICATION +# KNOWLEDGE ANALYSIS # ========================= -def classify_intent(query: str) -> str: - """ - Classify user intent into different modes: - - SEARCH: Standard RAG retrieval - - ANALYSIS: Progress and knowledge analysis - - SUGGEST: Topic and learning suggestions - - LEARN: Interactive learning mode - - STATS: Progress statistics - """ - query_lower = query.lower().strip() - - # Analysis keywords (progress evaluation) - analysis_keywords = [ - r"assess my progress", r"eval(uate)? my (learning|knowledge)", - r"what have i learned", r"summary of (my )?notes", - r"my progress", r"learning path", r"knowledge gap", r"analyze my", - r"оцени (мой )?прогресс", r"что я выучил", r"итоги", r"анализ знаний", - r"сегодня(?:\s+\w+)*\s*урок", r"что я изучил" - ] - - # Suggestion keywords - suggestion_keywords = [ - r"what should i learn next", r"suggest (new )?topics", r"recommend (to )?learn", - r"next (topics|lessons)", r"learning suggestions", r"what to learn", - r"что учить дальше", r"предложи темы", r"рекомендации по обучению" - ] - - # Stats keywords - stats_keywords = [ - r"show stats", r"learning statistics", r"progress stats", r"knowledge stats", - r"статистика обучения", r"прогресс статистика" - ] - - # Learning mode keywords - learn_keywords = [ - r"start learning", r"learning mode", r"learn new", r"study plan", - r"начать обучение", r"режим обучения" - ] - - # Check patterns - for pattern in analysis_keywords: - if re.search(pattern, query_lower): - return "ANALYSIS" - - for pattern in suggestion_keywords: - if re.search(pattern, query_lower): - return "SUGGEST" - - for pattern in stats_keywords: - if re.search(pattern, query_lower): - return "STATS" - - for pattern in learn_keywords: - if re.search(pattern, query_lower): - return "LEARN" - - return "SEARCH" - -# ========================= -# DOCUMENT PROCESSING -# ========================= -def validate_chunk_size(text: str, max_chars: int = MAX_EMBED_CHARS) -> List[str]: - """Split oversized chunks into smaller pieces""" - if len(text) <= max_chars: - return [text] - - sentences = text.replace('. ', '.|').replace('! ', '!|').replace('? ', '?|').split('|') - chunks = [] - current = "" - - for sentence in sentences: - if len(current) + len(sentence) <= max_chars: - current += sentence - else: - if current: chunks.append(current.strip()) - if len(sentence) > max_chars: - words = sentence.split() - temp = "" - for word in words: - if len(temp) + len(word) + 1 <= max_chars: - temp += word + " " - else: - if temp: chunks.append(temp.strip()) - temp = word + " " - if temp: chunks.append(temp.strip()) - current = "" - else: - current = sentence - - if current: chunks.append(current.strip()) - return [c for c in chunks if c] - -def parse_markdown_with_frontmatter(file_path: str) -> tuple[dict, str]: - """Parse markdown file and extract YAML frontmatter + content""" - content = Path(file_path).read_text(encoding='utf-8') - - # YAML frontmatter pattern - frontmatter_pattern = r'^---\s*\n(.*?)\n---\s*\n(.*)$' - match = re.match(frontmatter_pattern, content, re.DOTALL) - - if match: - try: - metadata = yaml.safe_load(match.group(1)) - metadata = metadata if isinstance(metadata, dict) else {} - return metadata, match.group(2) - except yaml.YAMLError as e: - console.print(f"[yellow]⚠️ YAML error in {Path(file_path).name}: {e}[/yellow]") - return {}, content - - return {}, content - -class ChunkProcessor: - """Handles document chunking and embedding""" - def __init__(self, vectorstore): - self.vectorstore = vectorstore - self.semaphore = asyncio.Semaphore(MAX_PARALLEL_FILES) - - async def process_file(self, file_path: str) -> List[Dict]: - """Process a single markdown file into chunks""" - try: - metadata, content = parse_markdown_with_frontmatter(file_path) - metadata["source"] = file_path - - if metadata.get('exclude'): - console.print(f"[dim]📋 Found excluded file: {Path(file_path).name}[/dim]") - - docs = [Document(page_content=content, metadata=metadata)] - - except Exception as e: - console.print(f"✗ {Path(file_path).name}: {e}", style="red") - return [] - - splitter = RecursiveCharacterTextSplitter( - chunk_size=CHUNK_SIZE, - chunk_overlap=CHUNK_OVERLAP, - separators=["\n\n", "\n", ". ", " "] - ) - - chunks = [] - for doc_idx, doc in enumerate(docs): - doc_metadata = doc.metadata - - for chunk_idx, text in enumerate(splitter.split_text(doc.page_content)): - safe_texts = validate_chunk_size(text) - for sub_idx, safe_text in enumerate(safe_texts): - chunks.append({ - "id": f"{file_path}::{doc_idx}::{chunk_idx}::{sub_idx}", - "text": safe_text, - "metadata": doc_metadata - }) - return chunks - - async def embed_batch(self, batch: List[Dict]) -> bool: - """Embed a batch of chunks""" - if not batch: - return True - - try: - docs = [Document(page_content=c["text"], metadata=c["metadata"]) for c in batch] - ids = [c["id"] for c in batch] - - docs = filter_complex_metadata(docs) - await asyncio.to_thread(self.vectorstore.add_documents, docs, ids=ids) - return True - - except Exception as e: - console.print(f"✗ Embed error: {e}", style="red") - return False - - async def index_file(self, file_path: str, cache: dict) -> bool: - """Index a single file with change detection""" - async with self.semaphore: - current_hash = get_file_hash(file_path) - if cache.get(file_path) == current_hash: - return False - - chunks = await self.process_file(file_path) - if not chunks: return False - - # Remove old chunks for this file - try: - self.vectorstore._collection.delete(where={"source": {"$eq": file_path}}) - except: - pass - - # Embed new chunks in batches - for i in range(0, len(chunks), BATCH_SIZE): - batch = chunks[i:i + BATCH_SIZE] - await self.embed_batch(batch) - - cache[file_path] = current_hash - console.print(f"✓ {Path(file_path).name} ({len(chunks)} chunks)", style="green") - return True - -# ========================= -# FILE WATCHER -# ========================= -class DocumentWatcher(FileSystemEventHandler): - """Watch for file changes and reindex automatically""" - def __init__(self, processor, cache): - self.processor = processor - self.cache = cache - self.queue = deque() - self.processing = False - - def on_modified(self, event): - if not event.is_directory and event.src_path.endswith(".md"): - self.queue.append(event.src_path) - - async def process_queue(self): - while True: - if self.queue and not self.processing: - self.processing = True - file_path = self.queue.popleft() - if Path(file_path).exists(): - await self.processor.index_file(file_path, self.cache) - save_hash_cache(self.cache) - self.processing = False - await asyncio.sleep(1) - -def start_watcher(processor, cache): - """Start file system watcher""" - handler = DocumentWatcher(processor, cache) - observer = Observer() - observer.schedule(handler, MD_DIRECTORY, recursive=True) - observer.start() - asyncio.create_task(handler.process_queue()) - return observer - -# ========================= -# CONVERSATION MEMORY -# ========================= -class ConversationMemory: - """Manage conversation history""" - def __init__(self, max_messages: int = 8): - self.messages = [] - self.max_messages = max_messages - - def add(self, role: str, content: str): - self.messages.append({"role": role, "content": content}) - if len(self.messages) > self.max_messages: - self.messages.pop(0) - - def get_history(self) -> str: - if not self.messages: return "No previous conversation." - return "\n".join([f"{m['role'].upper()}: {m['content']}" for m in self.messages]) - -# ========================= -# LEARNING ANALYTICS -# ========================= -class LearningAnalytics: - """Analyze learning progress and provide insights""" +class KnowledgeAnalyzer: + """Analyze learning progress from notes""" def __init__(self, vectorstore): self.vectorstore = vectorstore + + async def analyze_all_notes(self, file_hashes: Dict[str, str]) -> KnowledgeState: + """Analyze all notes to build complete knowledge state""" + console.print("[cyan]🔍 Analyzing all notes for learning progress...[/cyan]") - async def get_knowledge_summary(self) -> dict: - """Get comprehensive knowledge base summary""" + # Initialize subjects + subjects = { + name: SubjectProgress(name=name, topics_covered=set(), last_studied=None) + for name in SUBJECT_KEYWORDS.keys() + } + + # Get all documents from vectorstore try: db_data = await asyncio.to_thread(self.vectorstore.get) if not db_data or not db_data['documents']: - return {"total_docs": 0, "total_chunks": 0, "subjects": {}} + console.print("[yellow]⚠️ No documents found in vectorstore[/yellow]") + return KnowledgeState(subjects, datetime.now().isoformat(), file_hashes) - # Filter excluded documents - filtered_pairs = [ - (text, meta) for text, meta in zip(db_data['documents'], db_data['metadatas']) - if meta and not meta.get('exclude', False) - ] - - # Extract subjects/topics from file names and content - subjects = defaultdict(lambda: {"chunks": 0, "files": set(), "last_updated": None}) - - for text, meta in filtered_pairs: - source = meta.get('source', 'unknown') - filename = Path(source).stem + # Process each document + for text, metadata in zip(db_data['documents'], db_data['metadatas']): + if not metadata or 'source' not in metadata: + continue - # Simple subject extraction from filename - subject = filename.split()[0] if filename else 'Unknown' - - subjects[subject]["chunks"] += 1 - subjects[subject]["files"].add(source) - - # Track last update (simplified) - if not subjects[subject]["last_updated"]: - subjects[subject]["last_updated"] = datetime.now().isoformat() + # Detect subject + subject = detect_subject_from_content(text) + if subject: + subjects[subject].topics_covered.add(text[:100]) # Use first 100 chars as topic identifier + + # Update last studied timestamp + file_path = metadata['source'] + if file_path in file_hashes: + subjects[subject].last_studied = file_hashes[file_path] - # Convert sets to counts - for subject in subjects: - subjects[subject]["files"] = len(subjects[subject]["files"]) + # Calculate confidence levels based on topic coverage + for subject in subjects.values(): + subject.confidence_level = min(len(subject.topics_covered) / 10.0, 1.0) - return { - "total_docs": len(filtered_pairs), - "total_chunks": len(filtered_pairs), - "subjects": dict(subjects) - } + console.print(f"[green]✓ Analysis complete. Found progress in {len([s for s in subjects.values() if s.topics_covered])} subjects[/green]") except Exception as e: - console.print(f"[red]✗ Error getting knowledge summary: {e}[/red]") - return {"total_docs": 0, "total_chunks": 0, "subjects": {}} + console.print(f"[red]✗ Error during analysis: {e}[/red]") + + return KnowledgeState(subjects, datetime.now().isoformat(), file_hashes) - async def get_learning_stats(self) -> dict: - """Get detailed learning statistics""" - summary = await self.get_knowledge_summary() + def get_progress_summary(self, knowledge_state: KnowledgeState, subject: Optional[str] = None) -> str: + """Generate human-readable progress summary""" + if subject and subject in knowledge_state.subjects: + subj = knowledge_state.subjects[subject] + return f"Предмет: {subj.name}\n" \ + f"Тем изучено: {len(subj.topics_covered)}\n" \ + f"Уровень уверенности: {subj.confidence_level:.1%}" - # Load progress history - progress_cache = load_progress_cache() + # Return all subjects summary + summary = "Текущий прогресс обучения:\n" + for subj in knowledge_state.subjects.values(): + if subj.topics_covered: + summary += f"- {subj.name}: {len(subj.topics_covered)} тем, уверенность {subj.confidence_level:.1%}\n" - stats = { - "total_topics": len(summary["subjects"]), - "total_notes": summary["total_docs"], - "total_files": sum(s["files"] for s in summary["subjects"].values()), - "topics": list(summary["subjects"].keys()), - "progress_history": progress_cache.get("sessions", []), - "study_streak": self._calculate_streak(progress_cache.get("sessions", [])), - "most_productive_topic": self._get_most_productive_topic(summary["subjects"]) - } - - return stats - - def _calculate_streak(self, sessions: list) -> int: - """Calculate consecutive days of studying""" - if not sessions: - return 0 - - # Simplified streak calculation - dates = [datetime.fromisoformat(s.get("date", datetime.now().isoformat())).date() - for s in sessions[-10:]] # Last 10 sessions - - streak = 0 - current_date = datetime.now().date() - - for date in reversed(dates): - if (current_date - date).days <= 1: - streak += 1 - current_date = date - else: - break - - return streak - - def _get_most_productive_topic(self, subjects: dict) -> str: - """Identify the most studied topic""" - if not subjects: - return "None" - - return max(subjects.items(), key=lambda x: x[1]["chunks"])[0] + return summary # ========================= -# CHAIN FACTORY +# DOCUMENT PROCESSING # ========================= -def get_chain(system_prompt): - """Create a LangChain processing chain""" - llm = ChatOllama( - model=LLM_MODEL, - temperature=0.2, - base_url=OLLAMA_BASE_URL - ) - prompt = ChatPromptTemplate.from_messages([ - ("system", system_prompt), - ("human", USER_PROMPT_TEMPLATE) - ]) - return prompt | llm | StrOutputParser() - -# ========================= -# INTERACTIVE COMMANDS -# ========================= -class InteractiveCommands: - """Handle interactive learning commands""" +class DocumentProcessor: + """Process markdown documents for the learning system""" - def __init__(self, vectorstore, analytics): + def __init__(self, vectorstore): self.vectorstore = vectorstore - self.analytics = analytics + self.text_splitter = RecursiveCharacterTextSplitter( + chunk_size=CHUNK_SIZE, + chunk_overlap=CHUNK_OVERLAP, + separators=["\n\n", "\n", ". ", " "] + ) - async def list_excluded_files(self): - """List all files marked with exclude: true""" - console.print("\n[bold yellow]📋 Fetching list of excluded files...[/bold yellow]") + async def process_file(self, file_path: str) -> List[Document]: + """Process a single markdown file""" + try: + loader = UnstructuredMarkdownLoader(file_path) + documents = loader.load() + + if not documents: + return [] + + # Add source metadata + for doc in documents: + doc.metadata["source"] = file_path + + # Split into chunks + chunks = self.text_splitter.split_documents(documents) + return chunks + + except Exception as e: + console.print(f"[red]✗ Error processing {Path(file_path).name}: {e}[/red]") + return [] + + async def index_files(self, file_paths: List[str]) -> bool: + """Index multiple files with batching""" + all_chunks = [] + + for file_path in file_paths: + chunks = await self.process_file(file_path) + all_chunks.extend(chunks) + + if not all_chunks: + return False + + batch_size = 20 + total_batches = (len(all_chunks) + batch_size - 1) // batch_size try: - excluded_data = await asyncio.to_thread( - self.vectorstore.get, - where={"exclude": True} - ) + await asyncio.to_thread(self.vectorstore.reset_collection) - if not excluded_data or not excluded_data['metadatas']: - console.print("[green]✓ No files are marked for exclusion.[/green]") - return + 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) + console.print(f" [dim]Пакет {i//batch_size + 1}/{total_batches} проиндексирован[/dim]") - excluded_files = set() - for meta in excluded_data['metadatas']: - if meta and 'source' in meta: - excluded_files.add(Path(meta['source']).name) - - console.print(f"\n[bold red]❌ Excluded Files ({len(excluded_files)}):[/bold red]") - console.print("=" * 50, style="dim") - - for filename in sorted(excluded_files): - console.print(f" • {filename}", style="red") - - console.print("=" * 50, style="dim") - console.print(f"[dim]Total chunks excluded: {len(excluded_data['metadatas'])}[/dim]\n") + return True except Exception as e: - console.print(f"[red]✗ Error fetching excluded files: {e}[/red]") + console.print(f"[red]✗ Error indexing documents: {e}[/red]") + return False + +# ========================= +# LEARNING ASSISTANT +# ========================= +class LearningAssistant: + """Main learning assistant class""" - async def show_learning_stats(self): - """Display comprehensive learning statistics""" - console.print("\n[bold cyan]📊 Learning Statistics[/bold cyan]") - console.print("=" * 60, style="dim") + def __init__(self): + self.embeddings = OllamaEmbeddings( + model=EMBEDDING_MODEL, + base_url=OLLAMA_BASE_URL + ) - stats = await self.analytics.get_learning_stats() + self.vectorstore = Chroma( + collection_name=COLLECTION_NAME, + persist_directory=CHROMA_PATH, + embedding_function=self.embeddings + ) - # Display stats in a table - table = Table(title="Knowledge Overview", show_header=False) - table.add_column("Metric", style="cyan") - table.add_column("Value", style="yellow") + self.llm = ChatOllama( + model=LLM_MODEL, + temperature=0.2, + base_url=OLLAMA_BASE_URL + ) - table.add_row("Total Topics Studied", str(stats["total_topics"])) - table.add_row("Total Notes Created", str(stats["total_notes"])) - table.add_row("Total Files", str(stats["total_files"])) - table.add_row("Study Streak (days)", str(stats["study_streak"])) - table.add_row("Most Productive Topic", stats["most_productive_topic"]) + self.prompt = ChatPromptTemplate.from_messages([ + ("system", SYSTEM_PROMPT), + ("human", USER_PROMPT_TEMPLATE) + ]) - console.print(table) - - # Show topics - if stats["topics"]: - console.print(f"\n[bold green]📚 Topics Studied:[/bold green]") - for topic in sorted(stats["topics"]): - console.print(f" ✓ {topic}") - - console.print() + self.chain = self.prompt | self.llm | StrOutputParser() + self.processor = DocumentProcessor(self.vectorstore) + self.analyzer = KnowledgeAnalyzer(self.vectorstore) - async def interactive_learning_mode(self): - """Start interactive learning mode""" - console.print("\n[bold magenta]🎓 Interactive Learning Mode[/bold magenta]") - console.print("I'll analyze your current knowledge and suggest what to learn next!\n") + async def initialize(self): + """Initialize the learning system""" + console.print(Panel.fit( + "[bold cyan]🎓 RAG Learning System - Educational Assistant[/bold cyan]\n" + "📂 Notes Directory: {}\n" + "🧠 Model: {}\n" + "[dim]Analyzing your learning progress...[/dim]".format( + MD_DIRECTORY, LLM_MODEL + ), + border_style="cyan" + )) - # First, analyze current knowledge - console.print("[cyan]Analyzing your current knowledge base...[/cyan]") + # Load or create knowledge state + knowledge_state = await self.load_or_analyze_knowledge() - # Get analysis - db_data = await asyncio.to_thread(self.vectorstore.get) - all_texts = db_data['documents'] - all_metadatas = db_data['metadatas'] + console.print("[green]✓ System initialized successfully![/green]") + console.print("[dim]💡 Tip: /help[/dim]\n") - # Filter excluded - filtered_pairs = [ - (text, meta) for text, meta in zip(all_texts, all_metadatas) - if meta and not meta.get('exclude', False) - ] - - if not filtered_pairs: - console.print("[yellow]⚠️ No learning materials found. Add some notes first![/yellow]") - return - - # Build context for analysis - full_context = "" - for text, meta in filtered_pairs[:20]: # Limit context - full_context += f"\n---\nSource: {Path(meta['source']).name}\n{text}\n" - - # Get AI analysis - chain = get_chain(SYSTEM_PROMPT_ANALYSIS) - - console.print("[cyan]Getting AI analysis of your progress...[/cyan]") - analysis_response = "" - async for chunk in chain.astream({ - "context": full_context, - "question": "Analyze my learning progress and identify what I've learned well and what gaps exist.", - "history": "" - }): - analysis_response += chunk - - console.print(f"\n[bold green]📈 Your Learning Analysis:[/bold green]") - console.print(analysis_response) - - # Get suggestions - console.print("\n[cyan]Generating personalized learning suggestions...[/cyan]") - - suggestion_chain = get_chain(SYSTEM_PROMPT_SUGGESTION) - suggestion_response = "" - async for chunk in suggestion_chain.astream({ - "context": full_context, - "question": "Based on this student's current knowledge, what should they learn next?", - "history": "" - }): - suggestion_response += chunk - - console.print(f"\n[bold blue]💡 Recommended Next Topics:[/bold blue]") - console.print(suggestion_response) - - # Save progress - progress_cache = load_progress_cache() - if "sessions" not in progress_cache: - progress_cache["sessions"] = [] - - progress_cache["sessions"].append({ - "date": datetime.now().isoformat(), - "type": "analysis", - "topics_count": len(filtered_pairs) - }) - - save_progress_cache(progress_cache) - - console.print(f"\n[green]✓ Analysis complete! Add notes about the suggested topics and run 'learning mode' again.[/green]") + return knowledge_state - async def suggest_topics(self): - """Suggest new topics to learn""" - console.print("\n[bold blue]💡 Topic Suggestions[/bold blue]") + async def load_or_analyze_knowledge(self) -> KnowledgeState: + """Load existing knowledge state or analyze all notes""" + # Load file hashes + file_hashes = self.get_file_hashes() - # Get current knowledge - db_data = await asyncio.to_thread(self.vectorstore.get) - all_texts = db_data['documents'] - all_metadatas = db_data['metadatas'] + # Load knowledge state + state_data = load_json_cache(KNOWLEDGE_STATE_PATH) - filtered_pairs = [ - (text, meta) for text, meta in zip(all_texts, all_metadatas) - if meta and not meta.get('exclude', False) - ][:15] # Limit context + if state_data: + knowledge_state = KnowledgeState.from_dict(state_data) + + # Check if files have changed + if self.have_files_changed(file_hashes, knowledge_state.file_hashes): + console.print("[yellow]📁 Files changed, re-analyzing knowledge...[/yellow]") + knowledge_state = await self.analyzer.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.analyzer.analyze_all_notes(file_hashes) + save_json_cache(knowledge_state.to_dict(), KNOWLEDGE_STATE_PATH) - if not filtered_pairs: - console.print("[yellow]⚠️ No notes found. Start by creating some learning materials![/yellow]") - return + return knowledge_state + + def get_file_hashes(self) -> Dict[str, str]: + """Get hashes for all markdown files""" + file_hashes = {} - # Build context - context = "" - for text, meta in filtered_pairs: - context += f"\n---\nSource: {Path(meta['source']).name}\n{text}\n" + 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]") - # Get suggestions from AI - chain = get_chain(SYSTEM_PROMPT_SUGGESTION) + return file_hashes + + def have_files_changed(self, current_hashes: Dict[str, str], cached_hashes: Dict[str, str]) -> bool: + """Check if any files have changed""" + if len(current_hashes) != len(cached_hashes): + return True - console.print("[cyan]Analyzing your knowledge and generating suggestions...[/cyan]\n") + for file_path, current_hash in current_hashes.items(): + if file_path not in cached_hashes or cached_hashes[file_path] != current_hash: + return True + + return False + + async def get_relevant_context(self, subject: str, knowledge_state: KnowledgeState) -> str: + """Get context relevant to the specified subject""" + try: + # Get all documents and filter by subject + 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 + }) + + # Build context string + context = f"Найдено {len(relevant_docs)} заметок по предмету:\n" + + char_count = len(context) + for doc in relevant_docs[:TOP_K]: # Limit to top K documents + 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"Заметок по предмету '{subject}' не найдено." + + return context + + except Exception as e: + console.print(f"[red]✗ Error getting context: {e}[/red]") + return "Ошибка при получении контекста." + + async def process_learning_query(self, query: str, knowledge_state: KnowledgeState) -> str: + """Process a learning query""" + # Detect subject from query + subject = detect_subject_from_query(query) + + if not subject: + # Try to infer from broader context or ask for clarification + return "Пожалуйста, уточните предмет для изучения (например: 'изучаем английский', 'учим математику')." + + # Get relevant context + context = await self.get_relevant_context(subject, knowledge_state) + + # Get progress summary + progress = self.analyzer.get_progress_summary(knowledge_state, subject) + + # Generate response + console.print(f"[blue]🔍 Анализирую прогресс по предмету: {subject}[/blue]") + console.print(f"[dim]Контекст: {len(context)} символов[/dim]\n") response = "" - async for chunk in chain.astream({ + console.print("[bold blue]Ассистент:[/bold blue] ", end="") + + async for chunk in self.chain.astream({ "context": context, - "question": "What are the next logical topics for this student to learn?", - "history": "" + "question": query, + "progress": progress }): + console.print(chunk, end="", style=ANSWER_COLOR) response += chunk - console.print(chunk, end="") console.print("\n") - - async def exclude_file_interactive(self): - """Interactively exclude a file from learning analysis""" - console.print("\n[bold yellow]📁 Exclude File from Analysis[/bold yellow]") - - # List all non-excluded files - db_data = await asyncio.to_thread(self.vectorstore.get) - files = set() - - for meta in db_data['metadatas']: - if meta and 'source' in meta and not meta.get('exclude', False): - files.add(meta['source']) - - if not files: - console.print("[yellow]⚠️ No files found to exclude.[/yellow]") - return - - # Show files - file_list = sorted(list(files)) - console.print("\n[bold]Available files:[/bold]") - for i, file_path in enumerate(file_list, 1): - console.print(f" {i}. {Path(file_path).name}") - - # Get user choice - choice = Prompt.ask("\nSelect file number to exclude", - choices=[str(i) for i in range(1, len(file_list) + 1)], - default="1") - - selected_file = file_list[int(choice) - 1] - - # Confirmation - if Confirm.ask(f"\nExclude '{Path(selected_file).name}' from learning analysis?"): - # Update the file's metadata in vectorstore - try: - # Note: In a real implementation, you'd need to update the file's frontmatter - # For now, we'll show instructions - console.print(f"\n[red]⚠️ Manual action required:[/red]") - console.print(f"Add 'exclude: true' to the frontmatter of:") - console.print(f" {selected_file}") - console.print(f"\n[dim]Example:[/dim]") - console.print("```\n---\nexclude: true\n---\n```") - console.print(f"\n[green]The file will be excluded on next reindex.[/green]") - except Exception as e: - console.print(f"[red]✗ Error: {e}[/red]") + return response # ========================= # MAIN APPLICATION @@ -766,73 +566,14 @@ async def main(): # Setup directories Path(MD_DIRECTORY).mkdir(parents=True, exist_ok=True) - Path(CHROMA_PATH).parent.mkdir(parents=True, exist_ok=True) - - # Display welcome banner - console.print(Panel.fit( - f"[bold cyan]⚡ RAG Learning System[/bold cyan]\n" - f"📂 Notes Directory: {MD_DIRECTORY}\n" - f"🧠 Embedding Model: {EMBEDDING_MODEL}\n" - f"🤖 LLM Model: {LLM_MODEL}\n" - f"[dim]Commands: /help for available commands[/dim]", - border_style="cyan" - )) - - # Initialize components - embeddings = OllamaEmbeddings( - model=EMBEDDING_MODEL, - base_url=OLLAMA_BASE_URL - ) - vectorstore = Chroma( - collection_name=COLLECTION_NAME, - persist_directory=CHROMA_PATH, - embedding_function=embeddings - ) - - processor = ChunkProcessor(vectorstore) - analytics = LearningAnalytics(vectorstore) - commands = InteractiveCommands(vectorstore, analytics) + assistant = LearningAssistant() - cache = load_hash_cache() - - # Index existing documents - console.print(f"\n[bold yellow]📚 Indexing documents...[/bold yellow]") - - files = [ - os.path.join(root, file) - for root, _, files in os.walk(MD_DIRECTORY) - for file in files if file.endswith(".md") - ] - - semaphore = asyncio.Semaphore(MAX_PARALLEL_FILES) - async def sem_task(fp): - async with semaphore: - return await processor.index_file(fp, cache) - - # Use progress bar for indexing - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console - ) as progress: - task = progress.add_task("Indexing files...", total=len(files)) - - tasks = [sem_task(fp) for fp in files] - for fut in asyncio.as_completed(tasks): - await fut - progress.advance(task) - - save_hash_cache(cache) - - # Start file watcher - observer = start_watcher(processor, cache) - memory = ConversationMemory() - - # Show help hint - console.print(f"\n[dim]💡 Type /help to see available commands[/dim]\n") - try: + # Initialize system + knowledge_state = await assistant.initialize() + + # Main interaction loop while True: # Get user input query = await session.prompt_async("> ", style=style) @@ -841,184 +582,88 @@ async def main(): if not query: continue - # Handle commands - if query.startswith('/'): - command = query[1:].lower().strip() + # Handle exit commands + if query.lower() in ['/exit', '/quit', 'exit', 'quit', 'выход']: + console.print("\n👋 До свидания! Удачи в обучении!", style="yellow") + break + + # Handle help + if query.lower() in ['/help', 'help', 'помощь']: + await show_help() + continue + + # Handle reindex command + if query.lower() in ['/reindex', 'reindex']: + console.print("[yellow]🔄 Переиндексирую все файлы...[/yellow]") - if command in ['exit', 'quit', 'q']: - console.print("\n👋 Goodbye!", style="yellow") - break + files = [os.path.join(root, f) for root, _, files in os.walk(MD_DIRECTORY) + for f in files if f.endswith(".md")] - elif command in ['help', 'h']: - await show_help() + if not files: + console.print("[yellow]⚠️ Markdown файлы не найдены[/yellow]") + continue - elif command in ['stats', 'statistics']: - await commands.show_learning_stats() - - elif command in ['excluded', 'list-excluded']: - await commands.list_excluded_files() - - elif command in ['learning-mode', 'learn']: - await commands.interactive_learning_mode() - - elif command in ['suggest', 'suggestions']: - await commands.suggest_topics() - - elif command in ['exclude']: - await commands.exclude_file_interactive() - - elif command in ['reindex']: - console.print("\n[yellow]🔄 Reindexing all files...[/yellow]") - cache.clear() - for file_path in files: - await processor.index_file(file_path, cache) - save_hash_cache(cache) - console.print("[green]✓ Reindexing complete![/green]") + # Вызовите index_files напрямую — он сам напечатает прогресс + success = await assistant.processor.index_files(files) + if success: + console.print("[cyan]📊 Анализирую знания...[/cyan]") + knowledge_state = await assistant.analyzer.analyze_all_notes( + assistant.get_file_hashes() + ) + save_json_cache(knowledge_state.to_dict(), KNOWLEDGE_STATE_PATH) + console.print("[green]✓ Индексация завершена![/green]") else: - console.print(f"[red]✗ Unknown command: {command}[/red]") - console.print("[dim]Type /help to see available commands[/dim]") + console.print("[red]✗ Ошибка индексации[/red]") continue - # Process normal queries - console.print() - mode = classify_intent(query) - history_str = memory.get_history() - - if mode == "SEARCH": - console.print("🔍 SEARCH MODE (Top-K Retrieval)", style="bold blue") - - retriever = vectorstore.as_retriever(search_kwargs={"k": TOP_K}) - docs = await asyncio.to_thread(retriever.invoke, query) - context_str = "\n\n".join( - f"[{Path(d.metadata['source']).name}]\n{d.page_content}" - for d in docs - ) - - chain = get_chain(SYSTEM_PROMPT_SEARCH) - - elif mode == "ANALYSIS": - console.print("📊 ANALYSIS MODE (Full Context Evaluation)", style="bold magenta") - - db_data = await asyncio.to_thread(vectorstore.get) - all_texts = db_data['documents'] - all_metas = db_data['metadatas'] - - if not all_texts: - console.print("[red]No documents found to analyze![/red]") - continue - - # Filter excluded chunks - filtered_pairs = [ - (text, meta) for text, meta in zip(all_texts, all_metas) - if meta and not meta.get('exclude', False) - ] - - excluded_count = len(all_texts) - len(filtered_pairs) - if excluded_count > 0: - console.print(f"ℹ Excluded {excluded_count} chunks marked 'exclude: true'", style="dim") - - if not filtered_pairs: - console.print("[yellow]All documents are marked for exclusion. Nothing to analyze.[/yellow]") - continue - - # Build context - full_context = "" - char_count = 0 - - for text, meta in filtered_pairs[:25]: # Limit for analysis - entry = f"\n---\nSource: {Path(meta['source']).name}\n{text}\n" - if char_count + len(entry) > MAX_ANALYSIS_CONTEXT_CHARS: - full_context += "\n[...Truncated due to context limit...]" - console.print("⚠ Context limit reached, truncating analysis data.", style="yellow") - break - full_context += entry - char_count += len(entry) - - context_str = full_context - chain = get_chain(SYSTEM_PROMPT_ANALYSIS) - - elif mode == "SUGGEST": - await commands.suggest_topics() - continue - - elif mode == "STATS": - await commands.show_learning_stats() - continue - - elif mode == "LEARN": - await commands.interactive_learning_mode() - continue - - # Generate and display response - response = "" - console.print(f"Context size: {len(context_str)} chars", style="dim") - console.print("Assistant:", style="blue", end=" ") - - async for chunk in chain.astream({ - "context": context_str, - "question": query, - "history": history_str - }): - console.print(chunk, end="", style=ANSWER_COLOR) - response += chunk - console.print("\n") - - # Update conversation memory - memory.add("user", query) - memory.add("assistant", response) - - finally: - # Cleanup - observer.stop() - observer.join() + # Process learning query + 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]") + console.print_exception() async def show_help(): """Display help information""" - console.print("\n[bold cyan]📖 Available Commands:[/bold cyan]") - console.print("=" * 50, style="dim") + console.print("\n[bold cyan]🎓 RAG Learning System - Справка[/bold cyan]") + console.print("=" * 60, style="dim") - commands = [ - ("/help", "Show this help message"), - ("/stats", "Display learning statistics and progress"), - ("/learning-mode", "Start interactive learning analysis"), - ("/suggest", "Get topic suggestions for next study"), - ("/excluded", "List files excluded from analysis"), - ("/exclude", "Interactively exclude a file"), - ("/reindex", "Reindex all documents"), - ("/exit, /quit, /q", "Exit the application"), - ] + console.print("\n[bold green]Использование:[/bold green]") + console.print("Просто напишите, что хотите изучать:") + console.print(" • 'изучаем английский'") + console.print(" • 'учим математику'") + console.print(" • 'погнали по сетям'") + console.print(" • 'давай python'\n") - for cmd, desc in commands: - console.print(f"[yellow]{cmd:<20}[/yellow] {desc}") + console.print("[bold green]Доступные предметы:[/bold green]") + for subject, keywords in SUBJECT_KEYWORDS.items(): + console.print(f" • {subject}: {', '.join(keywords[:3])}...") - console.print("\n[bold cyan]🎯 Learning Modes:[/bold cyan]") - console.print("=" * 50, style="dim") - console.print("• [blue]Search Mode[/blue]: Ask questions about your notes") - console.print("• [magenta]Analysis Mode[/magenta]: Get progress evaluation") - console.print("• [green]Suggestion Mode[/green]: Get topic recommendations") + console.print("\n[bold green]Команды:[/bold green]") + console.print(" • /help или помощь - показать эту справку") + console.print(" • /reindex - переиндексировать все файлы") + console.print(" • exit, quit, выход - выйти из программы") - console.print("\n[bold cyan]💡 Examples:[/bold cyan]") - console.print("=" * 50, style="dim") - console.print("• \"What is SQL JOIN?\" → Search your notes") - console.print("• \"Assess my progress\" → Analyze learning") - console.print("• \"What should I learn next?\" → Get suggestions") - console.print("• \"Show my statistics\" → Display progress") - - console.print() + 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: - import asyncio - loop = asyncio.get_event_loop() - loop.run_until_complete(main()) + asyncio.run(main()) except KeyboardInterrupt: - console.print("\n👋 Goodbye!", style="yellow") + console.print("\n👋 До свидания! Удачи в обучении!", style="yellow") sys.exit(0) except Exception as e: - console.print(f"\n[red]✗ Unexpected error: {e}[/red]") + console.print(f"[red]✗ Unexpected error: {e}[/red]") sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index 7bc095d..89c8261 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,10 +6,13 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "chromadb>=1.4.0", + "langchain>=1.2.0", "langchain-chroma>=1.1.0", "langchain-community>=0.4.1", "langchain-ollama>=1.0.1", + "langchain-text-splitters>=1.1.0", "nest-asyncio>=1.6.0", + "nvidia-ml-py>=13.590.44", "prompt-toolkit>=3.0.52", "python-dotenv>=1.2.1", "pyyaml>=6.0.3", diff --git a/uv.lock b/uv.lock index b0b3841..19c0b2e 100644 --- a/uv.lock +++ b/uv.lock @@ -952,6 +952,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/ec/65f7d563aa4a62dd58777e8f6aa882f15db53b14eb29aba0c28a20f7eb26/kubernetes-34.1.0-py2.py3-none-any.whl", hash = "sha256:bffba2272534e224e6a7a74d582deb0b545b7c9879d2cd9e4aae9481d1f2cc2a", size = 2008380, upload-time = "2025-09-29T20:23:47.684Z" }, ] +[[package]] +name = "langchain" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/12/3a74c22abdfddd877dfc2ee666d516f9132877fcd25eb4dd694835c59c79/langchain-1.2.0.tar.gz", hash = "sha256:a087d1e2b2969819e29a91a6d5f98302aafe31bd49ba377ecee3bf5a5dcfe14a", size = 536126, upload-time = "2025-12-15T14:51:42.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/00/4e3fa0d90f5a5c376ccb8ca983d0f0f7287783dfac48702e18f01d24673b/langchain-1.2.0-py3-none-any.whl", hash = "sha256:82f0d17aa4fbb11560b30e1e7d4aeb75e3ad71ce09b85c90ab208b181a24ffac", size = 102828, upload-time = "2025-12-15T14:51:40.802Z" }, +] + [[package]] name = "langchain-chroma" version = "1.1.0" @@ -1060,6 +1074,62 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } +[[package]] +name = "langgraph" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/47/28f4d4d33d88f69de26f7a54065961ac0c662cec2479b36a2db081ef5cb6/langgraph-1.0.5.tar.gz", hash = "sha256:7f6ae59622386b60fe9fa0ad4c53f42016b668455ed604329e7dc7904adbf3f8", size = 493969, upload-time = "2025-12-12T23:05:48.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/1b/e318ee76e42d28f515d87356ac5bd7a7acc8bad3b8f54ee377bef62e1cbf/langgraph-1.0.5-py3-none-any.whl", hash = "sha256:b4cfd173dca3c389735b47228ad8b295e6f7b3df779aba3a1e0c23871f81281e", size = 157056, upload-time = "2025-12-12T23:05:46.499Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/07/2b1c042fa87d40cf2db5ca27dc4e8dd86f9a0436a10aa4361a8982718ae7/langgraph_checkpoint-3.0.1.tar.gz", hash = "sha256:59222f875f85186a22c494aedc65c4e985a3df27e696e5016ba0b98a5ed2cee0", size = 137785, upload-time = "2025-11-04T21:55:47.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/e3/616e3a7ff737d98c1bbb5700dd62278914e2a9ded09a79a1fa93cf24ce12/langgraph_checkpoint-3.0.1-py3-none-any.whl", hash = "sha256:9b04a8d0edc0474ce4eaf30c5d731cee38f11ddff50a6177eead95b5c4e4220b", size = 46249, upload-time = "2025-11-04T21:55:46.472Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/f9/54f8891b32159e4542236817aea2ee83de0de18bce28e9bdba08c7f93001/langgraph_prebuilt-1.0.5.tar.gz", hash = "sha256:85802675ad778cc7240fd02d47db1e0b59c0c86d8369447d77ce47623845db2d", size = 144453, upload-time = "2025-11-20T16:47:39.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/5e/aeba4a5b39fe6e874e0dd003a82da71c7153e671312671a8dacc5cb7c1af/langgraph_prebuilt-1.0.5-py3-none-any.whl", hash = "sha256:22369563e1848862ace53fbc11b027c28dd04a9ac39314633bb95f2a7e258496", size = 35072, upload-time = "2025-11-20T16:47:38.187Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d3/b6be0b0aba2a53a8920a2b0b4328a83121ec03eea9952e576d06a4182f6f/langgraph_sdk-0.3.1.tar.gz", hash = "sha256:f6dadfd2444eeff3e01405a9005c95fb3a028d4bd954ebec80ea6150084f92bb", size = 130312, upload-time = "2025-12-18T22:11:47.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/fe/0c1c9c01a154eba62b20b02fabe811fd94a2b810061ae9e4d8462b8cf85a/langgraph_sdk-0.3.1-py3-none-any.whl", hash = "sha256:0b856923bfd20bf3441ce9d03bef488aa333fb610e972618799a9d584436acad", size = 66517, upload-time = "2025-12-18T22:11:46.625Z" }, +] + [[package]] name = "langsmith" version = "0.5.1" @@ -1420,6 +1490,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/4f/1f8475907d1a7c4ef9020edf7f39ea2422ec896849245f00688e4b268a71/numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", size = 10661799, upload-time = "2025-12-20T16:18:01.078Z" }, ] +[[package]] +name = "nvidia-ml-py" +version = "13.590.44" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/23/3871537f204aee823c574ba25cbeb08cae779979d4d43c01adddda00bab9/nvidia_ml_py-13.590.44.tar.gz", hash = "sha256:b358c7614b0fdeea4b95f046f1c90123bfe25d148ab93bb1c00248b834703373", size = 49737, upload-time = "2025-12-08T14:41:10.872Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/47/4c822bd37a008e72fd5a0eae33524ae3ac97b13f7030f63bae1728b8957e/nvidia_ml_py-13.590.44-py3-none-any.whl", hash = "sha256:18feb54eca7d0e3cdc8d1a040a771eda72d9ec3148e5443087970dbfd7377ecc", size = 50683, upload-time = "2025-12-08T14:41:09.597Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -1593,6 +1672,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" }, ] +[[package]] +name = "ormsgpack" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/96/34c40d621996c2f377a18decbd3c59f031dde73c3ba47d1e1e8f29a05aaa/ormsgpack-1.12.1.tar.gz", hash = "sha256:a3877fde1e4f27a39f92681a0aab6385af3a41d0c25375d33590ae20410ea2ac", size = 39476, upload-time = "2025-12-14T07:57:43.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/42/f110dfe7cf23a52a82e23eb23d9a6a76ae495447d474686dfa758f3d71d6/ormsgpack-1.12.1-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9663d6b3ecc917c063d61a99169ce196a80f3852e541ae404206836749459279", size = 376746, upload-time = "2025-12-14T07:57:17.699Z" }, + { url = "https://files.pythonhosted.org/packages/11/76/b386e508a8ae207daec240201a81adb26467bf99b163560724e86bd9ff33/ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32e85cfbaf01a94a92520e7fe7851cfcfe21a5698299c28ab86194895f9b9233", size = 202489, upload-time = "2025-12-14T07:57:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0e/5db7a63f387149024572daa3d9512fe8fb14bf4efa0722d6d491bed280e7/ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dabfd2c24b59c7c69870a5ecee480dfae914a42a0c2e7c9d971cf531e2ba471a", size = 210757, upload-time = "2025-12-14T07:57:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/64/79/3a9899e57cb57430bd766fc1b4c9ad410cb2ba6070bc8cf6301e7d385768/ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51bbf2b64afeded34ccd8e25402e4bca038757913931fa0d693078d75563f6f9", size = 211518, upload-time = "2025-12-14T07:57:20.972Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/4f41710ae9fe50d7fcbe476793b3c487746d0e1cc194cc0fee42ff6d989b/ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9959a71dde1bd0ced84af17facc06a8afada495a34e9cb1bad8e9b20d4c59cef", size = 386251, upload-time = "2025-12-14T07:57:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/bf/54/ba0c97d6231b1f01daafaa520c8cce1e1b7fceaae6fdc1c763925874a7de/ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:e9be0e3b62d758f21f5b20e0e06b3a240ec546c4a327bf771f5825462aa74714", size = 479607, upload-time = "2025-12-14T07:57:23.525Z" }, + { url = "https://files.pythonhosted.org/packages/18/75/19a9a97a462776d525baf41cfb7072734528775f0a3d5fbfab3aa7756b9b/ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a29d49ab7fdd77ea787818e60cb4ef491708105b9c4c9b0f919201625eb036b5", size = 388062, upload-time = "2025-12-14T07:57:24.616Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/ec26e3f44e9632ecd2f43638b7b37b500eaea5d79cab984ad0b94be14f82/ormsgpack-1.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:c418390b47a1d367e803f6c187f77e4d67c7ae07ba962e3a4a019001f4b0291a", size = 116195, upload-time = "2025-12-14T07:57:25.626Z" }, + { url = "https://files.pythonhosted.org/packages/7d/64/bfa5f4a34d0f15c6aba1b73e73f7441a66d635bd03249d334a4796b7a924/ormsgpack-1.12.1-cp313-cp313-win_arm64.whl", hash = "sha256:cfa22c91cffc10a7fbd43729baff2de7d9c28cef2509085a704168ae31f02568", size = 109986, upload-time = "2025-12-14T07:57:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/87/0e/78e5697164e3223b9b216c13e99f1acbc1ee9833490d68842b13da8ba883/ormsgpack-1.12.1-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b93c91efb1a70751a1902a5b43b27bd8fd38e0ca0365cf2cde2716423c15c3a6", size = 376758, upload-time = "2025-12-14T07:57:27.641Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/3a3cbb64703263d7bbaed7effa3ce78cb9add360a60aa7c544d7df28b641/ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf0ea0389167b5fa8d2933dd3f33e887ec4ba68f89c25214d7eec4afd746d22", size = 202487, upload-time = "2025-12-14T07:57:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2c/807ebe2b77995599bbb1dec8c3f450d5d7dddee14ce3e1e71dc60e2e2a74/ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4c29af837f35af3375070689e781161e7cf019eb2f7cd641734ae45cd001c0d", size = 210853, upload-time = "2025-12-14T07:57:30.508Z" }, + { url = "https://files.pythonhosted.org/packages/25/57/2cdfc354e3ad8e847628f511f4d238799d90e9e090941e50b9d5ba955ae2/ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:336fc65aa0fe65896a3dabaae31e332a0a98b4a00ad7b0afde21a7505fd23ff3", size = 211545, upload-time = "2025-12-14T07:57:31.585Z" }, + { url = "https://files.pythonhosted.org/packages/76/1d/c6fda560e4a8ff865b3aec8a86f7c95ab53f4532193a6ae4ab9db35f85aa/ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:940f60aabfefe71dd6b82cb33f4ff10b2e7f5fcfa5f103cdb0a23b6aae4c713c", size = 386333, upload-time = "2025-12-14T07:57:32.957Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/715081b36fceb8b497c68b87d384e1cc6d9c9c130ce3b435634d3d785b86/ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:596ad9e1b6d4c95595c54aaf49b1392609ca68f562ce06f4f74a5bc4053bcda4", size = 479701, upload-time = "2025-12-14T07:57:34.686Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cf/01ad04def42b3970fc1a302c07f4b46339edf62ef9650247097260471f40/ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:575210e8fcbc7b0375026ba040a5eef223e9f66a4453d9623fc23282ae09c3c8", size = 388148, upload-time = "2025-12-14T07:57:35.771Z" }, + { url = "https://files.pythonhosted.org/packages/15/91/1fff2fc2b5943c740028f339154e7103c8f2edf1a881d9fbba2ce11c3b1d/ormsgpack-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:647daa3718572280893456be44c60aea6690b7f2edc54c55648ee66e8f06550f", size = 116201, upload-time = "2025-12-14T07:57:36.763Z" }, + { url = "https://files.pythonhosted.org/packages/ed/66/142b542aed3f96002c7d1c33507ca6e1e0d0a42b9253ab27ef7ed5793bd9/ormsgpack-1.12.1-cp314-cp314-win_arm64.whl", hash = "sha256:a8b3ab762a6deaf1b6490ab46dda0c51528cf8037e0246c40875c6fe9e37b699", size = 110029, upload-time = "2025-12-14T07:57:37.703Z" }, + { url = "https://files.pythonhosted.org/packages/38/b3/ef4494438c90359e1547eaed3c5ec46e2c431d59a3de2af4e70ebd594c49/ormsgpack-1.12.1-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:12087214e436c1f6c28491949571abea759a63111908c4f7266586d78144d7a8", size = 376777, upload-time = "2025-12-14T07:57:38.795Z" }, + { url = "https://files.pythonhosted.org/packages/05/a0/1149a7163f8b0dfbc64bf9099b6f16d102ad3b03bcc11afee198d751da2d/ormsgpack-1.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e6d54c14cf86ef13f10ccade94d1e7de146aa9b17d371e18b16e95f329393b7", size = 202490, upload-time = "2025-12-14T07:57:40.168Z" }, + { url = "https://files.pythonhosted.org/packages/68/82/f2ec5e758d6a7106645cca9bb7137d98bce5d363789fa94075be6572057c/ormsgpack-1.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f3584d07882b7ea2a1a589f795a3af97fe4c2932b739408e6d1d9d286cad862", size = 211733, upload-time = "2025-12-14T07:57:42.253Z" }, +] + [[package]] name = "overrides" version = "7.7.0" @@ -2094,10 +2202,13 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "chromadb" }, + { name = "langchain" }, { name = "langchain-chroma" }, { name = "langchain-community" }, { name = "langchain-ollama" }, + { name = "langchain-text-splitters" }, { name = "nest-asyncio" }, + { name = "nvidia-ml-py" }, { name = "prompt-toolkit" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -2109,10 +2220,13 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "chromadb", specifier = ">=1.4.0" }, + { name = "langchain", specifier = ">=1.2.0" }, { name = "langchain-chroma", specifier = ">=1.1.0" }, { name = "langchain-community", specifier = ">=0.4.1" }, { name = "langchain-ollama", specifier = ">=1.0.1" }, + { name = "langchain-text-splitters", specifier = ">=1.1.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "nvidia-ml-py", specifier = ">=13.590.44" }, { name = "prompt-toolkit", specifier = ">=3.0.52" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -2862,6 +2976,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, ] +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, +] + [[package]] name = "yarl" version = "1.22.0"