diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 7f6c697..0000000 --- a/.dockerignore +++ /dev/null @@ -1,31 +0,0 @@ -.venv/ -__pycache__/ -*.pyc -*.pyo -*.pyd -.Python -env/ -venv/ -.venv/ -pip-log.txt -pip-delete-this-directory.txt -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.log -.git/ -.mypy_cache/ -.pytest_cache/ -.hypothesis/ -.DS_Store -*.swp -*.swo -*~ - -# Exclude large model files - they will be mounted as volumes -base_model/ -loras/ \ No newline at end of file diff --git a/.env.example b/.env.example index a4247ab..c5008fd 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,25 @@ -HF_TOKEN="please-add-your-token-here" \ No newline at end of file +# ---- Required ---------------------------------------------------------- +# HuggingFace token: needed for vLLM to download the base model + LoRAs. +HF_TOKEN=hf_xxx + +# ---- Server / UI wiring (defaults are usually fine for docker compose) - +# Where adaptive_minds.server hits vLLM (inside the compose network). +VLLM_BASE=http://vllm:8000/v1 + +# The Next.js UI proxies browser → API via its own server (`/api/am/* → +# server:8765`), so the bundle uses relative URLs and the same image +# works from localhost, a public IP, or behind a reverse proxy. Override +# only if you've split the UI off and want it to hit FastAPI directly: +# NEXT_PUBLIC_AM_API_BASE=https://api.example.com + +# Default mode the UI boots in: router | agent | auto | langgraph. +# NEXT_PUBLIC_DEFAULT_MODE=router + +# Catalog the FastAPI server loads at startup. Defaults to the paper's +# 30-adapter catalog. Swap to qwen25_smoke.yaml for a 2-adapter fast boot. +AM_CATALOG=/app/catalogs/qwen25_30.yaml + +# ---- Host-port overrides (uncomment if 8000/8765/7007 are taken) ------- +# AM_VLLM_PORT=8001 +# AM_SERVER_PORT=8766 +# AM_UI_PORT=7008 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e7e1edd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +name: tests + +on: + push: + branches: [main, v2, "release/**"] + pull_request: + branches: [main, v2] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: pytest · py${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install package + run: pip install -e ".[dev,serve]" + + - name: Lint (ruff) + run: ruff check adaptive_minds tests + + - name: Pytest + run: pytest tests/ -v + + - name: Smoke import + run: | + python -c "from adaptive_minds.server import app; print('server:', app.title, app.version)" + python -c "from adaptive_minds import run_router, run_agent, load_catalog; print('runtime ok')" diff --git a/.gitignore b/.gitignore index 7a9a954..0fbd86b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,10 @@ base_model/ loras/ models/ +# Eval / training artefacts +evals/results/ +training/loras/ + # Logs *.log logs/ @@ -69,7 +73,37 @@ temp/ .pytest_cache/ .coverage +# ruff +.ruff_cache/ + # mypy .mypy_cache/ .dmypy.json dmypy.json + +# Session-private files (not committed) +OSS_SESSION_LOG.md +.battle_test/ + +# Next.js UI build artefacts (Docker builds these inside the container) +ui/node_modules/ +ui/.next/ +ui/out/ +ui/.env.local +ui/next-env.d.ts + +# Remotion demo-video project: deps + render artefacts + the captured app +# footage (regenerate via scripts/capture_demo.py → scripts/build_demo.sh). +video/node_modules/ +video/out/ +video/public/app.mp4 +video/public/bgm.mp3 +# Raw BGM track is not redistributed (licensing) — the rendered docs/demo.mp4 +# carries the music; supply your own docs/bgm.mp3 to re-render. +docs/bgm.mp3 +docs/.rec/ + +# Re-allow the UI's typed API client; the broad `lib/` Python rule above +# would otherwise swallow it (page.tsx imports from `@/lib/api`). +!ui/src/lib/ +!ui/src/lib/** diff --git a/ADDING_NEW_LORAS.md b/ADDING_NEW_LORAS.md deleted file mode 100644 index 77d6d6a..0000000 --- a/ADDING_NEW_LORAS.md +++ /dev/null @@ -1,510 +0,0 @@ -# 🎯 Adding New LoRA Experts - -**Complete guide to training and deploying custom domain experts** - -This guide covers two approaches: -1. **Quick Deploy** - Use existing LoRAs from HuggingFace -2. **Train Custom** - Train your own domain-specific experts - -## 🚀 Quick Deploy (Use Existing LoRAs) - -### From HuggingFace - -Add any compatible LoRA from HuggingFace: - -```yaml -# In playground/models_config.yaml: -lora_adapters: - - name: "LegalExpert" - source: "huggingface" - huggingface_id: "someuser/legal-llama-lora" # Any compatible LoRA - local_path: "./loras/legal-expert" - description: "Legal expert for law-related questions" - system_prompt: "You are a legal expert. Provide clear, accurate answers about legal topics." - keywords: - - legal - - law - - contract - - regulation - - attorney - enabled: true -``` - -Then sync and deploy: -```bash -cd playground -python manage_models.py sync # Download the model -python manage_models.py rebuild # Update configuration -python server.py # Start server -``` - -### From Local Path - -If you have a LoRA locally: - -```yaml -lora_adapters: - - name: "CustomExpert" - source: "local" - local_path: "./loras/my-custom-expert" # Your local LoRA - description: "My custom trained expert" - system_prompt: "You are an expert in..." - keywords: [custom, domain, specific] - enabled: true -``` - -Then: -```bash -python manage_models.py rebuild -python server.py -``` - ---- - -## 🧪 Train Custom Expert - -### Prerequisites - -```bash -# Install training dependencies -uv sync --extra training - -# Or with pip -pip install unsloth datasets trl transformers -``` - -### Step 1: Choose Your Dataset - -Find a dataset on HuggingFace that matches your domain: - -| Domain | Example Datasets | -|--------|------------------| -| **Medical** | `medalpaca/medical_meadow_medical_flashcards` | -| **Legal** | `pile-of-law/pile-of-law` | -| **Code** | `codeparrot/github-code` | -| **Math** | `microsoft/orca-math-word-problems-200k` | -| **Science** | `sciq` | -| **General QA** | `squad`, `squad_v2` | - -**Browse datasets**: https://huggingface.co/datasets - -### Step 2: Train the LoRA - -```bash -cd playground/train - -# Basic training -python train.py \ - --dataset "medalpaca/medical_meadow_medical_flashcards" \ - --lora-name "medical-expert" \ - --steps 500 - -# Advanced training -python train.py \ - --dataset "your-dataset" \ - --lora-name "custom-expert" \ - --steps 1000 \ - --batch-size 4 \ - --learning-rate 2e-4 \ - --lora-r 32 -``` - -**Training parameters:** -- `--dataset`: HuggingFace dataset name -- `--lora-name`: Name for your LoRA (creates `./loras/{lora-name}`) -- `--steps`: Training steps (500-2000 recommended) -- `--batch-size`: Batch size (reduce if OOM) -- `--learning-rate`: Learning rate (default: 2e-4) -- `--lora-r`: LoRA rank (higher = more parameters) - -**Training time**: ~30 minutes for 500 steps on a single GPU - -### Step 3: Register the LoRA - -After training completes: - -```bash -cd playground - -# Register it -python manage_models.py add-lora \ - --name "MedicalExpert" \ - --path "./loras/medical-expert" \ - --description "Medical expert trained on medical flashcards" \ - --system-prompt "You are a medical knowledge expert. Provide detailed, evidence-based medical information." \ - --keywords medical health diagnosis treatment disease doctor patient - -# Rebuild configuration -python manage_models.py rebuild -``` - -### Step 4: Test Your Expert - -```bash -# Start server -python server.py - -# Test it -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "What are symptoms of diabetes?"}' -``` - ---- - -## 📊 Dataset Formats - -The training script automatically handles these formats: - -### Alpaca Format (Recommended) -```json -{ - "instruction": "What is diabetes?", - "input": "", - "output": "Diabetes is a chronic condition..." -} -``` - -### Q&A Format -```json -{ - "question": "What is diabetes?", - "answer": "Diabetes is a chronic condition..." -} -``` - -### Conversation Format -```json -{ - "conversations": [ - {"from": "human", "value": "What is diabetes?"}, - {"from": "gpt", "value": "Diabetes is a chronic condition..."} - ] -} -``` - -### Simple Format -```json -{ - "input": "What is diabetes?", - "output": "Diabetes is a chronic condition..." -} -``` - ---- - -## 🎯 Best Practices - -### Training Data - -**Quality over Quantity** -- 500-2000 high-quality examples > 10,000 low-quality -- Ensure data is clean and accurate -- Remove duplicates and noise - -**Domain-Specific** -- Use datasets specific to your domain -- Mix related datasets for better generalization -- Include edge cases and common queries - -**Format Consistency** -- Use consistent formatting -- Follow instruction-response pattern -- Include context when needed - -### Training Parameters - -**For Small Datasets (<1000 examples)** -```bash -python train.py \ - --steps 500 \ - --batch-size 2 \ - --lora-r 16 -``` - -**For Medium Datasets (1000-10000 examples)** -```bash -python train.py \ - --steps 1000 \ - --batch-size 4 \ - --lora-r 32 -``` - -**For Large Datasets (>10000 examples)** -```bash -python train.py \ - --steps 2000 \ - --batch-size 8 \ - --lora-r 64 -``` - -### System Prompts - -**Be Specific** -```yaml -# ❌ Generic -system_prompt: "You are a helpful assistant." - -# ✅ Specific -system_prompt: "You are a medical expert. Provide evidence-based medical information with specific explanations about conditions, treatments, and procedures. Always include a disclaimer to consult healthcare professionals." -``` - -**Include Constraints** -```yaml -system_prompt: | - You are a chemistry expert. - - Provide accurate chemical formulas - - Include safety information when relevant - - Give step-by-step procedures - - Never mention external resources - - Focus on practical, actionable advice -``` - -### Keywords - -**Use Domain-Specific Terms** -```yaml -keywords: - - legal - - law - - contract - - attorney - - court - - litigation - - statute - - regulation -``` - -**Include Variations** -```yaml -keywords: - - AI - - artificial intelligence - - machine learning - - ML - - neural network - - deep learning - - training - - model -``` - ---- - -## 🔧 Advanced Configuration - -### Custom Router Prompts - -Modify routing behavior in `models_config.yaml`: - -```yaml -router: - prompt_template: | - You are an expert routing system. Analyze the query and select the best expert. - - Query: "{query}" - - Available Experts: - {domain_list} - - Selection Criteria: - 1. Match keywords in the query - 2. Consider context and intent - 3. If medical/legal, prioritize safety - 4. Default to General for ambiguous queries - - Respond with ONLY the expert name: {domain_names} - - Selected Expert: -``` - -### Multiple LoRAs per Domain - -```yaml -lora_adapters: - - name: "GeneralMedical" - huggingface_id: "user/medical-general" - keywords: [medical, health, wellness] - enabled: true - - - name: "SpecializedMedical" - huggingface_id: "user/medical-oncology" - keywords: [cancer, oncology, chemotherapy] - enabled: true -``` - -### Conditional Loading - -```yaml -lora_adapters: - - name: "ProductionExpert" - enabled: true - - - name: "ExperimentalExpert" - enabled: false # Disable for production - - - name: "RegionalExpert" - enabled: ${REGION == "US"} # Environment-based -``` - ---- - -## 🐛 Troubleshooting - -### Training Issues - -**Out of Memory** -```bash -# Reduce batch size -python train.py --batch-size 1 - -# Or reduce LoRA rank -python train.py --lora-r 8 -``` - -**Poor Quality Outputs** -- Increase training steps -- Use better quality dataset -- Adjust system prompt -- Increase LoRA rank - -**Training Too Slow** -- Increase batch size (if memory allows) -- Use gradient accumulation -- Enable mixed precision (automatic) - -### Deployment Issues - -**LoRA Not Loading** -```bash -# Check if LoRA exists -ls ./loras/your-lora/adapter_config.json - -# Validate configuration -python manage_models.py validate - -# Check logs -tail -f server.log -``` - -**Wrong Expert Selected** -- Add more specific keywords -- Improve system prompt -- Test with various queries -- Adjust router prompt - ---- - -## 📚 Examples - -### Example 1: Legal Expert - -```bash -# 1. Train -python train/train.py \ - --dataset "pile-of-law/pile-of-law" \ - --lora-name "legal-expert" \ - --steps 1000 - -# 2. Register -python manage_models.py add-lora \ - --name "Legal" \ - --path "./loras/legal-expert" \ - --description "Legal expert for law-related questions" \ - --system-prompt "You are a legal expert. Provide clear, accurate legal information. Always recommend consulting a licensed attorney for legal advice." \ - --keywords legal law attorney court contract regulation statute - -# 3. Deploy -python manage_models.py rebuild -python server.py -``` - -### Example 2: Code Assistant - -```bash -# 1. Train -python train/train.py \ - --dataset "codeparrot/github-code" \ - --lora-name "code-assistant" \ - --steps 800 \ - --lora-r 32 - -# 2. Register -python manage_models.py add-lora \ - --name "CodeAssistant" \ - --path "./loras/code-assistant" \ - --description "Programming expert for code generation and debugging" \ - --system-prompt "You are a programming expert. Provide working code examples with clear explanations. Focus on best practices and clean code." \ - --keywords code programming python javascript debug function class - -# 3. Deploy -python manage_models.py rebuild -python server.py -``` - -### Example 3: Customer Support - -```bash -# 1. Prepare custom dataset (your company data) -# Format as Alpaca: instruction + output - -# 2. Train -python train/train.py \ - --dataset "your-company/support-data" \ - --lora-name "support-expert" \ - --steps 500 - -# 3. Register -python manage_models.py add-lora \ - --name "CustomerSupport" \ - --path "./loras/support-expert" \ - --description "Customer support expert for product questions" \ - --system-prompt "You are a customer support expert. Provide friendly, helpful answers about our products. Be empathetic and solution-focused." \ - --keywords support help product question issue problem troubleshoot - -# 4. Deploy -python manage_models.py rebuild -python server.py -``` - ---- - -## 🎓 Training Tips - -### Data Preparation -1. Clean your dataset thoroughly -2. Remove personal information -3. Balance different types of queries -4. Include edge cases -5. Test with sample queries - -### Monitoring Training -```bash -# Watch training progress -tail -f train.log - -# Check GPU usage -watch -n 1 nvidia-smi - -# Monitor disk space -df -h -``` - -### Iteration -1. Train with small steps first (100-200) -2. Test the model -3. Adjust parameters -4. Retrain with more steps -5. Deploy to production - ---- - -## 📬 Need Help? - -- 📖 **Full Documentation**: [README.md](README.md) -- 🎓 **Training Guide**: [playground/train/README.md](playground/train/README.md) -- 🐛 **Report Issues**: [GitHub Issues](https://github.com/yourusername/adaptive-minds/issues) -- 💬 **Discussions**: [GitHub Discussions](https://github.com/yourusername/adaptive-minds/discussions) - ---- - -**Happy Training! 🚀** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca03690..208558c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,316 +1,101 @@ -# Contributing to Adaptive Minds +# 🤝 Contributing -Thank you for your interest in contributing to Adaptive Minds! This document provides guidelines and information for contributors. +First off — **thank you** for considering a contribution! 🙏 Adaptive Minds aims +to be a small, hackable reference implementation, so please keep PRs focused and +minimal. Issues, questions, and ideas are just as welcome as code. -## 🤝 How to Contribute - -We welcome contributions of all kinds: - -- 🐛 **Bug reports** and fixes -- ✨ **New features** and enhancements -- 📚 **Documentation** improvements -- 🧠 **New domain adapters** and models -- 🧪 **Tests** and benchmarks -- 💡 **Ideas** and suggestions - -## 🚀 Getting Started - -### Prerequisites - -- Python 3.10+ -- Docker with GPU support -- NVIDIA GPU with 8GB+ VRAM -- Git -- Hugging Face account - -### Development Setup - -1. **Fork and clone the repository:** - ```bash - git clone https://github.com/qpiai/adaptive-minds.git - cd adaptive-minds - ``` - -2. **Set up your environment:** - ```bash - # Set your Hugging Face token - export HF_TOKEN=your_token_here - - # Install dependencies - pip install -r build/requirements.txt - - # Download models for testing - python build/download_models.py - ``` - -3. **Run the system locally:** - ```bash - # Terminal 1: Start the server - cd build && python server.py - - # Terminal 2: Start the frontend - cd build && streamlit run app_frontend.py - ``` - -4. **Test your setup:** - ```bash - curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "Hello, how are you?"}' - ``` - -## 📋 Contribution Types - -### 🐛 Bug Reports - -When reporting bugs, please include: - -- **Description**: Clear description of the issue -- **Steps to reproduce**: Minimal steps to reproduce the bug -- **Expected behavior**: What should happen -- **Actual behavior**: What actually happens -- **Environment**: OS, Python version, GPU model, Docker version -- **Logs**: Relevant error messages or logs - -**Use the bug report template when creating issues.** - -### ✨ Feature Requests - -For new features: - -- **Description**: Clear description of the proposed feature -- **Use case**: Why is this feature needed? -- **Implementation ideas**: Any thoughts on how to implement it -- **Examples**: Mockups, code snippets, or examples - -### 🧠 Adding New Domain Adapters - -We encourage adding new domain-specific adapters! Here's how: - -1. **Train your LoRA adapter** using your domain-specific data -2. **Upload to Hugging Face** with proper model cards -3. **Add to the system** by updating the configuration -4. **Test thoroughly** with domain-specific queries -5. **Document** the adapter's capabilities and limitations - -#### Adapter Requirements - -- **Model base**: Must be compatible with Llama 3.1 8B -- **Format**: LoRA adapter using PEFT -- **Documentation**: Clear model card on Hugging Face -- **Testing**: Include test queries and expected behaviors -- **Licensing**: Must be compatible with Apache 2.0 - -#### Example Adapter Addition - -```python -# In server.py, add to LORA_ADAPTERS -"Legal": { - "path": "/app/loras/llama-8B-legal", - "description": "Legal expert for contracts, regulations, and legal advice", - "system_prompt": "You are a legal expert. Provide accurate legal information while always recommending consultation with qualified attorneys for specific legal matters." -} -``` - -> **Note:** Keywords are no longer used. The AI router automatically understands domain relevance through semantic analysis. - -## 🔧 Development Guidelines - -### Code Style - -- **Python**: Follow PEP 8 style guidelines -- **Formatting**: Use `black` for code formatting -- **Linting**: Use `flake8` for linting -- **Type hints**: Include type hints where appropriate -- **Docstrings**: Use Google-style docstrings +## 🛠️ Dev setup ```bash -# Format code -black build/ - -# Lint code -flake8 build/ - -# Type checking -mypy build/ +git clone https://github.com/qpiai/adaptive-minds +cd adaptive-minds +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev,serve,tools]" ``` -### Commit Messages - -Use conventional commit messages: - -``` -feat: add new chemistry adapter -fix: resolve GPU memory leak in model loading -docs: update API documentation -test: add routing accuracy tests -refactor: simplify adapter configuration -``` - -### Pull Request Process - -1. **Create a feature branch:** - ```bash - git checkout -b feature/your-feature-name - ``` - -2. **Make your changes** following the coding guidelines - -3. **Test your changes:** - ```bash - # Run the system - docker compose up --build - - # Test API endpoints - curl -X POST http://localhost:8765/chat -H "Content-Type: application/json" -d '{"query": "test query"}' - - # Test web interface - open http://localhost:8501 - ``` - -4. **Update documentation** if needed - -5. **Commit your changes:** - ```bash - git add . - git commit -m "feat: your descriptive commit message" - ``` - -6. **Push to your fork:** - ```bash - git push origin feature/your-feature-name - ``` - -7. **Create a pull request** with: - - Clear title and description - - Reference any related issues - - Include screenshots/demos if applicable - - Ensure CI passes - -### Testing - -- **Unit tests**: Test individual components -- **Integration tests**: Test the full workflow -- **Performance tests**: Ensure no regressions -- **Manual testing**: Test the UI and API endpoints +Run the test suite: ```bash -# Run tests (when available) -pytest tests/ - -# Test routing accuracy -python tests/test_routing_accuracy.py - -# Performance benchmarks -python benchmarks/response_time.py +pytest tests/ -v ``` -## 📁 Project Structure +Lint: +```bash +ruff check adaptive_minds tests ``` -adaptive_minds_oss/ -├── build/ # Main application code -│ ├── server.py # FastAPI backend -│ ├── app_frontend.py # Streamlit frontend -│ ├── app_standalone.py # Standalone Streamlit app -│ ├── download_models.py # Model downloader -│ └── requirements.txt # Python dependencies -├── tests/ # Test files (to be added) -├── docs/ # Additional documentation (to be added) -├── examples/ # Usage examples (to be added) -├── benchmarks/ # Performance benchmarks (to be added) -├── docker-compose.yml # Docker deployment -├── Dockerfile # Container definition -├── README.md # Main documentation -├── CONTRIBUTING.md # This file -├── LICENSE # Apache 2.0 License -└── .gitignore # Git exclusions -``` - -## 🎯 Priority Areas for Contribution - -We're particularly looking for help with: -1. **🧪 Testing Framework** - - Unit tests for routing logic - - Integration tests for the full system - - Performance benchmarks - - Routing accuracy evaluation - -2. **🧠 New Domain Adapters** - - Legal domain - - Education/tutoring - - Creative writing - - Technical documentation - - Customer support - -3. **🔧 Developer Tools** - - CLI for easier setup and management - - Configuration system (YAML-based) - - Model evaluation tools - - Performance monitoring - -4. **📚 Documentation** - - API documentation - - Deployment guides - - Tutorial videos - - Use case examples - -5. **🎨 User Interface** - - Gradio alternative frontend - - React/Next.js web app - - Mobile-responsive design - - Accessibility improvements - -## 🏷️ Issue Labels - -We use these labels to categorize issues: - -- `bug`: Something isn't working -- `enhancement`: New feature or improvement -- `documentation`: Documentation improvements -- `good first issue`: Good for newcomers -- `help wanted`: Extra attention is needed -- `adapter`: Related to domain adapters -- `performance`: Performance improvements -- `ui/ux`: User interface improvements -- `testing`: Testing related +The UI is a Next.js app under `ui/` — `cd ui && npm install && npm run dev` if +you're working on it. + +## 🧩 How to add a new adapter + +1. Train (or pick) a LoRA on `Qwen/Qwen2.5-7B-Instruct` (or whatever the + catalog's `base_model.hf_id` points at). +2. Push the adapter to the HF Hub under + `pavan01729/adaptive-minds-loras` (or a fork of that repo — set + `hub.repo` in your catalog). +3. Add an entry to `catalogs/qwen25_30.yaml`: + ```yaml + - name: MyExpert + hf_subdir: qwen2.5-7b/qwen25_my_expert_v1 + description: One sentence the router will see. + system_prompt: You are an expert in … + keywords: [topic, related, terms] + enabled: true + ``` +4. Re-launch vLLM and the server (`docker compose restart vllm server`). + The new adapter is reachable at `POST /chat` and visible in the UI. -## 💬 Communication +A good `description` and `keywords` matter — they're what the router reads, so +please make them clear and specific. 🙂 -- **GitHub Issues**: For bug reports and feature requests -- **GitHub Discussions**: For questions and general discussion -- **Pull Requests**: For code contributions +## 🔧 How to add a new external tool -## 📜 Code of Conduct +External tools live in `adaptive_minds/external_tools.py`. To add one: -This project follows the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). By participating, you are expected to uphold this code. +1. Implement a handler with the signature + `def my_handler(sub_query: str) -> dict` + The dict must have `output` (str) and may have `debug` (dict) and + `error` (str) keys. +2. Add it to `EXTERNAL_TOOLS` in the same file. +3. Add a one-line help string to `TOOL_DESCRIPTIONS` in + `adaptive_minds/tools.py` — that's what the agent sees in its prompt. -### Our Standards +Tools run in-process; if your tool needs subprocess isolation, please copy the +`code_handler` / `shell_handler` patterns. -- **Be respectful** and inclusive -- **Be collaborative** and constructive -- **Be patient** with newcomers -- **Be professional** in all interactions +## 📊 How to add a benchmark to the evals -## 🏆 Recognition +Drop a new script under `evals/`; reuse `load_catalog`, `run_router`, +`run_agent`, and `vllm_chat` from `adaptive_minds`. Add a row to the +README in that folder describing the paper claim, command, and expected +output shape. -Contributors will be recognized in: +## ✅ PR checklist -- **README.md** contributors section -- **Release notes** for significant contributions -- **GitHub contributors** page +Before opening a PR, please make sure: -## 📄 License +- [ ] `pytest tests/ -v` passes locally (CI re-runs it across 3.10/3.11/3.12) +- [ ] `ruff check adaptive_minds tests` is clean +- [ ] Any new public function has a one-line docstring +- [ ] README / quickstart still works end-to-end if you changed the + runtime, CLI, or docker setup +- [ ] No hardcoded paths or credentials in committed files -By contributing to Adaptive Minds, you agree that your contributions will be licensed under the Apache 2.0 License. +Don't worry if you miss something — open the PR anyway and we'll help you get it +over the line. 🚀 -## ❓ Questions? +## 🚫 What we (probably) won't merge -If you have questions about contributing, please: +- Vendored third-party dependencies (please use the optional extras instead) +- Code that adds heavy runtime deps (transformers, torch, peft) to the + base install — those belong in the `.[training]` or `.[vllm]` extras +- Large, unfocused PRs that touch many areas at once — smaller is easier to + review and much more likely to land -1. Check existing [GitHub Issues](https://github.com/qpiai/adaptive-minds/issues) -2. Create a new issue with the `question` label -3. Start a [GitHub Discussion](https://github.com/qpiai/adaptive-minds/discussions) +## 🙏 Thanks -Thank you for contributing to Adaptive Minds! 🚀 +Thank you for taking the time to make Adaptive Minds better — every issue, idea, +and PR genuinely helps. If something here is unclear or could be smoother, +please tell us; we'd love to fix it. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 538dde9..0000000 --- a/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -FROM nvidia/cuda:12.1.0-devel-ubuntu22.04 - -# Set environment variables -ENV DEBIAN_FRONTEND=noninteractive -ENV PYTHONUNBUFFERED=1 -ENV CUDA_VISIBLE_DEVICES=0 - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - python3.10 \ - python3.10-dev \ - python3-pip \ - git \ - wget \ - curl \ - build-essential \ - && rm -rf /var/lib/apt/lists/* - -# Create symbolic link for python -RUN ln -s /usr/bin/python3.10 /usr/bin/python - -# Install uv -RUN pip install uv - -# Set working directory -WORKDIR /app - -# Copy requirements and install Python dependencies -COPY build/requirements.txt . -RUN uv pip install --system -r requirements.txt - -# Copy the project files -COPY . . - -# Create a script to run the inference (but don't run it by default) -RUN echo '#!/bin/bash\ncd /app/build\npython test_docker.py' > /app/run_inference.sh && \ - chmod +x /app/run_inference.sh - -# Expose port if needed for web interface -EXPOSE 8000 - -# Default command - just start bash for interactive use -CMD ["/bin/bash"] \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index ca3b512..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -1,289 +0,0 @@ -# 🚀 Quick Start Guide - -**Get Adaptive Minds running in 5 minutes with Docker** - -## Prerequisites - -- Docker & Docker Compose installed -- NVIDIA GPU with CUDA support -- 16GB+ VRAM (recommended) -- Internet connection for model downloads - -## Step 1: Clone Repository - -```bash -git clone https://github.com/qpiai/adaptive-minds.git -cd adaptive-minds -``` - -## Step 2: Configure Environment - -```bash -# Copy environment template -cp .env.example .env - -# Edit .env and add your HuggingFace token -nano .env -``` - -Add this line: -``` -HF_TOKEN=your_huggingface_token_here -``` - -> **💡 Get your token**: https://huggingface.co/settings/tokens -> **💡 Accept Llama license**: https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct - -## Step 3: Start the System - -```bash -docker compose up -``` - -This will: -1. 📥 Download base model (~17 files, ~16GB) -2. 📥 Download 5 LoRA adapters (~42 files each) -3. 🚀 Start FastAPI server (port 8765) -4. 🎨 Start Streamlit UI (port 8501) - -**⏱️ First run takes ~15-20 minutes** (downloads models) -**⚡ Subsequent runs**: Instant (uses cached models) - -## Step 4: Access the System - -### Web Interface -Open your browser: **http://localhost:8501** - -![Streamlit UI](./assets/adaptive_minds_v2_lite.gif) - -### API Endpoint -```bash -# Check status -curl http://localhost:8765/status - -# Send a query -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{ - "query": "What is the chemical formula for water?" - }' -``` - -## What You Get - -### 🧠 5 Domain Experts - -| Expert | Handles | Example Queries | -|--------|---------|----------------| -| **General** | Everyday conversation | "Hello", "How are you?", "Tell me about..." | -| **Chemistry** | Compounds & reactions | "What is H2O?", "Explain photosynthesis" | -| **Finance** | Money & investments | "How do I invest in stocks?", "What is a bond?" | -| **AI** | Tech & programming | "How to train a neural network?", "Explain transformers" | -| **Medical** | Health information | "What causes a headache?", "Symptoms of flu?" | - -### 🎯 Smart Routing - -The system automatically picks the best expert: -``` -Query: "How do I train a neural network?" → AI Expert ✅ -Query: "What is H2O?" → Chemistry Expert ✅ -Query: "Hello, how are you?" → General Expert ✅ -``` - -## Verify Everything Works - -### 1. Check Service Status -```bash -# Check if containers are running -docker compose ps - -# Should show: -# adaptive-minds-server running 0.0.0.0:8765->8765/tcp, 0.0.0.0:8501->8501/tcp -``` - -### 2. Check API Health -```bash -curl http://localhost:8765/status | jq -``` - -Expected output: -```json -{ - "is_initialized": true, - "loaded_adapters": ["General", "Chemistry", "Finance", "AI", "Medical"], - "gpu_memory_allocated": 15.11, - "gpu_memory_reserved": 16.52 -} -``` - -### 3. Test Each Expert - -```bash -# Test General -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "Hello, how are you?"}' - -# Test Chemistry -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "What is the chemical formula for water?"}' - -# Test AI -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "How do I train a neural network?"}' -``` - -## Common Commands - -```bash -# Start system -docker compose up - -# Start in background -docker compose up -d - -# View logs -docker compose logs -f - -# Stop system -docker compose down - -# Restart -docker compose restart - -# Rebuild after changes -docker compose up --build -``` - -## Troubleshooting - -### Issue: Port already in use - -```bash -# Check what's using the port -lsof -i:8765 -lsof -i:8501 - -# Kill the process -kill -9 -``` - -### Issue: CUDA out of memory - -```bash -# Edit docker-compose.yml to use smaller batch sizes -# Or reduce number of loaded experts in build/metadata.json -``` - -### Issue: Model download fails - -```bash -# Check HF_TOKEN in .env -cat .env | grep HF_TOKEN - -# Check if you accepted the Llama license -# Visit: https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct - -# Try manual download -docker compose exec adaptive-minds-server python /app/build/download_models.py -``` - -### Issue: Container won't start - -```bash -# Check logs -docker compose logs - -# Check NVIDIA Docker -docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi - -# Rebuild -docker compose down -docker compose build --no-cache -docker compose up -``` - -## What's Next? - -### Use the Web Interface -1. Open http://localhost:8501 -2. Type your questions -3. See which expert handles each query -4. View conversation history - -### Explore the API -- **POST /chat** - Send queries -- **GET /status** - System status -- **GET /available_experts** - List experts - -### Customize & Train Your Own -Want to add custom experts or train on your own data? -**👉 See [playground/README.md](playground/README.md) for development guide** - -### Production Deployment -- Use reverse proxy (nginx/traefik) -- Add authentication -- Scale with Kubernetes -- Monitor with Prometheus/Grafana - -## Performance Tips - -### Optimize GPU Usage -```bash -# Set specific GPU -CUDA_VISIBLE_DEVICES=0 docker compose up - -# Use multiple GPUs (if available) -# Edit docker-compose.yml: -# deploy: -# resources: -# reservations: -# devices: -# - driver: nvidia -# device_ids: ['0', '1'] -``` - -### Speed Up First Start -```bash -# Pre-download models before Docker -python build/download_models.py - -# Then start Docker -docker compose up -``` - -## System Requirements - -### Minimum -- 16GB VRAM (NVIDIA GPU) -- 32GB RAM -- 50GB disk space -- Ubuntu 20.04+ - -### Recommended -- 24GB+ VRAM (RTX 3090, A5000, etc.) -- 64GB RAM -- 100GB SSD -- Ubuntu 22.04 - -### Cloud Deployment -Works great on: -- AWS p3/p4 instances -- GCP GPU instances -- Azure NC series -- Lambda Labs -- RunPod - -## Support - -- 📖 **Full Documentation**: [README.md](README.md) -- 🐛 **Report Issues**: [GitHub Issues](https://github.com/yourusername/adaptive-minds/issues) -- 💬 **Discussions**: [GitHub Discussions](https://github.com/yourusername/adaptive-minds/discussions) -- 🤗 **Models**: [HuggingFace Collection](https://huggingface.co/collections/pavan01729/adaptive-minds-68cbab3565664604be49a462) - ---- - -**🎉 Congratulations! You now have a working multi-agent AI system with specialized domain experts!** - diff --git a/README.md b/README.md index 17ecfd8..6eac20f 100644 --- a/README.md +++ b/README.md @@ -1,639 +1,274 @@ +
+ # 🧠 Adaptive Minds -**Intelligent multi-agent AI system that dynamically selects specialized domain experts for your queries** - -[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Models-yellow)](https://huggingface.co/collections/pavan01729/adaptive-minds-68cbab3565664604be49a462) -[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) -[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) - -Adaptive Minds transforms LoRA adapters into **specialized tools** for AI agents. Instead of using a single general-purpose model, it employs an intelligent router that automatically selects the most appropriate LoRA adapter for each query, effectively giving the AI agent access to domain-specific expertise on demand. - -![Adaptive Minds Demo](./assets/adaptive_minds_v2_lite.gif) - -## ✨ Key Features - -- 🎯 **Intelligent Routing**: AI automatically selects the best domain expert for each query -- 🧠 **5 Domain Experts**: Chemistry, Finance, AI, Medical, and General knowledge -- 🔄 **Multi-Agent Architecture**: Built with LangGraph for robust workflow management -- 🔧 **Fully Configurable**: YAML-based configuration, no code changes needed -- 🚀 **Two Deployment Modes**: - - **Docker** - One-command deployment with pre-configured experts - - **Playground** - Train, customize, and deploy your own experts -- 💬 **Dual Interface**: FastAPI backend + Streamlit web UI -- 📊 **Transparent Reasoning**: See why each expert was selected -- 🔗 **Conversation Memory**: Context-aware responses across interactions - -## 🏗️ How It Works - -```mermaid -graph LR - A[User Query] --> B[AI Router Agent] - B --> C{Domain Analysis} - C --> D[Chemistry Expert] - C --> E[Finance Expert] - C --> F[AI Expert] - C --> G[Medical Expert] - C --> H[General Expert] - D --> I[Specialized Response] - E --> I - F --> I - G --> I - H --> I -``` +### Empowering Agents with LoRA-as-Tools -1. **User sends a query** via API or web interface -2. **AI Router Agent** analyzes the query using the base Llama 3.1 model -3. **Domain selection** happens through semantic understanding -4. **Expert Agent** generates response using the selected domain-specific LoRA adapter -5. **Response delivered** with reasoning about why that expert was chosen +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) +[![tests](https://github.com/qpiai/adaptive-minds/actions/workflows/test.yml/badge.svg)](https://github.com/qpiai/adaptive-minds/actions/workflows/test.yml) +[![HuggingFace adapters](https://img.shields.io/badge/🤗-adaptive--minds--loras-yellow)](https://huggingface.co/pavan01729/adaptive-minds-loras) +[![arXiv](https://img.shields.io/badge/arXiv-2510.15416-b31b1b.svg)](https://arxiv.org/abs/2510.15416) ---- +**One base model. Many LoRA experts. The model picks which one(s) to use — and you can see why.** -## 🚀 Quick Start (Docker) +[Key results](#-key-results) · [How it works](#-how-it-works--four-modes-one-framework) · [Quickstart](#-quickstart-docker) · [Reproduce paper](#-reproducing-paper-results) · [Architecture](docs/ARCHITECTURE.md) -**Get up and running in 5 minutes with pre-configured experts.** +
-### Prerequisites -- Docker & Docker Compose -- NVIDIA GPU with 8GB+ VRAM -- HuggingFace account (for Llama 3.1 access) +--- -### 1. Get HuggingFace Access + -**Required:** You need access to Meta's Llama 3.1 model on HuggingFace. +https://github.com/user-attachments/assets/45a8f228-dac0-4f77-8a89-93d03a697d06 -1. **Create a HuggingFace account** at [huggingface.co](https://huggingface.co) +
▶︎ Click to play (with sound) — a 60-second tour: Router → LangGraph on the live 30-adapter stack.  ·  Silent GIF: docs/demo.gif  ·  Full MP4: docs/demo.mp4
-2. **Accept the Llama 3.1 license:** - - Visit: https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct - - Click "Accept" on the license agreement - - Wait for approval (usually instant) +--- -3. **Get your access token:** - - Go to: https://huggingface.co/settings/tokens - - Create a new token with "Read" permissions - - **💡 Recommended:** Create a `.env` file for persistent storage: - ```bash - # Create .env file in project root - cat > .env << EOF - HF_TOKEN=your_actual_token_here - EOF - ``` - - **Alternative:** Copy and edit the example template: - ```bash - cp .env.example .env - nano .env # Edit with your actual token - ``` - - ⚠️ **Security Note:** The `.env` file is already in `.gitignore` - never commit tokens to version control! +## 🤔 Why Adaptive Minds? -4. **Verify access:** - ```bash - # Test your token works - curl -H "Authorization: Bearer $HF_TOKEN" \ - https://huggingface.co/api/models/meta-llama/Llama-3.1-8B-Instruct - ``` +> **Adapters are tools — and like any tool, the quality of each one matters enormously.** Train good adapters, and the framework takes care of the rest: discovering them, picking the right one(s) for each query, and composing them into an answer. -### 2. Start the System +Two of the most useful ideas in modern LLMs have lived in separate worlds: **parameter-efficient specialization** (LoRA adapters that make a base model great at one domain) and **tool-augmented agents** (models that reason by calling tools). **Adaptive Minds unifies them** — it treats each LoRA adapter as a *named, callable tool* and lets the base model decide **which** adapter to use, **when**, **how often**, and **in what order**. -```bash -git clone https://github.com/qpiai/adaptive-minds.git -cd adaptive-minds +Instead of merging LoRAs into one weight blend, every adapter stays a distinct, inspectable tool served by a **single vLLM engine**. The base model orchestrates them — in one call (**Router**) or across a multi-step reasoning loop that can also reach external tools (**Agent**). Because every adapter call is an explicit, named action, you get an **auditable trace** of how the answer was produced — something parameter-merging can't give you. -# Create .env file with your HF token (if not done already) -echo "HF_TOKEN=your_actual_token_here" > .env +- 🎯 **Model-driven routing** — 98.3% accuracy picking the right expert from a 30-adapter library (vs 31.7% for keyword matching). +- 🧩 **Adapters as tools** — add or remove experts at deploy time; no router retraining, no weight merging. +- 🔍 **Auditable** — an explicit `CALL` / `OBSERVATION` trace instead of entangled merged weights. +- ⚡ **One engine** — vLLM serves the base + all adapters by name; Router mode is **3.1× faster** than the agent baseline. +- 🖥️ **Batteries included** — FastAPI server, Next.js chat UI (light/dark), Docker compose, 30 adapters on the HF Hub, reproducible evals, and a 295-line `nanoam.py` reference. -# Start the system (first run downloads ~16GB of models) -docker compose up -``` +## ✨ Key results -**🕐 First Run:** Initial startup takes ~15-20 minutes to download models. Watch for "🎉 ALL SYSTEMS READY!" message. +*From the paper ([arXiv 2510.15416](https://arxiv.org/abs/2510.15416)).* -The system will: -- Download base model and 5 LoRA adapters from HuggingFace -- Start FastAPI server (port 8765) -- Start Streamlit UI (port 8501) +| Result | Number | +|---|---| +| Routing accuracy — **30-adapter** library | **98.3%**  (vs 31.7% keyword · **+66.6 pp**) | +| Routing accuracy — 5-adapter library | **100%**  (vs 48.3% keyword · +51.7 pp) | +| Specialist gains over base — 9 task families | **+4.6 → +84.0 pp** (strict scorer) | +| Router vs. directly-pinned specialist | within **±5 pp** on every benchmark | +| Router-mode latency | **3.1× faster** than the agent baseline (3.49 s vs 10.81 s) | -### 3. Access the System +Representative per-domain specialist gains (Table 2): SQL **+29.4**, Text2Cypher **+39.3**, PII redaction **+76.3**, legal/LEDGAR **+84.0**, chemistry/SMILES **+30.4** — and the router recovers each automatically, without a human picking the expert. -**Web Interface:** -- Open http://localhost:8501 for the Streamlit UI +**Three findings hold the framework together:** -**API Interface:** -```bash -# Send a query -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "What is the molecular formula of caffeine?"}' +1. **Specialists help where the base has a real gap.** On weak-base structured generation and niche tasks the gains are large; on strong-base reasoning (MATH-500, GSM8K) they stay within ±2 pp. Adapter *quality* is the multiplier. +2. **The router aggregates those gains** — within ±5 pp of the directly-pinned specialist on every benchmark whose queries carry domain signal. +3. **Routing is reliable at scale** — 98.3% even as the adapter pool grows 6× and the boundaries become semantic rather than keyword-like. -# Check system status -curl http://localhost:8765/status -``` +## 🧭 How it works — four modes, one framework -**📚 See [QUICKSTART.md](QUICKSTART.md) for detailed Docker deployment guide** +### 🎯 Router — single-step semantic routing ---- +
Router architecture: query → router agent → expert LoRA adapter → output
-## 🧪 Development Mode (Playground) +One base-model call reads the query, matches it against adapter metadata, and selects the best expert; that adapter answers. No keyword rules, no hand-written dispatch — the model *is* the router. Best for clear, single-domain queries. *(paper §5.2)* -**For training custom experts and advanced configuration.** +### 🤖 Agent — multi-step ReAct reasoning -The `playground/` directory provides a complete development environment where you can: -- Train custom LoRA experts on any HuggingFace dataset -- Mix pre-trained and custom models -- Configure routing behavior dynamically -- Test and validate your setup +
Agent architecture: Think → Select Tool → Observe → Iterate over LoRA adapters and external tools, then synthesise
-### Setup +For tasks that need decomposition, the base model runs a **THOUGHT → CALL → OBSERVATION → FINAL** loop, invoking LoRA experts **and** external tools (calculator, code, shell, web, LP solver) across steps, then synthesises a final answer. Here adapters are composable skills, not just one-shot specialists. *(paper §5.4)* -```bash -# Install dependencies -uv sync +### 🪄 Auto — let the model choose the mode -# Or with training capabilities -uv sync --extra training +A lightweight classifier sizes up the query and dispatches it: short, single-domain → **Router**; compound or multi-step → **Agent**. The decision (and the reason) is returned alongside the answer. The paper's entropy gate uses the base model's first-16-token entropy H(Q): `H < 0.8 → Router`, `H > 1.5 → Agent`. See the unified diagram in [System architecture](#-system-architecture) below. *(paper §5.5)* -# Activate environment -source .venv/bin/activate -cd playground -``` +### 🕸️ LangGraph — the agent as a state graph -### Configuration - -Edit `playground/models_config.yaml` to configure your experts: - -```yaml -# Add any HuggingFace model -lora_adapters: - - name: "MyExpert" - source: "huggingface" - huggingface_id: "username/model-name" - local_path: "./loras/my-expert" - description: "Expert in my domain" - system_prompt: "You are an expert in..." - keywords: [domain, specific, keywords] - enabled: true - - # Or use local models - - name: "CustomExpert" - source: "local" - local_path: "./loras/custom-expert" - description: "My custom trained expert" - system_prompt: "You are an expert in..." - keywords: [custom, domain, specific] - enabled: true -``` +The same ReAct behaviour expressed as a `langgraph.StateGraph` (**plan → dispatch → synthesise**), so each run is observable as node visits — handy for tracing and for wiring Adaptive Minds into existing LangGraph pipelines. -### Model Management +## 🏗 System architecture -```bash -# List configured models -python manage_models.py list +
Unified Adaptive Minds architecture: a mode classifier dispatches the query to single-step routing or a multi-step agent, both drawing on a shared tool registry of LoRA adapters and external tools
-# Download/sync all models -python manage_models.py sync +One classifier, two operating regimes, a shared tool registry of LoRA adapters + external tools. The design principles: -# Validate configuration -python manage_models.py validate +- **Single source of truth** — one YAML catalog drives *both* the `vllm serve` launch command and the runtime's adapter selection. Adding an expert is "edit YAML, restart vLLM, done." +- **No model weights in the server** — `adaptive_minds.server` is FastAPI + Pydantic + `requests`; all inference is HTTP to vLLM. The browser reaches it through the Next.js proxy (`/api/am/*`), so the same stack works on localhost, a public IP, or behind a reverse proxy. +- **Small core** — ~1.66 k lines across eleven `.py` files; every public function has a docstring that says *why* it exists. -# Rebuild metadata from config -python manage_models.py rebuild -``` - -### Train and Add New LoRAs (End-to-End) +> **Shortest path to understanding:** read [`nanoam.py`](nanoam.py) (≤300 lines) — catalog loader, vLLM client, router, agent loop, two tool handlers, and `__main__` in one file with just stdlib + `requests` + `PyYAML`. The `adaptive_minds/` package is the same shape with FastAPI, sandboxed tools, evals, and Docker around it. Full walk-through: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). -Follow these exact steps from the `playground/` directory to train, register, and serve a new expert: +## 📦 What's included -```bash -# 0) Prep environment (run once) -uv sync --extra training -source .venv/bin/activate -cd playground - -# 1) Configure and download models (base + any enabled LoRAs) -export HF_TOKEN="your_huggingface_token_here" # if using gated HF models -python manage_models.py sync - -# 2) Train a new LoRA on any HuggingFace dataset -python train/train.py \ - --dataset "medalpaca/medical_meadow_medical_flashcards" \ - --lora-name "medical-expert" \ - --steps 500 - -# 3) Register the trained LoRA into the config -python manage_models.py add-lora \ - --name "MedicalExpert" \ - --path "./loras/medical-expert" \ - --description "Medical domain expert" \ - --system-prompt "You are a medical expert. Provide detailed, evidence-based answers with a brief disclaimer to consult professionals for personal care." \ - --keywords medical health diagnosis treatment - -# 4) Rebuild metadata.json used by the server -python manage_models.py rebuild - -# 5) (Optional) Validate and list -python manage_models.py validate -python manage_models.py list - -# 6) Start the dev server -python server.py - -# 7) Test via API -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "What are common flu symptoms?"}' -``` +| | | +|---|---| +| **5 external tools** | `calculator` (sympy) · `code` (sandbox) · `shell` (sandbox) · `websearch` (DDG) · `pulp` (LP solver). One function to add your own. | +| **30 LoRA specialists** | SQL, Cypher, SPARQL, bash, Mermaid, PII, quantum, legal, chem + 21 more — all on the HF Hub. | +| **FastAPI server** | `/health` · `/adapters` · `/route` · `/agent` · `/chat`. Pydantic-validated, CORS-open, no torch/transformers in the server layer. | +| **Next.js chat UI** | Light/dark, four-mode tabs, adapter sidebar, trace expander, `@xyflow/react` decision + state-graph viz. Port `7007`. | +| **Docker compose** | `docker compose up -d` → vLLM + server + UI. | +| **Reproducible evals** | scripts for paper Tables 1 & 3 + a 151-query hand-labeled gold set. | +| **`nanoam.py`** | the whole framework in 295 lines. | +| **CI + tests** | 42 hermetic pytest cases (no GPU/network); matrix on Python 3.10 / 3.11 / 3.12. | -### Start Development Server +## ⚡ Quickstart (Docker) ```bash -# Start server -python server.py - -# Or with frontend (in another terminal) -streamlit run app_frontend.py +git clone https://github.com/qpiai/adaptive-minds +cd adaptive-minds +cp .env.example .env # then fill in HF_TOKEN +docker compose up -d ``` -**📚 See [playground/README.md](playground/README.md) for complete development documentation** +That's it. Wait for vLLM to download the base model + all 30 LoRA adapters (~15–30 min first time, watch `docker compose logs -f vllm`), then open **http://localhost:7007**. ---- - -## 🎯 Examples & Usage +For a faster first boot, swap to the 2-adapter smoke catalog: set `AM_CATALOG=/app/catalogs/qwen25_smoke.yaml` in `.env` and trim `docker-compose.yml`'s `vllm` `--lora-modules` to just `chemistry` + `sql`. -### 📚 Ready-to-Run Examples - -We've included several examples to help you get started: +## 🐍 Quickstart (pip, against an existing vLLM) ```bash -# 🐍 Python API examples -python examples/basic_usage.py - -# 💬 Interactive chat demo -python examples/interactive_demo.py - -# 🌐 cURL examples -./examples/curl_examples.sh -``` - -See the [`examples/`](examples/) directory for detailed usage examples and documentation. +pip install -e ".[serve,tools]" +export VLLM_BASE=http://your-vllm-host:8000/v1 -### 🎯 Example Queries +# 1. Start the FastAPI server +adaptive-minds server --catalog catalogs/qwen25_30.yaml & -| Query | Selected Expert | Why | -|-------|----------------|-----| -| "What is the molecular formula of water?" | **Chemistry** | Chemical compound question | -| "How does compound interest work?" | **Finance** | Financial concept | -| "Explain machine learning" | **AI** | Technology topic | -| "What are symptoms of flu?" | **Medical** | Health-related question | -| "Hello, how are you?" | **General** | Casual conversation | +# 2. (Optional) launch the Next.js UI from source +cd ui && npm install && npm run dev ---- - -## 📁 Project Structure +# 3. Or use the CLI directly: +adaptive-minds route --catalog catalogs/qwen25_30.yaml \ + --query "Write a SQL query for top 10 customers by revenue in 2023." +adaptive-minds agent --catalog catalogs/qwen25_30.yaml \ + --query "Compute 2**32+17, then explain the figure in finance terms." ``` -adaptive-minds/ -├── build/ # Docker quick-start (production) -│ ├── server.py # FastAPI backend -│ ├── metadata.json # Expert configuration -│ ├── download_models.py # Model downloader -│ ├── app_frontend.py # Streamlit web UI -│ └── requirements.txt # Python dependencies -│ -├── playground/ # Development environment -│ ├── models_config.yaml # Main configuration (edit this!) -│ ├── manage_models.py # Model management CLI -│ ├── server.py # Dev server -│ ├── app_frontend.py # Streamlit UI -│ ├── train/ # Training scripts -│ │ ├── train.py # LoRA training -│ │ └── README.md # Training guide -│ └── README.md # Playground documentation -│ -├── examples/ # Usage examples -│ ├── basic_usage.py # Python API examples -│ ├── interactive_demo.py # Interactive chat demo -│ ├── curl_examples.sh # cURL examples -│ └── README.md # Examples documentation -│ -├── docker-compose.yml # Docker deployment -├── Dockerfile # Container definition -├── README.md # This file -├── QUICKSTART.md # Docker quick start guide -├── ADDING_NEW_LORAS.md # Training & customization guide -└── CONTRIBUTING.md # Contribution guidelines -``` - ---- - -## 📊 Models & Architecture -### Base Model -[**meta-llama/Llama-3.1-8B-Instruct**](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct) -- Used by the Router Agent for domain selection -- Requires HuggingFace authentication - -### LoRA Adapters -🔗 [**View Complete Collection on HuggingFace**](https://huggingface.co/collections/pavan01729/adaptive-minds-68cbab3565664604be49a462) - -| Expert | Model ID | Domain | Keywords | -|--------|----------|--------|----------| -| **General** | `pavan01729/llama-8B-alpaca-2k` | Everyday conversation | chat, help, question | -| **Chemistry** | `pavan01729/llama-8B-chemistry` | Compounds & reactions | chemistry, molecule, reaction | -| **Finance** | `pavan01729/llama-8B-finance-alpaca` | Investments & economics | finance, investment, stock | -| **AI** | `pavan01729/llama-8B-gpt-ai` | Tech & programming | AI, programming, neural network, LoRA | -| **Medical** | `pavan01729/llama-8B-medical-alpaca` | Healthcare & medicine | medical, health, diagnosis | - -> 💡 **Training Note**: All LoRA adapters were fine-tuned using [QpiAI Pro](https://www.qpiai-pro.tech/), a no-code platform that simplifies LLM fine-tuning and deployment. This made it easy to create domain-specific adapters without complex coding - perfect for researchers and developers looking to build their own specialized models. - ---- - -## 📡 API Reference - -### POST /chat -Send a query to the system. - -**Request:** -```json -{ - "query": "What is photosynthesis?" -} -``` - -**Response:** -```json -{ - "response": "Photosynthesis is the process by which plants convert light energy...", - "selected_adapter": "Chemistry", - "reasoning": "🔍 Query: 'What is photosynthesis?'\n🤖 AI Selection: Chemistry\n✅ Decision: Chemistry via AI semantic analysis" -} -``` +## 🧪 Try it -### GET /status -Get system status and loaded models. +**Via curl:** -**Response:** -```json -{ - "is_initialized": true, - "loaded_adapters": ["General", "Chemistry", "Finance", "AI", "Medical"], - "gpu_memory_allocated": 15.2, - "gpu_memory_reserved": 16.0 -} -``` - -### GET /available_experts -List all available domain experts. - -### DELETE /chat/history -Clear conversation history. - ---- - -## 🎯 Use Cases - -### Domain-Specific Chatbots -Build specialized assistants for: -- Customer support (product-specific) -- Technical documentation -- Healthcare information -- Financial advice -- Educational tutoring - -### Research Assistants -Create experts for: -- Scientific literature review -- Legal document analysis -- Market research -- Code generation and debugging -- Data analysis - -### Multi-Domain Systems -Combine multiple experts for: -- Enterprise knowledge bases -- Multi-specialty consulting -- Educational platforms -- Professional services - ---- - -## 🔧 Configuration - -### Dynamic Configuration -All configuration is managed through `playground/models_config.yaml` - no code changes needed! - -### Router Behavior -Configure how the AI router selects experts: - -```yaml -router: - prompt_template: | - Analyze this user query and select the most appropriate domain expert. - - Query: "{query}" - Available Domain Experts: {domain_list} - - Instructions: - - Analyze the query carefully - - Consider the main topic and intent - - Choose the domain expert that best matches the query - - If unsure, choose General - - Selected Domain: +```bash +curl -s :8765/health | jq . +curl -s :8765/adapters | jq 'map(.id)' + +curl -s :8765/route \ + -H 'Content-Type: application/json' \ + -d '{"query": "Write SQL to find top 5 customers by revenue."}' \ + | jq '.adapter_id, .response' + +curl -s :8765/agent \ + -H 'Content-Type: application/json' \ + -d '{"query": "Compute 2**16+17, then explain it as a finance metric."}' \ + | jq -r .response ``` -### Expert Configuration -Each expert needs: -- **name**: Unique identifier -- **source**: `huggingface` or `local` -- **huggingface_id**: Model ID on HuggingFace (if source is huggingface) -- **local_path**: Model location -- **description**: What this expert handles -- **system_prompt**: Expert's persona and instructions -- **keywords**: For routing (auto-generates descriptions) -- **enabled**: Toggle on/off without deleting - -### Generation Parameters -Customize response quality in `server.py`: +**Via Python:** ```python -outputs = model.generate( - max_new_tokens=512, # Response length - temperature=0.8, # Creativity (0.0-1.0) - top_p=0.92, # Nucleus sampling - top_k=50, # Vocabulary filtering - repetition_penalty=1.15, # Reduce repetition - no_repeat_ngram_size=3 # Prevent phrase repetition -) -``` +from adaptive_minds import load_catalog, run_router, run_agent +from adaptive_minds.catalog import router_cfg ---- +cat = load_catalog("catalogs/qwen25_30.yaml") +cfg = router_cfg("catalogs/qwen25_30.yaml") -## 🔧 Adding Your Own Domain Experts - -Want to add your own specialized LoRA adapters? It's easy! - -### Quick Start -```bash -# 1. Train your adapter -cd playground/train -python train.py --dataset your-dataset --lora-name your-domain - -# 2. Register it -cd .. -python manage_models.py add-lora \ - --name "YourExpert" \ - --path "./loras/your-domain" \ - --description "Expert in your domain" \ - --system-prompt "You are an expert in..." \ - --keywords your domain keywords - -# 3. Deploy -python manage_models.py rebuild -python server.py +r = run_router("How do you optimize a SPARQL query?", cat, cfg, 0.3, 256) +print(r["adapter_id"], "→", r["response"]) ``` -**📖 See the complete guide:** [ADDING_NEW_LORAS.md](ADDING_NEW_LORAS.md) +## 📊 Reproducing paper results -This covers: -- Training custom LoRA adapters -- Choosing and preparing datasets -- Integration steps -- Testing and troubleshooting -- Multiple real-world examples +| Paper | What it measures | Command | +|---|---|---| +| **Table 1** | router accuracy on the 30-adapter library | `python -m evals.routing_table1 --catalog catalogs/qwen25_30.yaml` | +| **Table 1** | keyword-matching baseline | `python -m evals.routing_keyword_baseline --catalog catalogs/qwen25_30.yaml` | +| **Table 2** | per-specialist gains over base | see [`training/README.md`](training/README.md) — shared recipe + benchmark map | +| **Table 3** | three-way (vanilla / router / agent) on MMLU | `python -m evals.mmlu_three_way --catalog catalogs/qwen25_30.yaml --n 10` | ---- +Full details in [`evals/README.md`](evals/README.md). -## 🐛 Troubleshooting +## 🧰 Bring your own adapter -### Common Issues +1. Train a LoRA with the shared recipe (or reuse any PEFT LoRA on the catalog's base model): + ```bash + python training/train_sft.py \ + --dataset hf://your-org/your-dataset \ + --base-model Qwen/Qwen2.5-7B-Instruct \ + --lora-name qwen25_my_expert_v1 + ``` + The paper's adapters use **LoRA r=32, α=64, all-linear targets**, ~500 SFT steps for reasoning-trace experts; structured-generation specialists add 400–600 SFT + ~200 GRPO steps with an execution-based reward (the 30-adapter pool uses a lighter r=16 / 100-step recipe for breadth). A "train-as-a-tool" pipeline can build a fresh adapter from web pages in ~21 min on a single L40S. +2. Push to the Hub (or any HF-compatible repo), then add an entry to `catalogs/qwen25_30.yaml`: + ```yaml + - name: MyExpert + hf_subdir: qwen2.5-7b/qwen25_my_expert_v1 + description: One sentence the router will see. + system_prompt: You are an expert in … + keywords: [topic, related, terms] + enabled: true + ``` +3. Restart vLLM + server. The new adapter is routable — no router retraining needed. -**"Access denied" when downloading models:** -- Ensure `HF_TOKEN` is set correctly in `.env` -- Accept the Llama 3.1 license at https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct -- Verify token has read permissions +## ⚖️ When it helps (and when it doesn't) -**Server fails to start:** -- Check GPU availability: `nvidia-smi` -- Ensure Docker has GPU access -- Verify sufficient GPU memory (8GB+ recommended) +Adaptive Minds is honest about where the gains come from: -**CUDA out of memory:** -```bash -# Reduce batch size or use smaller models -# Or run on CPU (slower) -CUDA_VISIBLE_DEVICES="" python server.py -``` +- ✅ **Big wins where the base has a real gap** — structured generation (SQL, Cypher, SPARQL, Mermaid) and niche formats (PII, legal labels, SMILES). This is where specialists earn their +30 → +84 pp. +- ➖ **Marginal on strong-base reasoning** — on MATH-500 / GSM8K the recipe moves accuracy < ±2 pp; the base is already capable, so routing mostly preserves it. +- ⚠️ **Out-of-distribution MCQ** — on GPQA Diamond, single-step routing *underperforms* the base (−20.7 pp) because specialist output format mismatches the strict scorer; the multi-step **Agent** loop, keeping the base model as controller, recovers to within ~1.3 pp. -**Models download slowly:** -- First run downloads ~16GB of models -- Use a stable internet connection -- Models are cached for subsequent runs +The takeaway: **train good adapters; the framework makes them available on the right query at the right time.** Better adapters strictly help; weak ones simply don't get selected. -**Port already in use:** -```bash -# Kill existing process -lsof -ti:8765 | xargs kill -9 +## 🗂 Project layout -# Or change port in server.py or docker-compose.yml +``` +adaptive-minds/ +├── adaptive_minds/ # runtime: router, agent, auto, langgraph, tools, catalog, server, CLI +├── ui/ # Next.js 14 chat UI (light/dark, Tailwind + framer-motion + xyflow) +├── catalogs/ # YAML adapter catalogs (30-adapter + 2-adapter smoke) +├── evals/ # paper-table reproduction scripts + 151-query gold set +├── training/ # shared SFT recipe + per-benchmark mapping +├── examples/ # python + curl quickstart scripts +├── docker/ # Dockerfile.server, Dockerfile.ui +├── docs/ # ARCHITECTURE.md + figures + demo media +├── video/ # Remotion intro/outro for the demo video +├── scripts/ # capture_demo.py + build_demo.sh +├── tests/ # pytest, no GPU/network needed +├── nanoam.py # the whole framework in one 295-line file +└── docker-compose.yml # vllm + server + ui ``` -### System Requirements +## 🗺 Roadmap -**Minimum:** -- NVIDIA GPU with 8GB+ VRAM -- 16GB system RAM -- 20GB free disk space -- Ubuntu 20.04+ +The big questions we'd love to push on next — contributions and ideas are very welcome 🙏: -**Recommended:** -- NVIDIA GPU with 16GB+ VRAM (RTX 3090, A5000, etc.) -- 32GB+ system RAM -- 50GB+ SSD space -- Ubuntu 22.04 +- **🧩 Adapters as truly first-class tools.** Make the agent reach for adapters as naturally as any other tool — including *train-as-a-tool*: when no existing specialist fits, commission and train a new one on the fly, then route to it. +- **📈 Scale to hundreds → thousands of adapters.** Our study covers 30; we want to learn how reliably model-driven routing holds as the library grows large and the boundaries blur — and what indexing / retrieval the router needs at that scale. +- **🎓 Better ways to train the adapters.** Quality is the multiplier, so finding the data, recipes, and evaluation that reliably produce strong specialists is where most of the leverage is. ---- +Smaller, practical items: SSE streaming in the UI · a PEFT-backed in-process runtime for single-GPU setups · adapter-fusion experiments (paper §3.3, vs. LoRA Soups). ## 🤝 Contributing -We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. +PRs, issues, and ideas are very welcome — thank you for helping make this better! Please see [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup, testing, and what we will / won't merge. -### Quick Contribution Guide +## 📚 Citation -1. **Fork the repository** -2. **Create a feature branch**: `git checkout -b feature/amazing-feature` -3. **Make your changes** -4. **Test thoroughly**: `cd playground && python test_workflow.py` -5. **Commit**: `git commit -m 'Add amazing feature'` -6. **Push**: `git push origin feature/amazing-feature` -7. **Open a Pull Request** +If you use this work, please cite the paper: -### Areas for Contribution -- 🧠 Additional domain-specific adapters and training datasets -- 🔧 Router improvements and alternative algorithms -- 📚 Documentation, tutorials, and examples -- 🐛 Bug fixes and performance optimizations -- 🎨 UI/UX improvements -- 🧪 Testing and benchmarks +> Shekar, P. & Krishnan, N. *Adaptive Minds: Empowering Agents with LoRA-as-Tools*. arXiv:[2510.15416](https://arxiv.org/abs/2510.15416), Oct 2025. ---- - -## 📖 Documentation - -- **[Quick Start Guide](QUICKSTART.md)** - 5-minute Docker deployment -- **[Development Guide](playground/README.md)** - Training & customization -- **[Adding New LoRAs](ADDING_NEW_LORAS.md)** - Step-by-step training guide -- **[Contributing](CONTRIBUTING.md)** - Contribution guidelines -- **[Examples](examples/README.md)** - Usage examples - ---- - -## 📊 Performance - -- **Response Time**: ~2-5 seconds per query (depending on complexity) -- **Memory Usage**: ~15-20GB GPU VRAM for full system -- **Throughput**: Handles concurrent requests via FastAPI -- **Accuracy**: AI routing achieves >90% domain selection accuracy -- **Training Time**: ~30 minutes per LoRA on single GPU - ---- - -## 📜 License - -This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. - ---- - -## 🙏 Acknowledgments - -- **Meta** - Llama 3.1 base model -- **HuggingFace** - Model hosting and transformers library -- **LangGraph** - Multi-agent workflow framework -- **Unsloth** - Fast and efficient LoRA training -- **PEFT** - Parameter-efficient fine-tuning -- **Community** - Datasets, feedback, and contributions - ---- - -## 📬 Contact & Support - -- **Issues**: [GitHub Issues](https://github.com/qpiai/adaptive-minds/issues) -- **Discussions**: [GitHub Discussions](https://github.com/qpiai/adaptive-minds/discussions) -- **HuggingFace**: [Model Collection](https://huggingface.co/collections/pavan01729/adaptive-minds-68cbab3565664604be49a462) - - - - +## 🙏 Acknowledgements ---- +Adaptive Minds stands on the shoulders of wonderful open source — [vLLM](https://github.com/vllm-project/vllm) for multi-LoRA serving, [Hugging Face](https://huggingface.co/) for the model + adapter hub, [LangGraph](https://github.com/langchain-ai/langgraph) for the state-graph mode, and Next.js + Tailwind for the UI. Thank you to everyone building these. And thank *you* for taking the time to look at this project — if it's useful to you, we'd genuinely love to hear about it. -**Star ⭐ this repository if you find it useful!** +## 📄 License -**Built with ❤️ for the open source community** +Apache 2.0. See [`LICENSE`](LICENSE). diff --git a/adaptive_minds/__init__.py b/adaptive_minds/__init__.py new file mode 100644 index 0000000..43d9339 --- /dev/null +++ b/adaptive_minds/__init__.py @@ -0,0 +1,33 @@ +"""Adaptive Minds — LoRA adapters as callable tools for one base model. + +Paper: *Adaptive Minds: Empowering Agents with LoRA-as-Tools* + Shekar & Krishnan, Oct 2025. arXiv:2510.15416 + https://arxiv.org/abs/2510.15416 + +Two control flows share a single base model + adapter library: + + run_router(query, catalog, cfg, ...) # one LLM call to pick + one to answer + run_agent(query, catalog, cfg, ...) # multi-step ReAct: tools + experts + +Both expect a vLLM server with LoRA support at ``VLLM_BASE`` (default +``http://localhost:8000/v1``) serving the adapters by name. +""" +from __future__ import annotations + +from .agent import run_agent +from .catalog import load_catalog, load_config, vllm_lora_args +from .common import Adapter, vllm_chat +from .router import keyword_pick, run_router + +__version__ = "0.1.0" + +__all__ = [ + "Adapter", + "load_catalog", + "load_config", + "vllm_lora_args", + "vllm_chat", + "run_router", + "run_agent", + "keyword_pick", +] diff --git a/adaptive_minds/agent.py b/adaptive_minds/agent.py new file mode 100644 index 0000000..b94f9db --- /dev/null +++ b/adaptive_minds/agent.py @@ -0,0 +1,284 @@ +"""Multi-step ReAct agent — adapters and external tools share one call surface. + +Paper reference: §5.4 "Agent Loop". The base model emits CALL/FINAL lines; +the runtime executes every CALL (tool or LoRA adapter) and feeds the +batched OBSERVATION block back next turn. A FINAL terminates the loop. + +Design choices worth knowing about: + + (1) Stop tokens that match the desired output kill the output. Earlier + revisions used stop=["\\nCALL:", "\\nFINAL:"] which silently dropped + the brain's first verb line. We stop only on hallucinated boundaries + (OBSERVATION:, "User query:"). + + (2) The brain may emit multiple CALLs per turn for independent sub-tasks. + The runtime executes them in declaration order; all observations are + batched and returned in a single OBSERVATION block. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass + +from .common import Adapter, resolve_sysp, vllm_chat +from .tools import TOOL_DESCRIPTIONS, run_tool + + +DEFAULT_AGENT_PROMPT = """\ +You are the Adaptive Minds agent. You answer the user by orchestrating +tools and domain experts. + +== output format == +You write THOUGHT lines and ACTION lines. Two kinds of actions: + + CALL: | run a tool or consult an expert + FINAL: emit the final answer for the user + +You MAY emit multiple `CALL:` lines in a single response when the sub-tasks +are independent — the runtime executes them in order and returns all +observations back in one block. End with `FINAL:` when ready. + +Example (multi-call planning): + + THOUGHT: independent sub-tasks; I'll fan them out. + CALL: mermaid | sequenceDiagram\\n A->>B: ping\\n B->>A: pong + CALL: chemistry | Mechanism of action for tirzepatide. + CALL: shell | wc -l x.v | awk '$1 > 80 {{print "too long"}}' + +== rules == + 1. Arguments are plain text by default. Only `pulp` requires JSON. + 2. Do NOT write `OBSERVATION:` — the runtime appends it. + 3. Prefer fewer turns: batch independent CALLs together. + 4. Don't ask the same expert/tool the same question twice. + +== capabilities == + +[tools] — deterministic; runtime EXECUTES the argument. +{tool_menu} + +[experts] — LoRA fine-tunes on the base model; they GENERATE domain text. +{expert_menu} + +User query: {query} +""" + + +_VERB_RE = re.compile(r"^\s*(CALL|FINAL)\s*:\s*(.*)$", re.IGNORECASE) + + +@dataclass +class _Action: + kind: str # "CALL" or "FINAL" + name: str # tool / expert id (empty for FINAL) + arg: str # plain text or JSON + + +def _parse_actions(text: str) -> list[_Action]: + """Walk the response line-by-line. Each CALL/FINAL line starts an + action; its argument extends through subsequent lines until the + next verb line (CALL/FINAL/OBSERVATION) or EOF.""" + lines = text.splitlines() + actions: list[_Action] = [] + i = 0 + while i < len(lines): + m = _VERB_RE.match(lines[i]) + if not m: + i += 1 + continue + kind = m.group(1).upper() + payload = m.group(2) + j = i + 1 + while j < len(lines): + s = lines[j].lstrip().upper() + if (s.startswith("CALL:") or s.startswith("FINAL:") + or s.startswith("OBSERVATION:")): + break + j += 1 + if j > i + 1: + payload = payload + "\n" + "\n".join(lines[i + 1:j]) + payload = payload.rstrip() + if kind == "FINAL": + actions.append(_Action("FINAL", "", payload.strip())) + else: + head, _, arg = payload.partition("|") + actions.append(_Action("CALL", head.strip().lower(), arg.strip())) + i = j + return actions + + +def _build_menus(catalog: dict[str, Adapter]) -> tuple[str, str]: + tool_menu = "\n".join( + f" - {name:<10s} — {desc}" for name, desc in TOOL_DESCRIPTIONS.items() + ) + expert_menu = "\n".join( + f" - {a.id:<24s} — {(a.description or '').strip()[:120]}" + for a in sorted(catalog.values(), key=lambda x: x.id) + ) + return tool_menu, expert_menu + + +def _dispatch_call(name: str, arg: str, + catalog: dict[str, Adapter], + sys_prompt_mode: str) -> tuple[str, dict]: + if name in TOOL_DESCRIPTIONS: + obs = run_tool(name, arg) + return obs, {"label": f"CALL [tool] {name}", + "kind": "tool", "name": name, "argument": arg, + "observation": obs} + if name in catalog: + adapter = catalog[name] + sysp = resolve_sysp(sys_prompt_mode, adapter) + sub_arg = arg + if arg.startswith("{"): + try: + obj = json.loads(arg) + for k in ("query", "question", "input", "prompt", "text", + "description", "spec"): + if isinstance(obj.get(k), str): + sub_arg = obj[k] + break + except Exception: + pass + msgs = [] + if sysp: + msgs.append({"role": "system", "content": sysp}) + msgs.append({"role": "user", "content": sub_arg}) + rr = vllm_chat(name, msgs, temperature=0.3, max_tokens=800) + obs = rr.get("response") or rr.get("error") or "(empty)" + return obs, {"label": f"CALL [expert] {name}", "kind": "expert", + "name": name, "argument": arg, "sub_query": sub_arg, + "observation": obs, + "request_body": rr.get("request_body"), + "response_body": rr.get("response_body"), + "elapsed": rr.get("elapsed")} + valid_t = ", ".join(TOOL_DESCRIPTIONS) + valid_e = ", ".join(sorted(catalog.keys())) + obs = f"unknown name '{name}'. Tools: {valid_t}. Experts: {valid_e}." + return obs, {"label": f"CALL ?{name}", "kind": "unknown", + "name": name, "argument": arg, "observation": obs} + + +def run_agent(query: str, catalog: dict[str, Adapter], cfg: dict, + temperature: float, max_tokens: int, + sys_prompt_mode: str = "trained") -> dict: + """Run the ReAct loop until FINAL or max_steps exhausted.""" + tool_menu, expert_menu = _build_menus(catalog) + max_steps = int(cfg.get("agent_max_steps", 6)) + brain_model = cfg.get("agent_brain", cfg.get("base_model_id", "base")) + brain_max_tokens = int(cfg.get("agent_brain_max_tokens", 2048)) + sys_prompt = cfg.get("agent_prompt", DEFAULT_AGENT_PROMPT).format( + tool_menu=tool_menu, expert_menu=expert_menu, query=query, + ) + + # Only stop on tokens the brain should never emit itself. + STOPS = ["\nOBSERVATION:", "\nUser query:"] + + trace = "" + steps: list[dict] = [] + + def call_brain(extra_hint: str = ""): + user_msg = ( + f"User query: {query}\n\n" + + (f"Trace so far:\n{trace}\n\n" if trace else "") + + (extra_hint + "\n\n" if extra_hint else "") + + ("Plan your next move. Emit one or more CALL: lines, or a " + "single FINAL: line when ready.") + ) + return vllm_chat( + brain_model, + [{"role": "system", "content": sys_prompt}, + {"role": "user", "content": user_msg}], + temperature=0.2, max_tokens=brain_max_tokens, stop=STOPS, + ) + + final = None + for step_i in range(max_steps): + r = call_brain() + steps.append({ + "label": f"brain[{step_i}]", "adapter": brain_model, + "raw_response": r.get("response"), + "request_body": r.get("request_body"), + "response_body": r.get("response_body"), + "elapsed": r.get("elapsed"), + }) + if not r["ok"]: + return {"ok": False, "mode": "agent", "error": r.get("error"), + "steps": steps, "system_prompt_used": sys_prompt} + + actions = _parse_actions(r.get("response") or "") + if not actions: + r2 = call_brain( + "Your last response had no CALL: or FINAL: line. Emit one " + "or more `CALL: | ` lines, ending with " + "`FINAL: ` only when ready." + ) + steps.append({ + "label": f"brain[{step_i}]* (retry)", "adapter": brain_model, + "raw_response": r2.get("response"), + "elapsed": r2.get("elapsed"), + }) + if not r2["ok"]: + return {"ok": False, "mode": "agent", + "error": r2.get("error"), + "steps": steps, "system_prompt_used": sys_prompt} + actions = _parse_actions(r2.get("response") or "") + r = r2 + if not actions: + steps.append({"label": "parse failure (after retry)", + "raw": (r.get("response") or "")[:400]}) + trace += "OBSERVATION: (last brain response had no action)\n" + continue + + trace += (r.get("response") or "").rstrip() + "\n" + + obs_block: list[str] = [] + for act in actions: + if act.kind == "FINAL": + final = act.arg or r.get("response") or "" + break + obs, meta = _dispatch_call(act.name, act.arg, catalog, + sys_prompt_mode) + steps.append(meta) + obs_block.append(f"OBSERVATION ({act.name}): {obs[:800]}") + + if obs_block: + trace += "\n".join(obs_block) + "\n" + + if final is not None: + break + + if final is None: + commit = vllm_chat( + brain_model, + [{"role": "system", "content": sys_prompt}, + {"role": "user", "content": + f"User query: {query}\n\nTrace so far:\n{trace}\n\n" + "Step budget exhausted. Emit a single `FINAL: …` line that " + "synthesises every observation above into the answer. Do " + "not call any more tools or experts."}], + temperature=0.2, max_tokens=brain_max_tokens, + ) + if commit["ok"]: + acts = _parse_actions(commit["response"] or "") + final_act = next((a for a in acts if a.kind == "FINAL"), None) + final = final_act.arg if final_act else ( + commit["response"] or "(no FINAL after commit pass)") + steps.append({ + "label": "commit FINAL", "adapter": brain_model, + "raw_response": commit["response"], + "request_body": commit.get("request_body"), + "response_body": commit.get("response_body"), + "elapsed": commit.get("elapsed"), + }) + + return { + "ok": True, "mode": f"agent (brain={brain_model})", + "response": final or "(no FINAL emitted)", + "adapter_id": brain_model, + "elapsed": sum(s.get("elapsed") or 0 for s in steps), + "usage": {}, + "request_body": steps[0].get("request_body") if steps else None, + "response_body": steps[-1].get("response_body") if steps else None, + "system_prompt_used": sys_prompt, "steps": steps, + } diff --git a/adaptive_minds/auto.py b/adaptive_minds/auto.py new file mode 100644 index 0000000..44e1b70 --- /dev/null +++ b/adaptive_minds/auto.py @@ -0,0 +1,85 @@ +"""Auto-mode dispatcher: heuristic chooser between Router and Agent. + +Paper §5.5 motivates an entropy-gated classifier; v0.1 ships a cheap +deterministic heuristic so the mode picker is observable without a +secondary LLM call. The decision is returned in the result so the UI can +show *which* mode the dispatcher actually picked. + +Heuristic indicators that a query needs the multi-step Agent: + - explicit math/code/shell operators (``**``, ``%`` formatting, ``|``, + ``$()``, ``\\boxed``, ``=>``) + - "compute X then Y" / "find X and then" — sequential phrasing + - "and explain" / "then summarise" — composition phrasing + - long queries (≥ 30 tokens) — usually compound asks + - multiple sentences + +Everything else routes through the single-step Router. +""" +from __future__ import annotations + +import re + +from .agent import run_agent +from .common import Adapter +from .router import run_router + +_AGENT_INDICATORS = ( + "compute", + "calculate", + "then explain", + "then summarise", + "then summarize", + "and explain", + "and then", + "step by step", + "first ", + "after that", + "afterwards", + " and finally", +) + +_AGENT_OPERATORS = re.compile(r"(\*\*|\\boxed|=>|\$\(|\|\s*\w+|sum\(|integrate\()") + + +def needs_agent(query: str) -> tuple[bool, str]: + """Return (use_agent, reason). ``reason`` is a one-word tag for the UI.""" + q = (query or "").strip() + if not q: + return False, "empty" + + if _AGENT_OPERATORS.search(q): + return True, "operators" + + low = q.lower() + for ind in _AGENT_INDICATORS: + if ind in low: + return True, "sequencing" + + if len(q.split()) >= 30: + return True, "long" + + if q.count(".") >= 2 or q.count("?") >= 2: + return True, "multi-sentence" + + return False, "single-domain" + + +def run_auto(query: str, catalog: dict[str, Adapter], cfg: dict, + temperature: float, max_tokens: int, + sys_prompt_mode: str = "trained") -> dict: + """Decide router vs. agent from the query surface, then run it. + + The chosen mode + the one-word reason are stamped into the result so + the UI can render which path the dispatcher took. + """ + use_agent, reason = needs_agent(query) + if use_agent: + out = run_agent(query, catalog, cfg, temperature, max_tokens, + sys_prompt_mode=sys_prompt_mode) + else: + out = run_router(query, catalog, cfg, temperature, max_tokens, + sys_prompt_mode=sys_prompt_mode) + out["mode"] = f"auto → {'agent' if use_agent else 'router'}" + out["auto_decision"] = {"picked": "agent" if use_agent else "router", + "reason": reason} + return out diff --git a/adaptive_minds/catalog.py b/adaptive_minds/catalog.py new file mode 100644 index 0000000..4eb55ba --- /dev/null +++ b/adaptive_minds/catalog.py @@ -0,0 +1,116 @@ +"""Catalog loader — turn a YAML adapter definition into a usable runtime config. + +Two top-level operations: + + load_config(path) -> dict # full config (base_model, router, agent, ...) + load_catalog(path) -> {id: Adapter} # just the enabled adapter entries + +The companion `vllm_lora_args()` builds the `--lora-modules` arguments you +need when launching `vllm serve` so every catalog entry is reachable by its +lowercase `id` over the OpenAI-compatible /v1/chat/completions endpoint. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from .common import Adapter + + +def _adapter_id(name: str) -> str: + """Normalise display names to vLLM model ids (lowercase, no spaces).""" + return name.strip().lower().replace(" ", "_").replace("-", "_") + + +def _entry_hf_id(entry: dict, hub_repo: str) -> str: + """Resolve an entry's HF Hub identifier. + + Precedence: explicit `hf_id` > `repo/subdir` from top-level + `hf_subdir`. + """ + if entry.get("hf_id"): + return entry["hf_id"] + sub = entry.get("hf_subdir") + if hub_repo and sub: + return f"{hub_repo}/{sub}" + if hub_repo: + return hub_repo + return entry.get("hf_subdir", "") + + +def load_config(path: str | Path) -> dict[str, Any]: + """Load the raw YAML config (base_model, router, agent, lora_adapters, ...).""" + return yaml.safe_load(Path(path).read_text()) + + +def load_catalog(path: str | Path) -> dict[str, Adapter]: + """Read the YAML and return {id: Adapter} for every enabled entry.""" + cfg = load_config(path) + hub_repo = (cfg.get("hub") or {}).get("repo", "") + out: dict[str, Adapter] = {} + for entry in cfg.get("lora_adapters", []): + if not entry.get("enabled", True): + continue + aid = _adapter_id(entry["name"]) + out[aid] = Adapter( + id=aid, + name=entry["name"], + description=entry.get("description", ""), + system_prompt=entry.get("system_prompt", ""), + keywords=[str(k) for k in entry.get("keywords", [])], + hf_id=_entry_hf_id(entry, hub_repo), + ) + return out + + +def vllm_lora_args(path: str | Path) -> list[str]: + """Build the `vllm serve ... --lora-modules name=hf_id ...` arguments. + + The result is a flat argv list ready to splice into a subprocess call. + Returns an empty list if no adapters are enabled. + """ + cat = load_catalog(path) + if not cat: + return [] + modules = [f"{a.id}={a.hf_id}" for a in cat.values() if a.hf_id] + if not modules: + return [] + return ["--enable-lora", "--lora-modules", *modules] + + +def router_cfg(path: str | Path) -> dict[str, Any]: + """Extract the router-shaped config that run_router() expects.""" + cfg = load_config(path) + r = cfg.get("router", {}) + agent = cfg.get("agent") or {} + base = cfg.get("base_model") or {} + base_model_id = base.get("hf_id") or "base" + return { + "router_prompt": r.get("prompt_template", _DEFAULT_ROUTER_PROMPT), + "router_temperature": r.get("temperature", 0.0), + "router_max_tokens": r.get("max_tokens", 16), + "agent_max_steps": agent.get("max_steps", 6), + # `agent.brain` overrides; default is the base model id from the + # catalog so router/agent talk to whatever vLLM serves the base + # under (e.g. "Qwen/Qwen2.5-7B-Instruct"). + "agent_brain": agent.get("brain") if agent.get("brain") not in (None, "base") else base_model_id, + "agent_brain_max_tokens": agent.get("max_tokens", 2048), + "base_model_id": base_model_id, + } + + +_DEFAULT_ROUTER_PROMPT = """\ +Analyze this user query and select the most appropriate domain expert. + +Query: "{query}" + +Available Domain Experts: +{domain_list} + +Instructions: +- Consider the main topic and intent of the query. +- Choose the domain expert that best matches. +- Respond with ONLY the expert's name (no punctuation). + +Selected Expert:""" diff --git a/adaptive_minds/cli.py b/adaptive_minds/cli.py new file mode 100644 index 0000000..26aa411 --- /dev/null +++ b/adaptive_minds/cli.py @@ -0,0 +1,161 @@ +"""Command-line entry point. + + adaptive-minds serve --catalog catalogs/qwen25_30.yaml # vLLM launch helper + adaptive-minds server --catalog catalogs/qwen25_30.yaml # FastAPI server + adaptive-minds route --catalog catalogs/qwen25_30.yaml --query "..." + adaptive-minds agent --catalog catalogs/qwen25_30.yaml --query "..." + adaptive-minds list --catalog catalogs/qwen25_30.yaml + +The chat UI is a Next.js app under `ui/`; bring it up via +`docker compose up -d` (the `ui` service) or `cd ui && npm run dev` for +local development. +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import typer + +from .agent import run_agent +from .catalog import load_catalog, load_config, router_cfg, vllm_lora_args +from .router import run_router + +app = typer.Typer(add_completion=False, help="Adaptive Minds CLI.") + + +def _emit(d: dict, *, pretty: bool) -> None: + if pretty: + typer.echo(json.dumps(d, indent=2, default=str)) + else: + typer.echo(d.get("response") or json.dumps(d, default=str)) + + +@app.command() +def serve( + catalog: Path = typer.Option(..., "--catalog", "-c", exists=True), + base_model: str = typer.Option(None, "--base-model", + help="Override base model (default: from catalog)"), + port: int = typer.Option(8000, "--port"), + host: str = typer.Option("0.0.0.0", "--host"), + extra: list[str] = typer.Option([], "--extra", + help="Extra args passed through to vllm serve"), +) -> None: + """Print (or exec) the vllm serve command for the given catalog. + + Set AM_SERVE=exec to actually launch; otherwise just prints the command + so you can review or pipe it to bash. + """ + cfg = load_config(catalog) + bm = base_model or (cfg.get("base_model") or {}).get("hf_id") + if not bm: + typer.secho("ERROR: no base_model.hf_id in catalog and --base-model " + "not provided", fg="red") + raise typer.Exit(1) + lora_args = vllm_lora_args(catalog) + max_rank = (cfg.get("base_model") or {}).get("max_lora_rank", 64) + argv = [ + "vllm", "serve", bm, + "--host", host, "--port", str(port), + "--max-lora-rank", str(max_rank), + *lora_args, + *extra, + ] + if os.environ.get("AM_SERVE") == "exec": + os.execvp("vllm", argv) + typer.echo(" ".join(argv)) + + +@app.command("list") +def list_adapters( + catalog: Path = typer.Option(..., "--catalog", "-c", exists=True), +) -> None: + """Print one adapter id per line.""" + cat = load_catalog(catalog) + for aid, a in cat.items(): + typer.echo(f"{aid}\t{a.hf_id}\t{a.description[:80]}") + + +@app.command() +def route( + catalog: Path = typer.Option(..., "--catalog", "-c", exists=True), + query: str = typer.Option(..., "--query", "-q"), + temperature: float = typer.Option(0.3, "--temperature"), + max_tokens: int = typer.Option(512, "--max-tokens"), + pretty: bool = typer.Option(False, "--pretty"), +) -> None: + """Single-step routing: base picks an adapter, that adapter answers.""" + cat = load_catalog(catalog) + if not cat: + typer.secho("ERROR: catalog is empty", fg="red") + raise typer.Exit(1) + cfg = router_cfg(catalog) + out = run_router(query, cat, cfg, temperature, max_tokens) + _emit(out, pretty=pretty) + sys.exit(0 if out.get("ok") else 2) + + +@app.command() +def agent( + catalog: Path = typer.Option(..., "--catalog", "-c", exists=True), + query: str = typer.Option(..., "--query", "-q"), + temperature: float = typer.Option(0.3, "--temperature"), + max_tokens: int = typer.Option(1024, "--max-tokens"), + pretty: bool = typer.Option(False, "--pretty"), +) -> None: + """Multi-step ReAct agent: brain plans, tools/experts execute, brain synthesises.""" + cat = load_catalog(catalog) + if not cat: + typer.secho("ERROR: catalog is empty", fg="red") + raise typer.Exit(1) + cfg = router_cfg(catalog) + out = run_agent(query, cat, cfg, temperature, max_tokens) + _emit(out, pretty=pretty) + sys.exit(0 if out.get("ok") else 2) + + +@app.command() +def server( + catalog: Path = typer.Option(..., "--catalog", "-c", exists=True), + host: str = typer.Option("0.0.0.0", "--host"), + port: int = typer.Option(8765, "--port"), + reload: bool = typer.Option(False, "--reload"), +) -> None: + """Run the FastAPI server (router + agent + chat HTTP endpoints).""" + try: + import uvicorn + except ImportError: + typer.secho("ERROR: install the [serve] extra: pip install '.[serve]'", + fg="red") + raise typer.Exit(1) + # Fail fast with a friendly message if the chosen host:port is already + # bound — uvicorn's stock OSError is opaque, and 8765 in docker setups + # collides with the AM_SERVER_PORT override the user may have forgotten. + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind((host if host != "0.0.0.0" else "127.0.0.1", port)) + except OSError: + typer.secho( + f"ERROR: port {port} on {host} is already bound. " + f"Pass --port or set AM_SERVER_PORT in .env (docker compose).", + fg="red") + raise typer.Exit(1) + finally: + s.close() + # Set AM_CATALOG so the imported module configures itself on import + # (covers the --reload case where uvicorn re-imports per file change). + os.environ["AM_CATALOG"] = str(catalog) + from . import server as server_mod + server_mod.configure(catalog) + uvicorn.run( + "adaptive_minds.server:app", + host=host, port=port, reload=reload, log_level="info", + ) + + +if __name__ == "__main__": + app() diff --git a/adaptive_minds/common.py b/adaptive_minds/common.py new file mode 100644 index 0000000..3a5e71f --- /dev/null +++ b/adaptive_minds/common.py @@ -0,0 +1,111 @@ +"""Shared types + vLLM HTTP client used by router, agent, and evals. + +The client targets vLLM's OpenAI-compatible ``/v1/chat/completions`` endpoint. +The ``model`` parameter doubles as the LoRA adapter name when vLLM is launched +with ``--enable-lora --lora-modules name= ...`` so a single endpoint +serves the base model and every adapter in the catalog by name. +""" +from __future__ import annotations + +import os +import time +from dataclasses import dataclass + +import requests + +VLLM_BASE = os.environ.get("VLLM_BASE", "http://localhost:8000/v1") + + +@dataclass(frozen=True) +class Adapter: + """One entry from the catalog YAML, fully resolved. + + ``id`` is the lowercase adapter name passed to vLLM as the ``model`` + field. ``hf_id`` is the full HF Hub path (repo + optional subfolder). + """ + id: str + name: str + description: str + system_prompt: str + keywords: list[str] + hf_id: str = "" + + +# Three ways to scaffold an expert call. Exposed so callers can A/B test the +# LoRA with vs. without its trained scaffold; ``trained`` is the default and +# what every public benchmark in the paper uses. +SYS_PROMPT_MODES = { + "trained": "Use the adapter's trained system prompt (per-adapter; default).", + "generic": "Use a single neutral prompt for every adapter.", + "none": "Send NO system message. Probes the LoRA on its own.", +} + +GENERIC_SYS_PROMPT = ( + "You are a helpful assistant. Answer concisely and accurately." +) + + +def resolve_sysp(mode: str, adapter: Adapter, + override: str | None = None) -> str | None: + """Return the system-prompt string for an expert call, or ``None`` to + skip the system message entirely.""" + if mode == "none": + return None + if mode == "generic": + return GENERIC_SYS_PROMPT + return override if override is not None else (adapter.system_prompt or None) + + +def vllm_chat(model: str, messages: list[dict], + temperature: float, max_tokens: int, + stop: list[str] | None = None) -> dict: + """One POST to ``/v1/chat/completions``, returns a normalised result dict. + + Why a normalised return shape: the agent loop, the router, and the + evaluation harness all need to log request/response pairs verbatim so + failures are reproducible. Wrapping the raw response keeps the call + sites uniform. + """ + body: dict = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + if stop: + body["stop"] = stop + t0 = time.time() + try: + r = requests.post( + f"{VLLM_BASE}/chat/completions", json=body, timeout=180, + ) + except requests.exceptions.ConnectionError: + return { + "ok": False, + "error": (f"vLLM unreachable at {VLLM_BASE}. " + "Run 'docker compose up -d' or set VLLM_BASE."), + "elapsed": time.time() - t0, + "request_body": body, "response_body": None, + } + except Exception as e: + return {"ok": False, "error": str(e), "elapsed": time.time() - t0, + "request_body": body, "response_body": None} + elapsed = time.time() - t0 + try: + j = r.json() + except Exception as e: + return {"ok": False, "error": f"non-JSON response: {e}", + "elapsed": elapsed, "request_body": body, + "response_body": {"raw_text": r.text[:1000]}} + if r.status_code != 200 or "error" in j: + return {"ok": False, "error": j.get("error") or f"HTTP {r.status_code}", + "elapsed": elapsed, "request_body": body, "response_body": j} + return { + "ok": True, + "response": j["choices"][0]["message"]["content"], + "finish_reason": j["choices"][0].get("finish_reason"), + "elapsed": elapsed, + "usage": j.get("usage", {}), + "request_body": body, + "response_body": j, + } diff --git a/adaptive_minds/external_tools.py b/adaptive_minds/external_tools.py new file mode 100644 index 0000000..dbd4ef0 --- /dev/null +++ b/adaptive_minds/external_tools.py @@ -0,0 +1,280 @@ +"""External (non-LoRA) tools for the Adaptive Minds ReAct agent. + +Each handler takes a single string argument and returns a dict +{output, debug, error}. The same calling convention as adapter calls so +the agent doesn't need to learn a different surface. +""" +from __future__ import annotations + +import contextlib +import io +import json +import subprocess +import sys +import time +import traceback +from typing import Any + + +def _result(output: str = "", debug: dict | None = None, + error: str | None = None) -> dict: + return { + "output": (output or "")[:4000], # cap to keep prompts manageable + "debug": debug or {}, + "error": error, + } + + +# Calculator — sympy-based symbolic + numeric math + +def calc_handler(sub_query: str) -> dict: + """Evaluate a math expression with sympy. + + Accepts plain arithmetic ('2+3*4'), symbolic ('solve(x**2-4, x)'), + integrals/derivatives, and unit-free numeric work. + """ + expr = (sub_query or "").strip() + if not expr: + return _result(error="empty expression") + t0 = time.time() + try: + import sympy as sp + from sympy import (Symbol, sympify, simplify, solve, integrate, diff, + limit, Sum, oo, sin, cos, tan, exp, log, sqrt, + pi, E, Matrix, factor, expand, series) + ns = { + "sp": sp, "sympify": sympify, "simplify": simplify, "solve": solve, + "integrate": integrate, "diff": diff, "limit": limit, "Sum": Sum, + "Symbol": Symbol, "Matrix": Matrix, "factor": factor, + "expand": expand, "series": series, + "sin": sin, "cos": cos, "tan": tan, "exp": exp, "log": log, + "sqrt": sqrt, "pi": pi, "E": E, "oo": oo, + "x": Symbol("x"), "y": Symbol("y"), "z": Symbol("z"), + "n": Symbol("n"), "t": Symbol("t"), + "a": Symbol("a"), "b": Symbol("b"), "c": Symbol("c"), + } + try: + value = eval(expr, {"__builtins__": {}}, ns) + except Exception: + value = sympify(expr) + try: + simplified = simplify(value) + except Exception: + simplified = value + try: + numeric = float(simplified) + numeric_str = f" ≈ {numeric:.10g}" + except Exception: + numeric_str = "" + out = f"{simplified}{numeric_str}" + return _result(output=out, debug={ + "engine": "sympy", "expression": expr, + "raw_value": str(value), + "elapsed_ms": int((time.time() - t0) * 1000), + }) + except Exception as e: + return _result(error=f"calculator failed: {e}", + debug={"expression": expr, + "traceback": traceback.format_exc()}) + + +# Python code sandbox — subprocess with timeout + +_CODE_TIMEOUT_SEC = 12 + + +def code_handler(sub_query: str) -> dict: + """Execute a Python snippet in a fresh subprocess and return stdout. + The snippet should print() its results.""" + snippet = sub_query or "" + if not snippet.strip(): + return _result(error="empty snippet") + t0 = time.time() + try: + proc = subprocess.run( + [sys.executable, "-c", snippet], + capture_output=True, text=True, timeout=_CODE_TIMEOUT_SEC, + ) + elapsed_ms = int((time.time() - t0) * 1000) + if proc.returncode != 0: + return _result( + error=f"exit {proc.returncode}", + output=proc.stdout, + debug={"stderr": proc.stderr[-1500:], + "elapsed_ms": elapsed_ms, + "snippet": snippet[:1000]}, + ) + return _result(output=proc.stdout, debug={ + "stderr": proc.stderr[-500:] if proc.stderr else "", + "elapsed_ms": elapsed_ms, "snippet": snippet[:1000], + }) + except subprocess.TimeoutExpired: + return _result(error=f"timeout after {_CODE_TIMEOUT_SEC}s", + debug={"snippet": snippet[:1000]}) + except Exception as e: + return _result(error=f"code execution failed: {e}", + debug={"snippet": snippet[:1000], + "traceback": traceback.format_exc()}) + + +# Shell — execute a bash one-liner in a sandboxed subprocess. + +def shell_handler(sub_query: str) -> dict: + """Run a bash one-liner with a hard 10s timeout. Captures stdout+stderr.""" + cmd = (sub_query or "").strip() + if not cmd: + return _result(error="empty command") + danger = ["rm -rf", "mkfs", "dd if=", ":(){", "> /dev/sd", "shutdown", + "reboot", "halt", "init 0"] + low = cmd.lower() + for d in danger: + if d in low: + return _result(error=f"refused: command contains '{d}'") + t0 = time.time() + try: + p = subprocess.run( + ["bash", "-lc", cmd], capture_output=True, text=True, timeout=10, + ) + except subprocess.TimeoutExpired: + return _result(error="shell command timed out (>10s)") + elapsed_ms = int((time.time() - t0) * 1000) + out = (p.stdout or "")[:4000] + err = (p.stderr or "")[:1000] + parts = [] + if out: + parts.append(out.rstrip()) + if err: + parts.append(f"[stderr]\n{err.rstrip()}") + if p.returncode != 0: + parts.append(f"[exit code {p.returncode}]") + return _result(output="\n".join(parts) or "(no output)", + debug={"elapsed_ms": elapsed_ms, + "returncode": p.returncode}) + + +# DuckDuckGo web search + +def websearch_handler(sub_query: str) -> dict: + """Search the web via DuckDuckGo and return top 5 hits.""" + query = (sub_query or "").strip() + if not query: + return _result(error="empty query") + t0 = time.time() + try: + from ddgs import DDGS + with DDGS() as ddgs: + hits = list(ddgs.text(query, max_results=5)) + elapsed_ms = int((time.time() - t0) * 1000) + if not hits: + return _result(output="(no results)", + debug={"query": query, "elapsed_ms": elapsed_ms}) + lines = [] + for i, h in enumerate(hits, 1): + title = (h.get("title") or "")[:140] + body = (h.get("body") or h.get("snippet") or "")[:280] + url = h.get("href") or h.get("url") or "" + lines.append(f"[{i}] {title}\n {body}\n URL: {url}") + out = "\n".join(lines) + return _result(output=out, debug={ + "query": query, "n_results": len(hits), + "elapsed_ms": elapsed_ms, + "urls": [h.get("href") or h.get("url") for h in hits], + }) + except Exception as e: + return _result(error=f"websearch failed: {e}", + debug={"query": query, + "traceback": traceback.format_exc()}) + + +# PuLP — linear programming + +def pulp_handler(sub_query: str) -> dict: + """Solve a small LP/MIP. JSON spec preferred; falls back to Python snippet.""" + text = (sub_query or "").strip() + if not text: + return _result(error="empty problem") + t0 = time.time() + try: + spec = json.loads(text) + return _solve_lp_from_spec(spec, t0) + except (ValueError, json.JSONDecodeError): + pass + try: + import pulp + ns: dict[str, Any] = { + "pulp": pulp, "LpProblem": pulp.LpProblem, + "LpVariable": pulp.LpVariable, "LpMaximize": pulp.LpMaximize, + "LpMinimize": pulp.LpMinimize, "lpSum": pulp.lpSum, + "PULP_CBC_CMD": pulp.PULP_CBC_CMD, "value": pulp.value, + "__builtins__": __builtins__, + } + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + exec(text, ns) + out = buf.getvalue() + return _result(output=out, debug={ + "mode": "snippet", + "elapsed_ms": int((time.time() - t0) * 1000), + }) + except Exception as e: + return _result(error=f"pulp failed: {e}", + debug={"snippet": text[:1000], + "traceback": traceback.format_exc()}) + + +def _solve_lp_from_spec(spec: dict, t0: float) -> dict: + """Solve an LP described declaratively. + + Spec format: + {"sense": "max"|"min", + "variables": {"x": {"lb":0, "ub":10, "cat":"Continuous"}, ...}, + "objective": {"x": 3, "y": 5}, + "constraints": [{"expr": {"x":1, "y":1}, "op":"<=", "rhs": 4}, ...]} + """ + import pulp + sense_map = {"max": pulp.LpMaximize, "min": pulp.LpMinimize, + "maximize": pulp.LpMaximize, "minimize": pulp.LpMinimize} + sense = sense_map[spec.get("sense", "max").lower()] + prob = pulp.LpProblem("am_lp", sense) + vars_ = {} + for name, vinfo in spec.get("variables", {}).items(): + vars_[name] = pulp.LpVariable( + name, lowBound=vinfo.get("lb"), upBound=vinfo.get("ub"), + cat=vinfo.get("cat", "Continuous"), + ) + obj = spec.get("objective", {}) + prob += pulp.lpSum(coef * vars_[n] for n, coef in obj.items()) + for c in spec.get("constraints", []): + expr = pulp.lpSum(coef * vars_[n] for n, coef in c["expr"].items()) + op = c["op"] + rhs = c["rhs"] + if op == "<=": + prob += (expr <= rhs) + elif op == ">=": + prob += (expr >= rhs) + elif op == "==": + prob += (expr == rhs) + else: + raise ValueError(f"bad op: {op}") + status = prob.solve(pulp.PULP_CBC_CMD(msg=False)) + elapsed_ms = int((time.time() - t0) * 1000) + if pulp.LpStatus[status] != "Optimal": + return _result(error=f"LP status: {pulp.LpStatus[status]}", + debug={"spec": spec, "elapsed_ms": elapsed_ms}) + soln = {n: pulp.value(v) for n, v in vars_.items()} + obj_val = pulp.value(prob.objective) + out = (f"Status: Optimal\nObjective: {obj_val}\n" + f"Variables: " + ", ".join(f"{n}={soln[n]:g}" for n in soln)) + return _result(output=out, debug={ + "mode": "json_spec", "elapsed_ms": elapsed_ms, + "objective_value": obj_val, "solution": soln, + }) + + +EXTERNAL_TOOLS = { + "calculator": calc_handler, + "code": code_handler, + "shell": shell_handler, + "websearch": websearch_handler, + "pulp": pulp_handler, +} diff --git a/adaptive_minds/langgraph_agent.py b/adaptive_minds/langgraph_agent.py new file mode 100644 index 0000000..dafc0cd --- /dev/null +++ b/adaptive_minds/langgraph_agent.py @@ -0,0 +1,200 @@ +"""LangGraph-driven agent loop — same call surface as ``agent.run_agent``. + +This is the explicit-state-graph variant of the ReAct loop in +``agent.py``. The imperative version is one tight ``for`` loop; the +LangGraph version expresses the same control flow as nodes + conditional +edges. They produce equivalent results but the graph form makes the +state transitions inspectable (the UI renders each node visit). + +Nodes: + plan — base model emits CALL/FINAL lines. + dispatch — runtime executes every CALL (tool or LoRA expert). + synthesise — when step budget is exhausted without a FINAL, the + brain is asked once more to synthesise observations. + +Edges: + START → plan + plan → dispatch (if any CALL emitted) + plan → END (if FINAL emitted) + dispatch → plan (if budget remaining) + dispatch → synthesise (budget exhausted) + synthesise → END + +The module imports ``langgraph`` lazily so the rest of the package works +without it installed; only the ``/langgraph`` server endpoint pays. +""" +from __future__ import annotations + +from typing import Any, TypedDict + +from .agent import ( + DEFAULT_AGENT_PROMPT, + _build_menus, + _dispatch_call, + _parse_actions, +) +from .common import Adapter, vllm_chat + + +class _State(TypedDict, total=False): + query: str + sys_prompt: str + brain_model: str + brain_max_tokens: int + sys_prompt_mode: str + catalog: dict[str, Adapter] + trace: str + steps: list[dict[str, Any]] + step_index: int + max_steps: int + last_response: str + final: str | None + + +_STOPS = ["\nOBSERVATION:", "\nUser query:"] + + +def _node_plan(state: _State) -> _State: + """Brain emits the next batch of CALL lines (or a FINAL).""" + trace = state.get("trace", "") + user_msg = ( + f"User query: {state['query']}\n\n" + + (f"Trace so far:\n{trace}\n\n" if trace else "") + + "Plan your next move. Emit one or more CALL: lines, or a single " + "FINAL: line when ready." + ) + r = vllm_chat( + state["brain_model"], + [{"role": "system", "content": state["sys_prompt"]}, + {"role": "user", "content": user_msg}], + temperature=0.2, max_tokens=state["brain_max_tokens"], stop=_STOPS, + ) + state["steps"].append({ + "label": f"plan[{state['step_index']}]", + "kind": "brain", "name": state["brain_model"], + "raw_response": r.get("response"), + "elapsed": r.get("elapsed"), + }) + state["last_response"] = r.get("response") or "" + if not r["ok"]: + state["final"] = r.get("error") or "(plan failed)" + return state + + +def _node_dispatch(state: _State) -> _State: + """Execute every CALL the brain emitted; append OBSERVATIONs to trace.""" + actions = _parse_actions(state["last_response"]) + state["trace"] += state["last_response"].rstrip() + "\n" + obs_block = [] + for act in actions: + if act.kind == "FINAL": + state["final"] = act.arg or state["last_response"] + break + obs, meta = _dispatch_call(act.name, act.arg, state["catalog"], + state["sys_prompt_mode"]) + state["steps"].append(meta) + obs_block.append(f"OBSERVATION ({act.name}): {obs[:800]}") + if obs_block: + state["trace"] += "\n".join(obs_block) + "\n" + state["step_index"] += 1 + return state + + +def _node_synthesise(state: _State) -> _State: + """Step budget hit without a FINAL — make the brain commit.""" + r = vllm_chat( + state["brain_model"], + [{"role": "system", "content": state["sys_prompt"]}, + {"role": "user", "content": + f"User query: {state['query']}\n\nTrace so far:\n{state['trace']}\n\n" + "Step budget exhausted. Emit a single `FINAL: …` line that " + "synthesises every observation above into the answer."}], + temperature=0.2, max_tokens=state["brain_max_tokens"], + ) + state["steps"].append({ + "label": "synthesise FINAL", "kind": "brain", + "name": state["brain_model"], + "raw_response": r.get("response"), "elapsed": r.get("elapsed"), + }) + if r["ok"]: + acts = _parse_actions(r.get("response") or "") + final = next((a.arg for a in acts if a.kind == "FINAL"), None) + state["final"] = final or (r.get("response") or "(no FINAL)") + else: + state["final"] = r.get("error") or "(synthesise failed)" + return state + + +def _route_after_plan(state: _State) -> str: + if state.get("final") is not None: + return "end" + return "dispatch" + + +def _route_after_dispatch(state: _State) -> str: + if state.get("final") is not None: + return "end" + if state["step_index"] >= state["max_steps"]: + return "synthesise" + return "plan" + + +def _build_graph(): + """Construct the StateGraph. Imported lazily so the rest of the + package doesn't require langgraph at import time.""" + from langgraph.graph import END, START, StateGraph + + g = StateGraph(_State) + g.add_node("plan", _node_plan) + g.add_node("dispatch", _node_dispatch) + g.add_node("synthesise", _node_synthesise) + g.add_edge(START, "plan") + g.add_conditional_edges("plan", _route_after_plan, + {"dispatch": "dispatch", "end": END}) + g.add_conditional_edges("dispatch", _route_after_dispatch, + {"plan": "plan", "synthesise": "synthesise", + "end": END}) + g.add_edge("synthesise", END) + return g.compile() + + +_GRAPH = None + + +def run_langgraph_agent(query: str, catalog: dict[str, Adapter], cfg: dict, + temperature: float, max_tokens: int, + sys_prompt_mode: str = "trained") -> dict: + """Same return shape as ``run_agent`` so the server treats them + interchangeably. ``temperature`` is accepted for API symmetry but the + brain runs at temp=0.2 internally to keep planning deterministic.""" + global _GRAPH + if _GRAPH is None: + _GRAPH = _build_graph() + + tool_menu, expert_menu = _build_menus(catalog) + brain_model = cfg.get("agent_brain", cfg.get("base_model_id", "base")) + sys_prompt = cfg.get("agent_prompt", DEFAULT_AGENT_PROMPT).format( + tool_menu=tool_menu, expert_menu=expert_menu, query=query, + ) + state0: _State = { + "query": query, "sys_prompt": sys_prompt, + "brain_model": brain_model, + "brain_max_tokens": int(cfg.get("agent_brain_max_tokens", max_tokens)), + "sys_prompt_mode": sys_prompt_mode, + "catalog": catalog, + "trace": "", "steps": [], + "step_index": 0, + "max_steps": int(cfg.get("agent_max_steps", 6)), + "last_response": "", "final": None, + } + final_state = _GRAPH.invoke(state0) + return { + "ok": True, + "mode": f"langgraph (brain={brain_model})", + "response": final_state.get("final") or "(no FINAL emitted)", + "adapter_id": brain_model, + "elapsed": sum(s.get("elapsed") or 0 for s in final_state["steps"]), + "usage": {}, + "system_prompt_used": sys_prompt, + "steps": final_state["steps"], + } diff --git a/adaptive_minds/router.py b/adaptive_minds/router.py new file mode 100644 index 0000000..f87133c --- /dev/null +++ b/adaptive_minds/router.py @@ -0,0 +1,106 @@ +"""Single-step router — the base model picks one adapter, that adapter answers. + +Paper §5.2 ("From Routing to Agentic Reasoning"); the headline result in +Table 1 — 98.3 % adapter-selection accuracy on a 30-adapter library. + +Two functions are exported: + +* `keyword_pick`: cheap baseline used by evaluations (and as the Router's + fallback when the LLM picks an out-of-catalog id). +* `run_router`: the production router — one LLM call to classify, one + LLM call to the chosen adapter. + +The CLI exposes both modes via ``adaptive_minds.cli``. +""" +from __future__ import annotations + +import time + +from .common import Adapter, resolve_sysp, vllm_chat + + +def keyword_pick(query: str, + catalog: dict[str, Adapter]) -> tuple[str, list[tuple[str, int]]]: + """Cheap baseline: count keyword hits per adapter, pick the highest. + + Falls back to ``general`` if no adapter matches and that id exists, + otherwise the first adapter in the catalog. + """ + q = query.lower() + scores: list[tuple[str, int]] = [] + for aid, a in catalog.items(): + score = sum(1 for k in a.keywords if k.lower() in q) + scores.append((aid, score)) + scores.sort(key=lambda x: x[1], reverse=True) + top = scores[0] if scores else ("", 0) + if top[1] > 0: + return top[0], scores + fallback = "general" if "general" in catalog else next(iter(catalog), "") + return fallback, scores + + +def run_router(query: str, catalog: dict[str, Adapter], cfg: dict, + temperature: float, max_tokens: int, + sys_prompt_mode: str = "trained") -> dict: + """Two calls: (1) base model picks an adapter; (2) that adapter answers. + + Falls back to ``keyword_pick`` if the LLM emits an out-of-catalog id — + happens rarely but the catalog is the source of truth, not the LLM. + """ + domain_list = "\n".join( + f"- {a.id}: {a.description.strip()[:120]}" for a in catalog.values() + ) + router_prompt = cfg["router_prompt"].format( + domain_list=domain_list, query=query, + ) + t0 = time.time() + r1 = vllm_chat( + model=cfg.get("base_model_id", "base"), + messages=[{"role": "user", "content": router_prompt}], + temperature=cfg.get("router_temperature", 0.0), + max_tokens=cfg.get("router_max_tokens", 16), + stop=["\n"], + ) + route_text = (r1.get("response") or "").strip().lower().splitlines() + route_text = route_text[0] if route_text else "" + chosen = route_text.strip("`* ").split()[0] if route_text else "" + if chosen not in catalog: + chosen, _ = keyword_pick(query, catalog) + adapter = catalog[chosen] + + sysp = resolve_sysp(sys_prompt_mode, adapter) + msgs = [] + if sysp: + msgs.append({"role": "system", "content": sysp}) + msgs.append({"role": "user", "content": query}) + r2 = vllm_chat(chosen, msgs, temperature, max_tokens) + total = time.time() - t0 + + return { + "ok": r2["ok"] and r1["ok"], + "mode": f"router / sysp={sys_prompt_mode}", + "adapter_id": chosen, + "response": r2.get("response"), + "elapsed": total, + "usage": r2.get("usage", {}), + "request_body": r2.get("request_body"), + "response_body": r2.get("response_body"), + "error": r2.get("error") or r1.get("error"), + "system_prompt_used": sysp, + "steps": [ + { + "label": "router (base)", "adapter": "base", + "request_body": r1.get("request_body"), + "response_body": r1.get("response_body"), + "elapsed": r1.get("elapsed"), + "raw_response": r1.get("response"), + "decision": chosen, + }, + { + "label": "expert", "adapter": chosen, + "request_body": r2.get("request_body"), + "response_body": r2.get("response_body"), + "elapsed": r2.get("elapsed"), + }, + ], + } diff --git a/adaptive_minds/server.py b/adaptive_minds/server.py new file mode 100644 index 0000000..e82739e --- /dev/null +++ b/adaptive_minds/server.py @@ -0,0 +1,215 @@ +"""Thin FastAPI server around `run_router` and `run_agent`. + +Endpoints: + + GET /health → {ok, vllm_base, n_adapters, catalog} + GET /adapters → [{id, name, description, keywords, hf_id}] + POST /route → router output dict + POST /agent → agent output dict + POST /chat → unified shape used by the Streamlit UI + +The server holds NO model weights. It owns one `catalog` dict at startup +and proxies inference to the vLLM endpoint pointed to by `VLLM_BASE`. +Catalog source is `AM_CATALOG` (env var) or `--catalog` (CLI flag). +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Literal + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +from .agent import run_agent +from .auto import run_auto # noqa: F401 — referenced by _resolve_runner via getattr +from .catalog import load_catalog, router_cfg +from .common import VLLM_BASE +from .router import run_router +from .tools import list_tools + + +# --------------------------------------------------------------------------- +# Application state +# --------------------------------------------------------------------------- + +class _State: + catalog_path: Path | None = None + catalog: dict = {} + cfg: dict = {} + + +_state = _State() + + +def configure(catalog_path: str | Path) -> None: + """Re-load the catalog. Safe to call before/after `app` is created.""" + p = Path(catalog_path) + _state.catalog_path = p + _state.catalog = load_catalog(p) + _state.cfg = router_cfg(p) + + +# Lazy auto-load if AM_CATALOG is set (so `uvicorn adaptive_minds.server:app` +# works without going through the CLI helper). +_env_catalog = os.environ.get("AM_CATALOG") +if _env_catalog: + configure(_env_catalog) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class HealthResponse(BaseModel): + ok: bool + vllm_base: str + n_adapters: int + catalog: str | None + tools: list[str] + + +class AdapterInfo(BaseModel): + id: str + name: str + description: str + keywords: list[str] + hf_id: str + + +class ChatRequest(BaseModel): + query: str = Field(..., min_length=1, max_length=8000) + # The four modes the UI exposes. ``auto`` and ``langgraph`` dispatch to + # the agent or a LangGraph-driven equivalent of it; see + # adaptive_minds.auto and adaptive_minds.langgraph_agent. + mode: Literal["router", "agent", "auto", "langgraph"] = "router" + temperature: float = Field(0.3, ge=0.0, le=2.0) + max_tokens: int = Field(512, ge=1, le=8192) + sys_prompt_mode: Literal["trained", "generic", "none"] = "trained" + + +class RouteRequest(BaseModel): + query: str = Field(..., min_length=1, max_length=8000) + temperature: float = Field(0.3, ge=0.0, le=2.0) + max_tokens: int = Field(512, ge=1, le=8192) + sys_prompt_mode: Literal["trained", "generic", "none"] = "trained" + + +class AgentRequest(BaseModel): + query: str = Field(..., min_length=1, max_length=8000) + temperature: float = Field(0.3, ge=0.0, le=2.0) + max_tokens: int = Field(1024, ge=1, le=8192) + sys_prompt_mode: Literal["trained", "generic", "none"] = "trained" + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastAPI( + title="Adaptive Minds", + description="LoRA adapters as callable tools — router + ReAct agent over vLLM.", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +def _require_catalog() -> None: + if not _state.catalog: + raise HTTPException( + status_code=503, + detail=( + "Catalog not loaded. Start the server with " + "`adaptive-minds server --catalog catalogs/qwen25_30.yaml` " + "or set AM_CATALOG before importing." + ), + ) + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse( + ok=True, + vllm_base=VLLM_BASE, + n_adapters=len(_state.catalog), + catalog=str(_state.catalog_path) if _state.catalog_path else None, + tools=list_tools(), + ) + + +@app.get("/adapters", response_model=list[AdapterInfo]) +def adapters() -> list[AdapterInfo]: + _require_catalog() + return [ + AdapterInfo( + id=a.id, name=a.name, description=a.description, + keywords=list(a.keywords), hf_id=a.hf_id, + ) + for a in _state.catalog.values() + ] + + +@app.post("/route") +def route(req: RouteRequest) -> dict[str, Any]: + _require_catalog() + return run_router( + req.query, _state.catalog, _state.cfg, + temperature=req.temperature, max_tokens=req.max_tokens, + sys_prompt_mode=req.sys_prompt_mode, + ) + + +@app.post("/agent") +def agent(req: AgentRequest) -> dict[str, Any]: + _require_catalog() + return run_agent( + req.query, _state.catalog, _state.cfg, + temperature=req.temperature, max_tokens=req.max_tokens, + sys_prompt_mode=req.sys_prompt_mode, + ) + + +def _resolve_runner(mode: str): + # Look up by module attribute every call so unit tests can monkeypatch + # ``adaptive_minds.server.run_router`` / ``run_agent`` etc. and have + # the patches survive. (A pre-built dict would snapshot the originals.) + import sys + m = sys.modules[__name__] + if mode == "router": + return m.run_router + if mode == "agent": + return m.run_agent + if mode == "auto": + return m.run_auto + if mode == "langgraph": + try: + from .langgraph_agent import run_langgraph_agent + except ImportError as e: + raise HTTPException( + status_code=501, + detail=("langgraph mode requires the [serve] extra. " + f"pip install '.[serve]' — {e}"), + ) from e + return run_langgraph_agent + raise HTTPException(400, f"unknown mode: {mode}") + + +@app.post("/chat") +def chat(req: ChatRequest) -> dict[str, Any]: + _require_catalog() + fn = _resolve_runner(req.mode) + out = fn( + req.query, _state.catalog, _state.cfg, + temperature=req.temperature, max_tokens=req.max_tokens, + sys_prompt_mode=req.sys_prompt_mode, + ) + out["request_mode"] = req.mode + return out diff --git a/adaptive_minds/tools.py b/adaptive_minds/tools.py new file mode 100644 index 0000000..0371bb7 --- /dev/null +++ b/adaptive_minds/tools.py @@ -0,0 +1,65 @@ +"""External-tool registry for the agent. + +Five sandboxed tools defined in `external_tools.py`. Handlers run in the +same process (no subprocess hop) and return a string observation. The +prompt-side help text in `TOOL_DESCRIPTIONS` is rendered into the agent +system prompt. +""" +from __future__ import annotations + +import json + +from .external_tools import EXTERNAL_TOOLS + + +# Help text rendered into the agent system prompt. +TOOL_DESCRIPTIONS = { + "calculator": "math via sympy. arg = a PLAIN expression. e.g. `2**32 + 17` or `solve(x**2-4, x)`", + "code": "run a PYTHON snippet in a 12s sandbox. arg = the snippet; use print() for output.", + "shell": "run a BASH one-liner in a 10s sandbox. arg = the command, e.g. `wc -l file | awk '$1>80'`", + "websearch": "top-5 DuckDuckGo hits. arg = the query string.", + "pulp": "linear program solver. arg = JSON `{\"sense\":\"max\",\"objective\":...,\"constraints\":[...]}`", +} + + +def list_tools() -> list[str]: + return sorted(EXTERNAL_TOOLS.keys()) + + +def _normalise_arg(name: str, arg: str) -> str: + """Brain outputs sometimes wrap the arg in JSON. Unwrap for the simple + tools; let `pulp` keep the full JSON spec since that's its shape.""" + arg = arg.strip() + if not arg or arg[0] not in "{[": + return arg + try: + j = json.loads(arg) + except Exception: + return arg + if isinstance(j, dict): + for k in ("expression", "query", "code", "snippet", + "spec", "specification", "input"): + if k in j and isinstance(j[k], str): + return j[k] + if name == "pulp": + return arg + for v in j.values(): + if isinstance(v, str): + return v + return arg + + +def run_tool(name: str, arg: str) -> str: + """Return the observation string the agent should see. Errors are + returned (not raised) so a broken tool can't break the loop.""" + handler = EXTERNAL_TOOLS.get(name) + if not handler: + return f"ERROR: unknown tool '{name}'. Valid: {', '.join(EXTERNAL_TOOLS)}" + normalised = _normalise_arg(name, arg) + try: + result = handler(normalised) + except Exception as e: + return f"ERROR: tool '{name}' raised: {e}" + if not isinstance(result, dict): + return str(result)[:4000] + return result.get("output") or result.get("error") or "(empty)" diff --git a/assets/adaptive_minds_v2_lite.gif b/assets/adaptive_minds_v2_lite.gif deleted file mode 100644 index 164d7ae..0000000 Binary files a/assets/adaptive_minds_v2_lite.gif and /dev/null differ diff --git a/build/app_frontend.py b/build/app_frontend.py deleted file mode 100644 index fd51998..0000000 --- a/build/app_frontend.py +++ /dev/null @@ -1,167 +0,0 @@ -import streamlit as st -import requests -import json -from typing import Dict, Any - -# Page configuration -st.set_page_config( - page_title="Adaptive Minds", - page_icon="🧠", - layout="wide" -) - -st.title("🧠 Adaptive Minds") -st.markdown("*LangGraph-powered multi-agent system with intelligent routing*") - -# Configuration -API_BASE_URL = "http://localhost:8765" - -# Initialize session state -if 'chat_history' not in st.session_state: - st.session_state.chat_history = [] -if 'api_status' not in st.session_state: - st.session_state.api_status = None - -def check_api_status() -> Dict[str, Any]: - """Check if the FastAPI server is running and get status.""" - try: - response = requests.get(f"{API_BASE_URL}/status", timeout=5) - if response.status_code == 200: - return {"connected": True, "data": response.json()} - else: - return {"connected": False, "error": f"HTTP {response.status_code}"} - except requests.exceptions.RequestException as e: - return {"connected": False, "error": str(e)} - -def send_chat_message(query: str) -> Dict[str, Any]: - """Send a chat message to the FastAPI server.""" - try: - payload = {"query": query} - response = requests.post( - f"{API_BASE_URL}/chat", - json=payload, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - - if response.status_code == 200: - return {"success": True, "data": response.json()} - else: - return {"success": False, "error": f"HTTP {response.status_code}: {response.text}"} - except requests.exceptions.RequestException as e: - return {"success": False, "error": str(e)} - -def clear_chat_history(): - """Clear the chat history on the server.""" - try: - response = requests.delete(f"{API_BASE_URL}/chat/history", timeout=5) - return response.status_code == 200 - except requests.exceptions.RequestException: - return False - -# Sidebar for system status -with st.sidebar: - st.header("🔧 System Status") - - # Check API status - if st.button("🔄 Refresh Status"): - st.session_state.api_status = check_api_status() - st.rerun() - - if st.session_state.api_status is None: - st.session_state.api_status = check_api_status() - - status = st.session_state.api_status - - if status["connected"]: - st.success("✅ Connected to FastAPI server") - - # Display system info - data = status["data"] - st.subheader("📊 System Info") - st.write(f"**Initialized**: {'✅' if data['is_initialized'] else '❌'}") - st.write(f"**Loaded Adapters**: {len(data['loaded_adapters'])}") - - if data['loaded_adapters']: - st.write("**Adapters**:") - for adapter in data['loaded_adapters']: - st.write(f" • {adapter}") - - if data.get('gpu_memory_allocated'): - st.write(f"**GPU Memory**: {data['gpu_memory_allocated']:.1f}GB") - - # Clear chat history button - if st.button("🗑️ Clear Chat History"): - if clear_chat_history(): - st.session_state.chat_history = [] - st.success("Chat history cleared!") - st.rerun() - else: - st.error("Failed to clear chat history") - else: - st.error("❌ Cannot connect to FastAPI server") - st.write(f"**Error**: {status['error']}") - st.write(f"**Expected URL**: {API_BASE_URL}") - st.write("Make sure the FastAPI server is running on port 8765") - -# Display chat history -for message in st.session_state.chat_history: - if message["role"] == "user": - with st.chat_message("user"): - st.write(message["content"]) - elif message["role"] == "assistant": - with st.chat_message("assistant"): - st.write(f"🧠 **{message['adapter']} via Adaptive Minds**") - st.write(message["content"]) - elif message["role"] == "reasoning": - with st.expander("🔍 Router Agent Decision", expanded=False): - st.markdown(message["content"]) - -# Chat input -if prompt := st.chat_input("Ask me anything..."): - # Check if API is connected - if not st.session_state.api_status or not st.session_state.api_status["connected"]: - st.error("❌ Cannot connect to FastAPI server. Please check the server status in the sidebar.") - st.stop() - - # Add user message to chat history - st.session_state.chat_history.append({"role": "user", "content": prompt}) - - # Display user message - with st.chat_message("user"): - st.write(prompt) - - # Send to API and get response - with st.spinner("🤔 Processing your query..."): - result = send_chat_message(prompt) - - if result["success"]: - data = result["data"] - - # Display reasoning - with st.expander("🔍 Router Agent Decision", expanded=False): - st.markdown(data["reasoning"]) - - # Add reasoning to chat history - st.session_state.chat_history.append({ - "role": "reasoning", - "content": data["reasoning"] - }) - - # Display assistant response - with st.chat_message("assistant"): - st.write(f"🧠 **{data['selected_adapter']} via Adaptive Minds**") - st.write(data["response"]) - - # Add assistant response to chat history - st.session_state.chat_history.append({ - "role": "assistant", - "content": data["response"], - "adapter": data["selected_adapter"] - }) - else: - st.error(f"❌ Error: {result['error']}") - -# Footer -st.markdown("---") -st.markdown(f"*Connected to FastAPI server at {API_BASE_URL}*") diff --git a/build/app_standalone.py b/build/app_standalone.py deleted file mode 100644 index b80c8c6..0000000 --- a/build/app_standalone.py +++ /dev/null @@ -1,496 +0,0 @@ -import streamlit as st -import torch -from transformers import AutoTokenizer, AutoModelForCausalLM -from peft import PeftModel -import os -import gc -from typing import TypedDict, Annotated, Literal, List -from langgraph.graph import StateGraph, START, END -from langgraph.types import Command -import operator - -# Page configuration -st.set_page_config( - page_title="Adaptive Minds", - page_icon="🧠", - layout="wide" -) - -st.title("🧠 Adaptive Minds") -st.markdown("*LangGraph-powered multi-agent system with intelligent routing*") - -# Define the state that will be passed between agents -class AgentState(TypedDict): - query: str - selected_adapter: str - response: str - reasoning: str - messages: Annotated[List[dict], operator.add] - conversation_history: List[dict] - -# Initialize session state for Streamlit -if 'base_model' not in st.session_state: - st.session_state.base_model = None -if 'tokenizer' not in st.session_state: - st.session_state.tokenizer = None -if 'loaded_adapters' not in st.session_state: - st.session_state.loaded_adapters = {} -if 'workflow' not in st.session_state: - st.session_state.workflow = None -if 'chat_history' not in st.session_state: - st.session_state.chat_history = [] - -# Enhanced LoRA adapter configurations with General adapter -LORA_ADAPTERS = { - "General": { - "path": "/app/loras/llama-8B-alpaca-2k", - "description": "General purpose assistant for everyday conversations and casual chat", - "system_prompt": "You are a helpful, friendly assistant. Provide clear, conversational responses to any topic while being engaging and informative." - }, - "Chemistry": { - "path": "/app/loras/llama-8B-chemistry", - "description": "Chemistry expert for compounds, reactions, and laboratory procedures", - "system_prompt": "You are a chemistry expert. Provide clear, accurate answers about chemistry topics including formulas, reactions, and molecular structures. Always prioritize safety information when relevant." - }, - "Finance": { - "path": "/app/loras/llama-8B-finance-alpaca", - "description": "Finance expert for investments, banking, and economic topics", - "system_prompt": "You are a finance expert. Help with financial concepts, investments, market analysis, and economic topics. Provide practical, actionable financial advice.", - }, - "AI": { - "path": "/app/loras/llama-8B-gpt-ai", - "description": "AI and technology expert for machine learning and programming", - "system_prompt": "You are an AI and machine learning expert. Explain technical concepts, programming, and AI topics clearly. Provide comprehensive explanations with examples when helpful.", - }, - "Medical": { - "path": "/app/loras/llama-8B-medical-alpaca", - "description": "Medical expert for healthcare and health information", - "system_prompt": "You are a medical knowledge expert. Provide informative responses about health and medical topics. Always recommend consulting healthcare professionals for medical advice." - } -} - -@st.cache_resource -def load_base_model(): - """Load the base model and tokenizer.""" - base_model_path = "/app/base_model/llama-3.1-8B-instruct" - - try: - tokenizer = AutoTokenizer.from_pretrained(base_model_path) - if tokenizer.pad_token is None: - tokenizer.pad_token = tokenizer.eos_token - - base_model = AutoModelForCausalLM.from_pretrained( - base_model_path, - torch_dtype=torch.float16, - device_map="auto", - trust_remote_code=True, - low_cpu_mem_usage=True - ) - - return base_model, tokenizer - except Exception as e: - st.error(f"Error loading base model: {e}") - return None, None - -def load_adapter(adapter_name): - """Load a specific LoRA adapter.""" - if adapter_name in st.session_state.loaded_adapters: - return st.session_state.loaded_adapters[adapter_name] - - try: - adapter_path = LORA_ADAPTERS[adapter_name]["path"] - model = PeftModel.from_pretrained(st.session_state.base_model, adapter_path) - model.eval() - st.session_state.loaded_adapters[adapter_name] = model - return model - except Exception as e: - st.error(f"Error loading {adapter_name} adapter: {e}") - return None - -def auto_load_all_adapters(): - """Automatically load all available adapters.""" - success_count = 0 - total_adapters = len(LORA_ADAPTERS) - - progress_bar = st.progress(0) - status_text = st.empty() - - for i, (adapter_name, adapter_info) in enumerate(LORA_ADAPTERS.items()): - status_text.text(f"Loading {adapter_name} adapter...") - progress_bar.progress((i + 1) / total_adapters) - - if adapter_name not in st.session_state.loaded_adapters: - model = load_adapter(adapter_name) - if model: - success_count += 1 - else: - success_count += 1 - - progress_bar.empty() - status_text.empty() - - return success_count, total_adapters - -def get_conversation_history(): - """Get the last 2 user-assistant exchanges from chat history.""" - history = [] - - # Get all user and assistant messages in order - user_messages = [] - assistant_messages = [] - - for message in st.session_state.chat_history: - if message["role"] == "user": - user_messages.append(message["content"]) - elif message["role"] == "assistant": - assistant_messages.append(message["content"]) - - # Pair them up and take the last 2 - pairs = list(zip(user_messages, assistant_messages)) - recent_pairs = pairs[-2:] if len(pairs) >= 2 else pairs - - # Convert to the expected format - for user_msg, assistant_msg in recent_pairs: - history.append({ - "user": user_msg, - "assistant": assistant_msg - }) - - return history - -# AI-Powered Router Agent -def enhanced_router_agent(state: AgentState) -> AgentState: - """ - AI-Powered Router Agent: Uses the base model to intelligently select the best domain expert. - """ - query = state["query"] - - # Initialize reasoning - reasoning_parts = [f"🔍 **Query**: '{query}'"] - - try: - # AI selection prompt - ai_prompt = f"""Analyze this user query and select the most appropriate domain expert to handle it. - -Query: "{query}" - -Available Domain Experts: -1. General - For casual conversation, greetings, general knowledge, everyday questions, and topics that don't fit other categories -2. Chemistry - For chemical compounds, reactions, molecules, laboratory procedures, chemical safety, and chemistry-related questions -3. Finance - For money, investments, stocks, banking, economics, trading, companies, financial planning, and business topics -4. AI - For artificial intelligence, machine learning, programming, algorithms, reinforcement learning, neural networks, and technology topics -5. Medical - For health, diseases, treatments, symptoms, anatomy, medicine, medical advice, and healthcare topics - -Instructions: -- Analyze the query carefully -- Consider the main topic and intent -- Choose the domain expert that best matches the query -- If unsure or the query is general/casual, choose General -- Respond with ONLY the domain name (General, Chemistry, Finance, AI, or Medical) - -Selected Domain:""" - - # Generate AI selection - inputs = st.session_state.tokenizer(ai_prompt, return_tensors="pt", truncation=True, max_length=1024) - inputs = {k: v.to(st.session_state.base_model.device) for k, v in inputs.items()} - - with torch.no_grad(): - outputs = st.session_state.base_model.generate( - **inputs, - max_new_tokens=15, - temperature=0.3, - do_sample=True, - top_p=0.9, - pad_token_id=st.session_state.tokenizer.eos_token_id, - ) - - response = st.session_state.tokenizer.decode(outputs[0], skip_special_tokens=True) - ai_selection = response[len(ai_prompt):].strip() - - # Extract the first word/domain name from AI response - ai_domain = ai_selection.split()[0] if ai_selection.split() else "" - - # Map AI response to valid domains - selected_adapter = None - for adapter_name in LORA_ADAPTERS.keys(): - if adapter_name.lower() in ai_domain.lower(): - selected_adapter = adapter_name - break - - if selected_adapter: - reasoning_parts.extend([ - f"🤖 **AI Selection**: {selected_adapter}", - f"🎯 **AI Response**: '{ai_selection.strip()}'", - f"✅ **Decision**: {selected_adapter} via AI semantic analysis" - ]) - else: - # Fallback to General if AI response is unclear - selected_adapter = "General" - reasoning_parts.extend([ - f"⚠️ **AI Response Unclear**: '{ai_selection}'", - f"🔄 **Fallback**: Using General for safety" - ]) - - return { - **state, - "selected_adapter": selected_adapter, - "reasoning": "\n".join(reasoning_parts), - "conversation_history": get_conversation_history() - } - - except Exception as e: - # Emergency fallback - error_reasoning = f"❌ **Router Error**: {str(e)}\n🔄 **Emergency Fallback**: Using General" - return { - **state, - "selected_adapter": "General", - "reasoning": error_reasoning, - "conversation_history": get_conversation_history() - } - -# Enhanced Expert Agent with conversation history -def enhanced_expert_agent(state: AgentState) -> AgentState: - """ - Enhanced Expert Agent: Uses the selected adapter to generate domain-specific responses - with conversation history and improved prompt formatting. - """ - query = state["query"] - adapter_name = state["selected_adapter"] - conversation_history = state.get("conversation_history", []) - - if adapter_name not in st.session_state.loaded_adapters: - return { - **state, - "response": f"Error: {adapter_name} adapter not loaded. Please initialize the system first." - } - - model = st.session_state.loaded_adapters[adapter_name] - system_prompt = LORA_ADAPTERS[adapter_name]["system_prompt"] - - try: - # Build conversation history context - history_context = "" - if conversation_history: - history_context = "\n\nPrevious conversation:\n" - for i, exchange in enumerate(conversation_history, 1): - history_context += f"User: {exchange['user']}\nAssistant: {exchange['assistant']}\n\n" - - # Enhanced prompt formatting based on adapter type - if adapter_name == "General": - # More conversational format for general chat - response_prompt = f"""<|system|> -{system_prompt}{history_context} -<|user|> -{query} -<|assistant|> -""" - else: - # Professional format for specialized domains - response_prompt = f"""System: {system_prompt}{history_context} - -Query: {query} -Response:""" - - # Tokenize and prepare inputs - inputs = st.session_state.tokenizer(response_prompt, return_tensors="pt", truncation=True, max_length=1024) - inputs = {k: v.to(st.session_state.base_model.device) for k, v in inputs.items()} - - # Generate response with optimized parameters - with torch.no_grad(): - outputs = model.generate( - **inputs, - max_new_tokens=256, - temperature=0.7, - do_sample=True, - top_p=0.9, - repetition_penalty=1.1, - pad_token_id=st.session_state.tokenizer.eos_token_id, - eos_token_id=st.session_state.tokenizer.eos_token_id - ) - - # Decode and clean response - full_response = st.session_state.tokenizer.decode(outputs[0], skip_special_tokens=True) - - # Remove prompt from response - if adapter_name == "General": - if "<|assistant|>" in full_response: - response = full_response.split("<|assistant|>")[-1].strip() - else: - response = full_response[len(response_prompt):].strip() - else: - if "Response:" in full_response: - response = full_response.split("Response:")[-1].strip() - else: - response = full_response[len(response_prompt):].strip() - - # Clean up response - response = response.replace("<|user|>", "").replace("<|system|>", "").strip() - - # Remove any remaining conversation history from response - if "Previous conversation:" in response: - response = response.split("Previous conversation:")[0].strip() - - # Ensure response is not empty - if not response: - response = "I apologize, but I couldn't generate a proper response. Could you please rephrase your question?" - - return { - **state, - "response": response - } - - except Exception as e: - return { - **state, - "response": f"Error generating response: {str(e)}. Please try again." - } - -# Create the workflow -def create_workflow() -> StateGraph: - """Create the LangGraph workflow with enhanced agents.""" - workflow = StateGraph(AgentState) - - # Add nodes - workflow.add_node("router", enhanced_router_agent) - workflow.add_node("expert", enhanced_expert_agent) - - # Add edges - workflow.add_edge(START, "router") - workflow.add_edge("router", "expert") - workflow.add_edge("expert", END) - - return workflow.compile() - -# Sidebar for system initialization -with st.sidebar: - st.header("🔧 System Control") - - # System status - if st.session_state.base_model is None: - st.error("❌ System not initialized") - if st.button("🚀 Initialize System", type="primary"): - with st.spinner("Loading base model and adapters..."): - # Load base model first - base_model, tokenizer = load_base_model() - if base_model and tokenizer: - st.session_state.base_model = base_model - st.session_state.tokenizer = tokenizer - st.success("✅ Base model loaded!") - - # Then load all adapters - success_count, total_adapters = auto_load_all_adapters() - if success_count == total_adapters: - st.success(f"✅ All {total_adapters} adapters loaded!") - else: - st.warning(f"⚠️ {success_count}/{total_adapters} adapters loaded") - - # Initialize workflow - st.session_state.workflow = create_workflow() - st.success("🔗 Workflow initialized!") - - st.rerun() - else: - st.error("❌ Failed to load base model") - else: - st.success("✅ Base model loaded") - - # Adapter loading section - st.subheader("🧩 Adapters") - - # Show loaded adapters - if st.session_state.loaded_adapters: - st.write("**Loaded Adapters:**") - for adapter_name in st.session_state.loaded_adapters.keys(): - st.write(f"✅ {adapter_name}") - - # Initialize workflow - if st.session_state.workflow is None and st.session_state.loaded_adapters: - st.session_state.workflow = create_workflow() - st.success("🔗 Workflow initialized!") - - # System stats - st.subheader("📊 System Stats") - if torch.cuda.is_available(): - memory_allocated = torch.cuda.memory_allocated() / 1024**3 - memory_reserved = torch.cuda.memory_reserved() / 1024**3 - st.write(f"🔥 GPU Memory: {memory_allocated:.1f}GB / {memory_reserved:.1f}GB") - - # Clear chat history - if st.button("🗑️ Clear Chat History"): - st.session_state.chat_history = [] - st.rerun() - -# Main chat interface -st.header("💬 Chat Interface") - -# Display chat history -for message in st.session_state.chat_history: - if message["role"] == "user": - with st.chat_message("user"): - st.write(message["content"]) - elif message["role"] == "assistant": - with st.chat_message("assistant"): - st.write(f"🧠 **{message['adapter']} via Adaptive Minds**") - st.write(message["content"]) - elif message["role"] == "reasoning": - with st.expander("🔍 Router Agent Decision", expanded=False): - st.markdown(message["content"]) - -# Chat input -if prompt := st.chat_input("Ask me anything..."): - # Check if system is ready - if not st.session_state.workflow or not st.session_state.loaded_adapters: - st.error("❌ Please initialize the system first using the sidebar controls.") - st.stop() - - # Add user message to chat history - st.session_state.chat_history.append({"role": "user", "content": prompt}) - - # Display user message - with st.chat_message("user"): - st.write(prompt) - - # Process query through workflow - with st.spinner("🤔 Processing your query..."): - try: - # Create initial state - initial_state = { - "query": prompt, - "selected_adapter": "", - "response": "", - "reasoning": "", - "messages": [], - "conversation_history": [] - } - - # Run workflow - result = st.session_state.workflow.invoke(initial_state) - - # Display reasoning - with st.expander("🔍 Router Agent Decision", expanded=False): - st.markdown(result["reasoning"]) - - # Add reasoning to chat history - st.session_state.chat_history.append({ - "role": "reasoning", - "content": result["reasoning"] - }) - - # Display assistant response - with st.chat_message("assistant"): - st.write(f"🧠 **{result['selected_adapter']} via Adaptive Minds**") - st.write(result["response"]) - - # Add assistant response to chat history - st.session_state.chat_history.append({ - "role": "assistant", - "content": result["response"], - "adapter": result["selected_adapter"] - }) - - except Exception as e: - st.error(f"❌ Error processing query: {str(e)}") - -# Footer -st.markdown("---") -st.markdown("*Powered by LangGraph, LoRA adapters, and Streamlit*") diff --git a/build/download_models.py b/build/download_models.py deleted file mode 100644 index 0f0f170..0000000 --- a/build/download_models.py +++ /dev/null @@ -1,301 +0,0 @@ -#!/usr/bin/env python3 -""" -Hugging Face Model Downloader for Base Models and LoRA Files - -This script downloads base models and LoRA files from Hugging Face Hub. -It supports downloading multiple models and provides flexible configuration options. - -Usage: - python pull_models_hf.py --type base --model llama-3.1-8B-instruct - python pull_models_hf.py --type lora --model llama-8B-medical-alpaca - python pull_models_hf.py --type all - python pull_models_hf.py --type lora --all-loras -""" - -import os -import logging -import sys -import argparse -from huggingface_hub import snapshot_download, login -from typing import List, Dict, Optional - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[logging.StreamHandler(sys.stdout)] -) -logger = logging.getLogger(__name__) - -# Base directory for Docker container -BASE_DIR = "/app" - -# Hugging Face Configuration -HF_TOKEN = os.getenv('HF_TOKEN', None) # Set this for private models - -# Model Configurations with Hugging Face model IDs -MODEL_CONFIGS = { - 'base': { - 'local_path': os.getenv('BASE_MODEL_LOCAL_PATH', os.path.join(BASE_DIR, "base_model")), - 'models': { - 'llama-3.1-8B-instruct': { - 'model_id': 'meta-llama/Llama-3.1-8B-Instruct', - 'local_path': os.path.join(BASE_DIR, "base_model", "llama-3.1-8B-instruct") - } - } - }, - 'lora': { - 'local_path': os.getenv('LORA_MODEL_LOCAL_PATH', os.path.join(BASE_DIR, "loras")), - 'models': { - 'llama-8B-alpaca-2k': { - 'model_id': 'pavan01729/llama-8B-alpaca-2k', # Update with your actual HF model ID - 'local_path': os.path.join(BASE_DIR, "loras", "llama-8B-alpaca-2k") - }, - 'llama-8B-chemistry': { - 'model_id': 'pavan01729/llama-8B-chemistry', # Update with your actual HF model ID - 'local_path': os.path.join(BASE_DIR, "loras", "llama-8B-chemistry") - }, - 'llama-8B-finance-alpaca': { - 'model_id': 'pavan01729/llama-8B-finance-alpaca', # Update with your actual HF model ID - 'local_path': os.path.join(BASE_DIR, "loras", "llama-8B-finance-alpaca") - }, - 'llama-8B-gpt-ai': { - 'model_id': 'pavan01729/llama-8B-gpt-ai', - 'local_path': os.path.join(BASE_DIR, "loras", "llama-8B-gpt-ai") - }, - 'llama-8B-medical-alpaca': { - 'model_id': 'pavan01729/llama-8B-medical-alpaca', - 'local_path': os.path.join(BASE_DIR, "loras", "llama-8B-medical-alpaca") - } - } - } -} - -class HuggingFaceModelDownloader: - def __init__(self): - if HF_TOKEN and HF_TOKEN != "your_huggingface_token_here": - try: - login(token=HF_TOKEN) - logger.info("✅ Logged in to Hugging Face with token") - except Exception as e: - logger.error(f"❌ Failed to login to Hugging Face: {e}") - raise - else: - logger.warning("⚠️ No HF_TOKEN provided. Some models may require authentication.") - logger.info("💡 To access gated models like Llama 3.1, set HF_TOKEN environment variable") - logger.info("💡 Get your token from: https://huggingface.co/settings/tokens") - - def download_model(self, model_type: str, model_name: str) -> bool: - """ - Download a specific model from Hugging Face Hub. - - Args: - model_type: 'base' or 'lora' - model_name: Name of the model to download - - Returns: - bool: True if successful, False otherwise - """ - try: - if model_type not in MODEL_CONFIGS: - logger.error(f"Invalid model type: {model_type}") - return False - - config = MODEL_CONFIGS[model_type] - if model_name not in config['models']: - logger.error(f"Model '{model_name}' not found in {model_type} models") - return False - - model_config = config['models'][model_name] - model_id = model_config['model_id'] - local_path = model_config['local_path'] - - logger.info(f"Starting download of {model_type} model '{model_name}' from Hugging Face") - logger.info(f"Model ID: {model_id}") - logger.info(f"Local path: {local_path}") - - # Create local directory if it doesn't exist - os.makedirs(local_path, exist_ok=True) - - # Download the model - try: - snapshot_download( - repo_id=model_id, - local_dir=local_path, - resume_download=True # Resume if interrupted - ) - except Exception as e: - if "401" in str(e) or "gated" in str(e).lower() or "access" in str(e).lower(): - logger.error(f"❌ Access denied for {model_id}") - logger.error(f"💡 This model requires authentication. Please:") - logger.error(f" 1. Get a token from: https://huggingface.co/settings/tokens") - logger.error(f" 2. Accept the license at: https://huggingface.co/{model_id}") - logger.error(f" 3. Set HF_TOKEN environment variable") - return False - else: - raise - - logger.info(f"Download completed for {model_name}. Files saved to: {local_path}") - return True - - except Exception as e: - logger.error(f"Error downloading {model_type} model '{model_name}': {e}") - return False - - def download_all_models(self, model_type: str) -> Dict[str, bool]: - """ - Download all models of a specific type. - - Args: - model_type: 'base' or 'lora' - - Returns: - Dict[str, bool]: Results for each model - """ - if model_type not in MODEL_CONFIGS: - logger.error(f"Invalid model type: {model_type}") - return {} - - config = MODEL_CONFIGS[model_type] - results = {} - - logger.info(f"Starting download of all {model_type} models...") - - for model_name in config['models']: - logger.info(f"Processing {model_type} model: {model_name}") - results[model_name] = self.download_model(model_type, model_name) - - return results - - def download_all(self) -> Dict[str, Dict[str, bool]]: - """ - Download all base models and LoRA files. - - Returns: - Dict[str, Dict[str, bool]]: Results for all model types - """ - logger.info("Starting download of all models from Hugging Face...") - - results = {} - results['base'] = self.download_all_models('base') - results['lora'] = self.download_all_models('lora') - - return results - -def print_summary(results: Dict[str, bool], model_type: str): - """Print a summary of download results.""" - successful = sum(1 for success in results.values() if success) - total = len(results) - - logger.info(f"\n=== {model_type.upper()} MODELS DOWNLOAD SUMMARY ===") - logger.info(f"Successfully downloaded: {successful}/{total}") - - for model_name, success in results.items(): - status = "✓" if success else "✗" - logger.info(f" {status} {model_name}") - - if successful < total: - logger.warning(f"Failed to download {total - successful} {model_type} model(s)") - -def main(): - parser = argparse.ArgumentParser(description="Download base models and LoRA files from Hugging Face Hub") - parser.add_argument('--type', choices=['base', 'lora', 'all'], - help='Type of models to download') - parser.add_argument('--model', type=str, - help='Specific model name to download') - parser.add_argument('--all-loras', action='store_true', - help='Download all LoRA models') - parser.add_argument('--list-models', action='store_true', - help='List available models') - - args = parser.parse_args() - - # List available models if requested - if args.list_models: - logger.info("=== AVAILABLE MODELS ===") - for model_type, config in MODEL_CONFIGS.items(): - logger.info(f"\n{model_type.upper()} MODELS:") - for model_name, model_config in config['models'].items(): - model_id = model_config['model_id'] - local_path = model_config['local_path'] - logger.info(f" - {model_name} ({model_id}) -> {local_path}") - return - - # Default behavior: download all models if no arguments provided - if not args.type and not args.model and not args.all_loras: - logger.info("No arguments provided. Downloading all models (base + LoRAs) from Hugging Face...") - downloader = HuggingFaceModelDownloader() - try: - results = downloader.download_all() - print_summary(results['base'], 'base') - print_summary(results['lora'], 'lora') - except KeyboardInterrupt: - logger.info("Download interrupted by user") - except Exception as e: - logger.error(f"Unexpected error: {e}") - return - - downloader = HuggingFaceModelDownloader() - - try: - if args.type == 'all': - # Download all models - results = downloader.download_all() - print_summary(results['base'], 'base') - print_summary(results['lora'], 'lora') - - elif args.type == 'base': - if args.model: - # Download specific base model - success = downloader.download_model('base', args.model) - if success: - logger.info(f"Successfully downloaded base model: {args.model}") - else: - logger.error(f"Failed to download base model: {args.model}") - else: - # Download all base models - results = downloader.download_all_models('base') - print_summary(results, 'base') - - elif args.type == 'lora': - if args.model: - # Download specific LoRA model - success = downloader.download_model('lora', args.model) - if success: - logger.info(f"Successfully downloaded LoRA model: {args.model}") - else: - logger.error(f"Failed to download LoRA model: {args.model}") - else: - # Download all LoRA models - results = downloader.download_all_models('lora') - print_summary(results, 'lora') - - elif args.all_loras: - # Download all LoRA models - results = downloader.download_all_models('lora') - print_summary(results, 'lora') - - elif args.model: - # Try to determine model type and download - model_found = False - for model_type, config in MODEL_CONFIGS.items(): - if args.model in config['models']: - success = downloader.download_model(model_type, args.model) - if success: - logger.info(f"Successfully downloaded {model_type} model: {args.model}") - else: - logger.error(f"Failed to download {model_type} model: {args.model}") - model_found = True - break - - if not model_found: - logger.error(f"Model '{args.model}' not found in any model type") - - except KeyboardInterrupt: - logger.info("Download interrupted by user") - except Exception as e: - logger.error(f"Unexpected error: {e}") - -if __name__ == "__main__": - main() - diff --git a/build/metadata.json b/build/metadata.json deleted file mode 100644 index ee2221a..0000000 --- a/build/metadata.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "router_prompt_template": "Analyze this user query and select the most appropriate domain expert to handle it.\n\nQuery: \"{query}\"\n\nAvailable Domain Experts:\n{domain_list}\n\nInstructions:\n- Analyze the query carefully\n- Consider the main topic and intent\n- Choose the domain expert that best matches the query\n- If unsure or the query is general/casual, choose General\n- Respond with ONLY the domain name ({domain_names})\n\nSelected Domain:", - - "adapters": { - "General": { - "path": "/app/loras/llama-8B-alpaca-2k", - "description": "General purpose assistant for everyday conversations and casual chat", - "system_prompt": "You are a helpful, knowledgeable assistant. Provide clear, detailed, and actionable responses. Give concrete examples, step-by-step instructions, and practical advice. Never mention links or URLs. Focus on delivering complete, useful information directly in your response.", - "keywords": ["general", "chat", "conversation", "help", "question", "talk", "discuss", "explain", "tell me", "how are you", "hello", "hi", "greetings", "thanks", "please", "everyday questions"] - }, - "Chemistry": { - "path": "/app/loras/llama-8B-chemistry", - "description": "Chemistry expert for compounds, reactions, and laboratory procedures", - "system_prompt": "You are a chemistry expert. Provide detailed, accurate chemistry information with specific formulas, step-by-step reaction mechanisms, and practical procedures. Include safety information when relevant. Give complete, actionable answers without referring to external resources.", - "keywords": ["chemistry", "chemical", "molecule", "compound", "formula", "acid", "base", "element", "reaction", "periodic table", "organic", "inorganic", "laboratory", "safety", "chemical compounds", "molecular structures"] - }, - "Finance": { - "path": "/app/loras/llama-8B-finance-alpaca", - "description": "Finance expert for investments, banking, and economic topics", - "system_prompt": "You are a finance expert. Provide detailed financial analysis, specific strategies, and actionable advice. Include concrete examples, calculations, and step-by-step guidance. Never mention external resources - deliver complete financial insights directly.", - "keywords": ["finance", "financial", "money", "investment", "stock", "bond", "trading", "banking", "loan", "interest", "economics", "budget", "cryptocurrency", "company", "business", "financial planning"] - }, - "AI": { - "path": "/app/loras/llama-8B-gpt-ai", - "description": "AI and technology expert for machine learning and programming", - "system_prompt": "You are an AI and machine learning expert. Provide detailed, practical code examples and step-by-step explanations. Always include actual working code when asked. Explain technical concepts clearly with concrete examples. Never suggest looking things up elsewhere - provide the complete answer directly.", - "keywords": ["AI", "artificial intelligence", "machine learning", "neural network", "algorithm", "programming", "model", "training", "deep learning", "NLP", "code", "python", "javascript", "technology", "reinforcement learning", "lora", "fine-tuning"] - }, - "Medical": { - "path": "/app/loras/llama-8B-medical-alpaca", - "description": "Medical expert for healthcare and health information", - "system_prompt": "You are a medical knowledge expert. Provide detailed, evidence-based medical information with specific explanations about conditions, treatments, and procedures. Always include a disclaimer to consult healthcare professionals for personal medical advice. Give comprehensive answers without referring to external sources.", - "keywords": ["medical", "medicine", "health", "disease", "treatment", "doctor", "patient", "symptom", "diagnosis", "healthcare", "anatomy", "physiology", "drug", "medical advice"] - } - }, - - "_metadata": { - "version": "1.0.0", - "last_updated": "2025-01-01", - "description": "LoRA adapter configuration for Adaptive Minds", - "instructions": "Add new adapters to the 'adapters' object. Each adapter needs: path, description, system_prompt, and keywords (routing description is auto-generated from keywords)." - } -} diff --git a/build/requirements.txt b/build/requirements.txt deleted file mode 100644 index 8303b01..0000000 --- a/build/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -fastapi>=0.68.0 -pydantic>=1.8.0 -torch>=2.0.0 -transformers>=4.30.0 -peft>=0.5.0 -langgraph>=0.0.10 -uvicorn>=0.15.0 -boto3>=1.26.0 -botocore>=1.29.0 -huggingface_hub>=0.16.0 -streamlit>=1.28.0 \ No newline at end of file diff --git a/build/server.py b/build/server.py deleted file mode 100644 index 67a9ebc..0000000 --- a/build/server.py +++ /dev/null @@ -1,485 +0,0 @@ -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -import torch -from transformers import AutoTokenizer, AutoModelForCausalLM -from peft import PeftModel -import os -import gc -import json -import re -from typing import TypedDict, Annotated, Literal, List, Dict, Optional -from langgraph.graph import StateGraph, START, END -from langgraph.types import Command -import operator -import uvicorn - -app = FastAPI( - title="Adaptive Minds API", - description="LangGraph-powered multi-agent system with intelligent routing", - version="1.0.0" -) - -# Define the state that will be passed between agents -class AgentState(TypedDict): - query: str - selected_adapter: str - response: str - reasoning: str - messages: Annotated[List[dict], operator.add] - conversation_history: List[dict] - -# Global state management -class GlobalState: - base_model = None - tokenizer = None - loaded_adapters = {} - workflow = None - chat_history = [] # Global chat history - adapter_config = None # Loaded from JSON - router_prompt_template = None # Dynamic router prompt - -global_state = GlobalState() - -# ============================================================================ -# LOAD ADAPTER CONFIGURATION FROM JSON -# ============================================================================ -def load_adapter_config(config_path="/app/build/metadata.json"): - """Load LoRA adapter configuration from JSON file.""" - try: - with open(config_path, 'r') as f: - config = json.load(f) - return config - except FileNotFoundError: - raise HTTPException( - status_code=500, - detail=f"Adapter configuration file not found: {config_path}. Please create metadata.json" - ) - except json.JSONDecodeError as e: - raise HTTPException( - status_code=500, - detail=f"Invalid JSON in adapter configuration: {str(e)}" - ) - -def build_router_prompt(query: str, config: dict) -> str: - """Build dynamic router prompt from configuration.""" - adapters = config['adapters'] - template = config['router_prompt_template'] - - # Build domain list with descriptions (auto-generated from keywords) - domain_list_items = [] - for idx, (domain_name, adapter_info) in enumerate(adapters.items(), 1): - # Auto-generate routing description from keywords - keywords = adapter_info.get('keywords', []) - routing_desc = f"For {', '.join(keywords[:8])}" + (" and related topics" if len(keywords) > 8 else "") - - domain_list_items.append( - f"{idx}. {domain_name} - {routing_desc}" - ) - domain_list = "\n".join(domain_list_items) - - # Get domain names for instruction - domain_names = ", ".join(adapters.keys()) - - # Fill in template - prompt = template.format( - query=query, - domain_list=domain_list, - domain_names=domain_names - ) - - return prompt - -# ============================================================================ -# LoRA ADAPTER CONFIGURATION - Now loaded from metadata.json -# ============================================================================ -# To add your own custom LoRA adapter: -# 1. Edit metadata.json -# 2. Add a new entry to the "adapters" object with: -# - Unique domain name (key) -# - path: Path to your adapter directory -# - description: Brief description -# - system_prompt: Defines the expert's role and behavior -# - keywords: List of relevant keywords (routing description auto-generated from these) -# 3. Restart the server - the AI router will automatically recognize it! -# ============================================================================ - -LORA_ADAPTERS = {} # Will be loaded from JSON at startup - -def load_base_model(): - """Load the base model and tokenizer.""" - base_model_path = "/app/base_model/llama-3.1-8B-instruct" - - try: - tokenizer = AutoTokenizer.from_pretrained(base_model_path) - if tokenizer.pad_token is None: - tokenizer.pad_token = tokenizer.eos_token - - base_model = AutoModelForCausalLM.from_pretrained( - base_model_path, - torch_dtype=torch.float16, - device_map="auto", - trust_remote_code=True, - low_cpu_mem_usage=True - ) - - return base_model, tokenizer - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error loading base model: {str(e)}") - -def load_adapter(adapter_name): - """Load a specific LoRA adapter.""" - if adapter_name in global_state.loaded_adapters: - return global_state.loaded_adapters[adapter_name] - - try: - adapter_path = LORA_ADAPTERS[adapter_name]["path"] - model = PeftModel.from_pretrained(global_state.base_model, adapter_path) - model.eval() - global_state.loaded_adapters[adapter_name] = model - return model - except KeyError: - raise HTTPException(status_code=404, detail=f"Adapter '{adapter_name}' not found in configuration") - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error loading {adapter_name} adapter: {str(e)}") - -def get_conversation_history(): - """Get the last 2 user-assistant exchanges from chat history.""" - history = [] - - # Get all user and assistant messages in order - user_messages = [] - assistant_messages = [] - - for message in global_state.chat_history: - if message["role"] == "user": - user_messages.append(message["content"]) - elif message["role"] == "assistant": - assistant_messages.append(message["content"]) - - # Pair them up and take the last 2 - pairs = list(zip(user_messages, assistant_messages)) - recent_pairs = pairs[-2:] if len(pairs) >= 2 else pairs - - # Convert to the expected format - for user_msg, assistant_msg in recent_pairs: - history.append({ - "user": user_msg, - "assistant": assistant_msg - }) - - return history - -def enhanced_router_agent(state: AgentState) -> AgentState: - """ - AI-Powered Router Agent: Uses the base model to intelligently select the best domain expert. - Router prompt is dynamically generated from metadata.json configuration. - """ - query = state["query"] - - # Initialize reasoning - reasoning_parts = [f"🔍 **Query**: '{query}'"] - - try: - # Build dynamic AI selection prompt from configuration - ai_prompt = build_router_prompt(query, global_state.adapter_config) - - # Generate AI selection - inputs = global_state.tokenizer(ai_prompt, return_tensors="pt", truncation=True, max_length=1024) - inputs = {k: v.to(global_state.base_model.device) for k, v in inputs.items()} - - with torch.no_grad(): - outputs = global_state.base_model.generate( - **inputs, - max_new_tokens=15, - temperature=0.3, - do_sample=True, - top_p=0.9, - pad_token_id=global_state.tokenizer.eos_token_id, - ) - - response = global_state.tokenizer.decode(outputs[0], skip_special_tokens=True) - ai_selection = response[len(ai_prompt):].strip() - - # Extract the first word/domain name from AI response - ai_domain = ai_selection.split()[0] if ai_selection.split() else "" - - # Map AI response to valid domains - selected_adapter = None - for adapter_name in LORA_ADAPTERS.keys(): - if adapter_name.lower() in ai_domain.lower(): - selected_adapter = adapter_name - break - - if selected_adapter: - reasoning_parts.extend([ - f"🤖 **AI Selection**: {selected_adapter}", - f"🎯 **AI Response**: '{ai_selection.strip()}'", - f"✅ **Decision**: {selected_adapter} via AI semantic analysis" - ]) - else: - # Fallback to General if AI response is unclear - selected_adapter = "General" - reasoning_parts.extend([ - f"⚠️ **AI Response Unclear**: '{ai_selection}'", - f"🔄 **Fallback**: Using General for safety" - ]) - - return { - **state, - "selected_adapter": selected_adapter, - "reasoning": "\n".join(reasoning_parts), - "conversation_history": state["conversation_history"] - } - - except Exception as e: - # Emergency fallback - error_reasoning = f"❌ **Router Error**: {str(e)}\n🔄 **Emergency Fallback**: Using General" - return { - **state, - "selected_adapter": "General", - "reasoning": error_reasoning, - "conversation_history": state["conversation_history"] - } - -def enhanced_expert_agent(state: AgentState) -> AgentState: - """ - Enhanced Expert Agent: Uses the selected adapter to generate domain-specific responses - with conversation history and improved prompt formatting. - """ - query = state["query"] - adapter_name = state["selected_adapter"] - conversation_history = state["conversation_history"] - - if adapter_name not in global_state.loaded_adapters: - return { - **state, - "response": f"Error: {adapter_name} adapter not loaded. Please initialize the system first." - } - - model = global_state.loaded_adapters[adapter_name] - system_prompt = LORA_ADAPTERS[adapter_name]["system_prompt"] - - try: - # Build conversation history context - history_context = "" - if conversation_history: - history_context = "\n\nPrevious conversation:\n" - for i, exchange in enumerate(conversation_history, 1): - history_context += f"User: {exchange['user']}\nAssistant: {exchange['assistant']}\n\n" - - # Enhanced prompt formatting based on adapter type - if adapter_name == "General": - # More conversational format for general chat - response_prompt = f"""<|system|> -{system_prompt}{history_context} -<|user|> -{query} -<|assistant|> -""" - else: - # Professional format for specialized domains - response_prompt = f"""System: {system_prompt}{history_context} - -Query: {query} -Response:""" - - # Tokenize and prepare inputs - inputs = global_state.tokenizer(response_prompt, return_tensors="pt", truncation=True, max_length=1024) - inputs = {k: v.to(global_state.base_model.device) for k, v in inputs.items()} - - # Generate response with optimized parameters - with torch.no_grad(): - outputs = model.generate( - **inputs, - max_new_tokens=512, - temperature=0.8, - do_sample=True, - top_p=0.92, - top_k=50, - repetition_penalty=1.15, - no_repeat_ngram_size=3, - pad_token_id=global_state.tokenizer.eos_token_id, - eos_token_id=global_state.tokenizer.eos_token_id - ) - - # Decode and clean response - full_response = global_state.tokenizer.decode(outputs[0], skip_special_tokens=True) - - # Remove prompt from response - if adapter_name == "General": - if "<|assistant|>" in full_response: - response = full_response.split("<|assistant|>")[-1].strip() - else: - response = full_response[len(response_prompt):].strip() - else: - if "Response:" in full_response: - response = full_response.split("Response:")[-1].strip() - else: - response = full_response[len(response_prompt):].strip() - - # Clean up response - response = response.replace("<|user|>", "").replace("<|system|>", "").strip() - - # Remove any remaining conversation history from response - if "Previous conversation:" in response: - response = response.split("Previous conversation:")[0].strip() - - # Remove hallucinated links and references - # Remove , [link], or generic link mentions - response = re.sub(r'|<\/link>|\[link\]|\(link\)', '', response, flags=re.IGNORECASE) - response = re.sub(r'|<\/url>|\[url\]|\(url\)', '', response, flags=re.IGNORECASE) - # Remove phrases suggesting external lookup - response = re.sub(r'you can find .{0,50}(on github|in the .{0,20} repo|on the website)', '', response, flags=re.IGNORECASE) - response = re.sub(r'check out .{0,50}(github|repository|website|documentation)', '', response, flags=re.IGNORECASE) - - # Clean up extra whitespace and punctuation - response = re.sub(r'\s+', ' ', response).strip() - response = re.sub(r'\.{2,}', '.', response) - - # Ensure response is not empty - if not response or len(response) < 10: - response = "I apologize, but I couldn't generate a proper response. Could you please rephrase your question?" - - return { - **state, - "response": response - } - - except Exception as e: - return { - **state, - "response": f"Error generating response: {str(e)}. Please try again." - } - -def create_workflow() -> StateGraph: - """Create the LangGraph workflow with enhanced agents.""" - workflow = StateGraph(AgentState) - - # Add nodes - workflow.add_node("router", enhanced_router_agent) - workflow.add_node("expert", enhanced_expert_agent) - - # Add edges - workflow.add_edge(START, "router") - workflow.add_edge("router", "expert") - workflow.add_edge("expert", END) - - return workflow.compile() - -# API Models -class ChatRequest(BaseModel): - query: str - -class ChatResponse(BaseModel): - response: str - selected_adapter: str - reasoning: str - -class SystemStatus(BaseModel): - is_initialized: bool - loaded_adapters: List[str] - gpu_memory_allocated: Optional[float] - gpu_memory_reserved: Optional[float] - -# Add startup event handler -@app.on_event("startup") -async def startup_event(): - """Initialize the system when the server starts.""" - global LORA_ADAPTERS - try: - # Load adapter configuration from JSON - print("📖 Loading adapter configuration from metadata.json...") - config = load_adapter_config() - global_state.adapter_config = config - LORA_ADAPTERS = config['adapters'] - global_state.router_prompt_template = config['router_prompt_template'] - print(f"✅ Loaded {len(LORA_ADAPTERS)} adapter configurations") - - # Load base model - print("🔄 Loading base model...") - base_model, tokenizer = load_base_model() - global_state.base_model = base_model - global_state.tokenizer = tokenizer - print("✅ Base model loaded successfully") - - # Load all adapters - print("🔄 Loading LoRA adapters...") - for adapter_name in LORA_ADAPTERS: - load_adapter(adapter_name) - print(f" ✅ Loaded {adapter_name}") - - # Initialize workflow - global_state.workflow = create_workflow() - - print("\n🎉 ALL SYSTEMS READY!") - print(f"🧠 Available domains: {', '.join(list(global_state.loaded_adapters.keys()))}") - print(f"📊 Configuration version: {config.get('_metadata', {}).get('version', 'N/A')}\n") - - except Exception as e: - print(f"❌ Error initializing system: {str(e)}") - import traceback - traceback.print_exc() - -# API Endpoints -@app.post("/chat", response_model=ChatResponse) -async def chat(request: ChatRequest): - if not global_state.workflow or not global_state.loaded_adapters: - raise HTTPException( - status_code=503, - detail="System initialization failed. Please check server logs and restart the server." - ) - - try: - # Create initial state - initial_state = { - "query": request.query, - "selected_adapter": "", - "response": "", - "reasoning": "", - "messages": [], - "conversation_history": get_conversation_history() - } - - # Run workflow - result = global_state.workflow.invoke(initial_state) - - # Update chat history - global_state.chat_history.extend([ - {"role": "user", "content": request.query}, - {"role": "assistant", "content": result["response"], "adapter": result["selected_adapter"]}, - {"role": "reasoning", "content": result["reasoning"]} - ]) - - return ChatResponse( - response=result["response"], - selected_adapter=result["selected_adapter"], - reasoning=result["reasoning"] - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.get("/status", response_model=SystemStatus) -async def get_status(): - gpu_memory_allocated = None - gpu_memory_reserved = None - - if torch.cuda.is_available(): - gpu_memory_allocated = torch.cuda.memory_allocated() / 1024**3 - gpu_memory_reserved = torch.cuda.memory_reserved() / 1024**3 - - return SystemStatus( - is_initialized=global_state.workflow is not None, - loaded_adapters=list(global_state.loaded_adapters.keys()), - gpu_memory_allocated=gpu_memory_allocated, - gpu_memory_reserved=gpu_memory_reserved - ) - -@app.delete("/chat/history") -async def clear_chat_history(): - """Clear the global chat history.""" - global_state.chat_history = [] - return {"message": "Chat history cleared"} - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8765) \ No newline at end of file diff --git a/build/test_docker.py b/build/test_docker.py deleted file mode 100644 index 35a0322..0000000 --- a/build/test_docker.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -""" -Docker test script for Adaptive Minds -This script tests the basic functionality of the system in a Docker environment. -""" - -import os -import sys -import time -import requests -from typing import Dict, Any - -def test_server_health() -> bool: - """Test if the FastAPI server is responding.""" - try: - response = requests.get("http://localhost:8765/status", timeout=10) - return response.status_code == 200 - except Exception as e: - print(f"❌ Server health check failed: {e}") - return False - -def test_chat_endpoint() -> bool: - """Test the chat endpoint with a simple query.""" - try: - test_query = { - "query": "Hello, how are you?" - } - response = requests.post( - "http://localhost:8765/chat", - json=test_query, - timeout=30 - ) - - if response.status_code == 200: - data = response.json() - print(f"✅ Chat test successful!") - print(f" Selected adapter: {data.get('selected_adapter', 'Unknown')}") - print(f" Response preview: {data.get('response', '')[:100]}...") - return True - else: - print(f"❌ Chat endpoint failed with status {response.status_code}") - return False - - except Exception as e: - print(f"❌ Chat endpoint test failed: {e}") - return False - -def main(): - """Run all Docker tests.""" - print("🧪 Running Adaptive Minds Docker Tests") - print("=" * 50) - - # Wait a bit for server to be ready - print("⏳ Waiting for server to be ready...") - time.sleep(5) - - tests = [ - ("Server Health", test_server_health), - ("Chat Endpoint", test_chat_endpoint), - ] - - passed = 0 - total = len(tests) - - for test_name, test_func in tests: - print(f"\n🔍 Testing {test_name}...") - if test_func(): - passed += 1 - print(f"✅ {test_name} passed") - else: - print(f"❌ {test_name} failed") - - print(f"\n📊 Test Results: {passed}/{total} tests passed") - - if passed == total: - print("🎉 All tests passed! System is working correctly.") - sys.exit(0) - else: - print("⚠️ Some tests failed. Check the logs above.") - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/catalogs/qwen25_30.yaml b/catalogs/qwen25_30.yaml new file mode 100644 index 0000000..dedf014 --- /dev/null +++ b/catalogs/qwen25_30.yaml @@ -0,0 +1,257 @@ +# ============================================================================ +# Adaptive Minds — 30-adapter catalog on Qwen2.5-7B-Instruct +# ============================================================================ +# This is the paper's headline-result configuration (see Table 1, row 2: +# 98.3% router accuracy on a 30-adapter library, 60 queries). +# +# All adapters live at https://huggingface.co/pavan01729/adaptive-minds-loras +# under the `qwen2.5-7b/` subfolder. vLLM serves them by name when launched +# with the `--lora-modules` args generated by: +# +# adaptive-minds serve --catalog catalogs/qwen25_30.yaml +# +# To run a 2-adapter smoke test, disable all but two entries below (set +# `enabled: false`). +# ============================================================================ + +base_model: + hf_id: Qwen/Qwen2.5-7B-Instruct + max_lora_rank: 64 + +hub: + # Adapter weights repo; combined with each entry's `hf_subdir` to form the + # vLLM `--lora-modules name=/` path. + repo: pavan01729/adaptive-minds-loras + +router: + prompt_template: | + Analyze the user query below and select the single best domain expert + from the list. Respond with ONLY the expert's id (lowercase, no + punctuation). + + Query: "{query}" + + Experts: + {domain_list} + + Selected expert: + temperature: 0.0 + max_tokens: 16 + +agent: + brain: base + max_steps: 6 + max_tokens: 2048 + +lora_adapters: + # --- Paper Table 2 specialists (9 benchmarks) ----------------------------- + - name: SQL + hf_subdir: qwen2.5-7b/qwen25_sql_v1 + description: Translate natural-language questions into SQL queries (Spider domain). + system_prompt: You write correct, executable SQL. Use the schema provided. + keywords: [sql, query, select, join, where, group by, spider, schema] + enabled: true + + - name: Cypher + hf_subdir: qwen2.5-7b/qwen25_cypher_v1 + description: Translate natural language into Cypher graph queries (Neo4j). + system_prompt: You write correct Cypher patterns for Neo4j graph queries. + keywords: [cypher, neo4j, graph, match, relationship, node, path] + enabled: true + + - name: Bash + hf_subdir: qwen2.5-7b/qwen25_bash_v1 + description: Translate natural-language requests into bash one-liners (nl2bash). + system_prompt: You write concise, correct bash one-liners. Prefer POSIX-portable forms. + keywords: [bash, shell, command, pipe, grep, awk, sed, find, one-liner] + enabled: true + + - name: SPARQL + hf_subdir: qwen2.5-7b/qwen25_sparql_v1 + description: Translate natural-language questions into SPARQL queries (LC-QuAD). + system_prompt: You write correct SPARQL queries against linked-data endpoints. + keywords: [sparql, rdf, linked data, dbpedia, wikidata, prefix, select where] + enabled: true + + - name: Mermaid + hf_subdir: qwen2.5-7b/qwen25_mermaid_v1 + description: Generate Mermaid sequence/flow/class diagrams from natural-language specs. + system_prompt: You output valid Mermaid diagram source. No prose outside the code block. + keywords: [mermaid, sequence diagram, flowchart, class diagram, sequenceDiagram] + enabled: true + + - name: PII + hf_subdir: qwen2.5-7b/qwen25_pii_v1 + description: Detect and redact personally identifiable information from text. + system_prompt: You identify PII spans and emit redacted text with [LABEL] placeholders. + keywords: [pii, redact, anonymize, mask, ssn, email, phone, personal information] + enabled: true + + - name: Quantum + hf_subdir: qwen2.5-7b/qwen25_quantum_v1_grpo + description: Write Qiskit code for quantum-computing tasks (HumanEval-quantum, GRPO-trained). + system_prompt: You write correct Qiskit programs. Import what you use; prefer Aer. + keywords: [qiskit, quantum, qubit, circuit, gate, measurement, superposition] + enabled: true + + - name: Legal + hf_subdir: qwen2.5-7b/qwen25_legal_v1_grpo + description: Classify legal provisions into LEDGAR labels (3-stage curriculum). + system_prompt: You classify legal provisions into the LEDGAR taxonomy with the integer label only. + keywords: [legal, contract, ledgar, provision, clause, warranty, indemnity] + enabled: true + + - name: Chemistry + hf_subdir: qwen2.5-7b/qwen25_chem_v1_grpo + description: Optimize molecules toward QED on ChEMBL canonical SMILES (GRPO-trained). + system_prompt: You output a single canonical SMILES string. No prose. + keywords: [smiles, molecule, chemistry, chembl, qed, optimization, reaction] + enabled: true + + # --- Routing-library additions (single-domain specialists) ---------------- + - name: Reasoning + hf_subdir: qwen2.5-7b/qwen25_reasoning_v1 + description: General multi-step reasoning trace (MATH-500 / GSM8K style). + system_prompt: You think step-by-step before giving the final answer. + keywords: [reason, think, step by step, math problem, logic, deduction] + enabled: true + + - name: Astrophysics + hf_subdir: qwen2.5-7b/qwen25_astrophysics_v1 + description: Astrophysics expert — stellar physics, cosmology, observational astronomy. + system_prompt: You are an astrophysics expert. Give precise, equation-grounded answers. + keywords: [astrophysics, stellar, galaxy, cosmology, nebula, redshift, telescope] + enabled: true + + - name: Biology + hf_subdir: qwen2.5-7b/qwen25_biology_v1 + description: Biology expert — cell biology, genetics, evolution, physiology. + system_prompt: You are a biology expert. Provide accurate, mechanism-level explanations. + keywords: [biology, cell, gene, dna, protein, evolution, organism, enzyme] + enabled: true + + - name: CaseLaw + hf_subdir: qwen2.5-7b/qwen25_case_law_v1 + description: Case-law expert — common-law reasoning, holdings, IRAC analysis. + system_prompt: You analyze case law using IRAC structure (Issue, Rule, Application, Conclusion). + keywords: [case law, court, ruling, precedent, holding, irac, opinion] + enabled: true + + - name: CTI + hf_subdir: qwen2.5-7b/qwen25_cti_v1_grpo + description: Cyber threat intelligence — IOC extraction, TTP mapping, MITRE ATT&CK. + system_prompt: You extract structured CTI artifacts from incident reports. + keywords: [cti, threat, malware, ioc, mitre, attck, ttp, security, incident] + enabled: true + + - name: Economics + hf_subdir: qwen2.5-7b/qwen25_economics_v1 + description: Economics expert — micro/macro theory, policy analysis, markets. + system_prompt: You are an economics expert. Reason from first principles where possible. + keywords: [economics, market, supply, demand, gdp, inflation, monetary, fiscal] + enabled: true + + - name: Finance + hf_subdir: qwen2.5-7b/qwen25_finance_v1 + description: Finance expert — investing, valuation, accounting, banking. + system_prompt: You are a finance expert. Give concrete, numerate answers. + keywords: [finance, stock, bond, investment, valuation, accounting, banking, portfolio] + enabled: true + + - name: FunctionCall + hf_subdir: qwen2.5-7b/qwen25_funccall_v1 + description: OpenAI-style structured tool/function calls (JSON arguments). + system_prompt: You emit JSON function calls in OpenAI tool-call format. + keywords: [function call, tool call, api, json, invoke, function_call] + enabled: true + + - name: History + hf_subdir: qwen2.5-7b/qwen25_history_v1 + description: History expert — events, periods, historiography, primary sources. + system_prompt: You are a history expert. Cite dates, places, and causal chains precisely. + keywords: [history, century, war, empire, ancient, medieval, revolution, dynasty] + enabled: true + + - name: Marketing + hf_subdir: qwen2.5-7b/qwen25_marketing_v1 + description: Marketing expert — copy, positioning, campaign analysis, channels. + system_prompt: You are a marketing expert. Give actionable, audience-aware advice. + keywords: [marketing, brand, campaign, copy, audience, conversion, funnel, ad] + enabled: true + + - name: Medicine + hf_subdir: qwen2.5-7b/qwen25_medicine_v1 + description: Medicine expert — clinical reasoning, anatomy, pharmacology. + system_prompt: You are a medical knowledge expert. Always recommend professional consultation. + keywords: [medicine, clinical, patient, symptom, diagnosis, treatment, drug, anatomy] + enabled: true + + - name: MusicTheory + hf_subdir: qwen2.5-7b/qwen25_music_theory_v1 + description: Music theory — harmony, voice leading, counterpoint, analysis. + system_prompt: You are a music theory expert. Reference scale-degree and Roman-numeral analysis precisely. + keywords: [music, harmony, chord, scale, counterpoint, key signature, modulation] + enabled: true + + - name: Philosophy + hf_subdir: qwen2.5-7b/qwen25_philosophy_v1 + description: Philosophy expert — ethics, epistemology, metaphysics, logic. + system_prompt: You are a philosophy expert. Reconstruct arguments carefully and identify assumptions. + keywords: [philosophy, ethics, epistemology, metaphysics, kant, hume, argument] + enabled: true + + - name: Psychology + hf_subdir: qwen2.5-7b/qwen25_psychology_v1 + description: Psychology expert — cognitive, clinical, social, developmental. + system_prompt: You are a psychology expert. Cite mechanisms and major findings precisely. + keywords: [psychology, cognitive, behavior, perception, memory, freud, clinical] + enabled: true + + - name: Regex + hf_subdir: qwen2.5-7b/qwen25_regex_v1 + description: Generate correct regular expressions from natural-language descriptions. + system_prompt: You output a single regex pattern. No prose unless asked. + keywords: [regex, pattern, regular expression, match, grep, capture group] + enabled: true + + - name: Sociology + hf_subdir: qwen2.5-7b/qwen25_sociology_v1 + description: Sociology expert — social structures, theory, methodology. + system_prompt: You are a sociology expert. Reference major theorists and empirical findings. + keywords: [sociology, society, social, durkheim, weber, marx, ethnography] + enabled: true + + - name: Theorem + hf_subdir: qwen2.5-7b/qwen25_theorem_v1_grpo + description: Theorem proving — Lean / Coq-style structured proofs (GRPO-trained). + system_prompt: You output formal proof scripts. State each tactic clearly. + keywords: [theorem, proof, lean, coq, tactic, lemma, qed, induction] + enabled: true + + - name: Verilog + hf_subdir: qwen2.5-7b/qwen25_verilog_v1 + description: Verilog / SystemVerilog hardware design — modules, sequential and combinational logic. + system_prompt: You write correct synthesizable Verilog. Always declare reg/wire types. + keywords: [verilog, systemverilog, module, always, hdl, hardware, counter, register] + enabled: true + + - name: DataframeDS + hf_subdir: qwen2.5-7b/qwen25_dataframe_ds_v23_sft + description: Pandas / data-science operations — transformations, joins, aggregations. + system_prompt: You write correct pandas / polars code. Prefer vectorised operations. + keywords: [pandas, dataframe, groupby, merge, csv, polars, dataframe operations] + enabled: true + + - name: JSONSchema + hf_subdir: qwen2.5-7b/qwen25_json_schema_v23_sft + description: Generate JSON Schema definitions from natural-language data descriptions. + system_prompt: You emit valid JSON Schema (draft 2020-12). Output JSON only. + keywords: [json schema, validation, properties, required, draft, openapi schema] + enabled: true + + - name: OpenAPI + hf_subdir: qwen2.5-7b/qwen25_openapi_v23_sft + description: Generate OpenAPI 3.x specifications from natural-language endpoint descriptions. + system_prompt: You emit valid OpenAPI 3.0 YAML. Output YAML only. + keywords: [openapi, swagger, rest api, endpoint, path, schema, get, post] + enabled: true diff --git a/catalogs/qwen25_smoke.yaml b/catalogs/qwen25_smoke.yaml new file mode 100644 index 0000000..bc6f5f7 --- /dev/null +++ b/catalogs/qwen25_smoke.yaml @@ -0,0 +1,45 @@ +# 2-adapter smoke catalog — for `docker compose up -d` smoke tests. +# Pulls only Qwen2.5-7B-Instruct + 2 LoRAs (~1 GB extra), instead of the +# full 30-adapter library (~6 GB). + +base_model: + hf_id: Qwen/Qwen2.5-7B-Instruct + max_lora_rank: 64 + +hub: + repo: pavan01729/adaptive-minds-loras + +router: + prompt_template: | + Analyze the user query below and select the single best domain expert + from the list. Respond with ONLY the expert's id (lowercase, no + punctuation). + + Query: "{query}" + + Experts: + {domain_list} + + Selected expert: + temperature: 0.0 + max_tokens: 16 + +agent: + brain: base + max_steps: 4 + max_tokens: 1024 + +lora_adapters: + - name: Chemistry + hf_subdir: qwen2.5-7b/qwen25_chem_v1_grpo + description: Chemistry expert — compounds, reactions, ChEMBL canonical SMILES. + system_prompt: You are a chemistry expert. Give precise, mechanism-grounded answers. + keywords: [chemistry, molecule, reaction, compound, smiles, formula] + enabled: true + + - name: SQL + hf_subdir: qwen2.5-7b/qwen25_sql_v1 + description: Translate natural-language questions into SQL queries (Spider domain). + system_prompt: You write correct, executable SQL. Use the schema provided. + keywords: [sql, query, select, join, where, group by, spider, schema] + enabled: true diff --git a/docker-compose.yml b/docker-compose.yml index d01dbe9..97a5d74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,64 @@ +# Adaptive Minds — full stack via Docker Compose. +# +# One command: +# docker compose up -d +# open http://localhost:7007 +# +# Defaults to the paper's 30-adapter catalog. First boot is ~15–30 min +# (downloads Qwen2.5-7B + 30 LoRA adapters into named volumes); subsequent +# starts are instant. +# +# How LoRAs are loaded: +# `prefetch` runs first, downloading every adapter in $AM_LORA_SUBDIRS +# into the `lora-cache` volume. Then `vllm` boots with --lora-modules +# pointing at those local paths. +# +# Swap to the 2-adapter smoke catalog (fast first boot, ~5 min): +# in .env set AM_CATALOG=/app/catalogs/qwen25_smoke.yaml +# AM_LORA_SUBDIRS=qwen2.5-7b/qwen25_chem_v1_grpo,qwen2.5-7b/qwen25_sql_v1 +# and trim the `vllm` --lora-modules list below to just chemistry + sql. +# +# Browser → API is proxied via the Next.js `ui` service (`/api/am/* → +# server:8765`). No host-port wiring needed in the browser bundle, so the +# same image works whether you hit localhost, a remote IP, or a reverse +# proxy — no NEXT_PUBLIC_AM_API_BASE override required. + services: - adaptive-lora: - build: - context: . - dockerfile: Dockerfile - container_name: adaptive-minds-server - gpus: all + prefetch: + image: python:3.11-slim + container_name: am-prefetch environment: - - NVIDIA_VISIBLE_DEVICES=all - - NVIDIA_DRIVER_CAPABILITIES=compute,utility - - HF_TOKEN=${HF_TOKEN} + HF_TOKEN: ${HF_TOKEN:-} + HF_HUB_ENABLE_HF_TRANSFER: "1" + LORA_REPO: ${AM_LORA_REPO:-pavan01729/adaptive-minds-loras} + # All 30 paper-catalog adapters. Override AM_LORA_SUBDIRS in .env to + # prefetch a subset (e.g. the smoke pair) and trim --lora-modules + # below to match. + LORA_SUBDIRS: ${AM_LORA_SUBDIRS:-qwen2.5-7b/qwen25_sql_v1,qwen2.5-7b/qwen25_cypher_v1,qwen2.5-7b/qwen25_bash_v1,qwen2.5-7b/qwen25_sparql_v1,qwen2.5-7b/qwen25_mermaid_v1,qwen2.5-7b/qwen25_pii_v1,qwen2.5-7b/qwen25_quantum_v1_grpo,qwen2.5-7b/qwen25_legal_v1_grpo,qwen2.5-7b/qwen25_chem_v1_grpo,qwen2.5-7b/qwen25_reasoning_v1,qwen2.5-7b/qwen25_astrophysics_v1,qwen2.5-7b/qwen25_biology_v1,qwen2.5-7b/qwen25_case_law_v1,qwen2.5-7b/qwen25_cti_v1_grpo,qwen2.5-7b/qwen25_economics_v1,qwen2.5-7b/qwen25_finance_v1,qwen2.5-7b/qwen25_funccall_v1,qwen2.5-7b/qwen25_history_v1,qwen2.5-7b/qwen25_marketing_v1,qwen2.5-7b/qwen25_medicine_v1,qwen2.5-7b/qwen25_music_theory_v1,qwen2.5-7b/qwen25_philosophy_v1,qwen2.5-7b/qwen25_psychology_v1,qwen2.5-7b/qwen25_regex_v1,qwen2.5-7b/qwen25_sociology_v1,qwen2.5-7b/qwen25_theorem_v1_grpo,qwen2.5-7b/qwen25_verilog_v1,qwen2.5-7b/qwen25_dataframe_ds_v23_sft,qwen2.5-7b/qwen25_json_schema_v23_sft,qwen2.5-7b/qwen25_openapi_v23_sft} + LORA_OUT_DIR: /loras volumes: - # Only mount build directory if needed for other purposes - - ./build:/app/build + - lora-cache:/loras + - ./docker/prefetch.py:/prefetch.py:ro + command: + - bash + - -lc + - "pip install --quiet --no-cache-dir 'huggingface_hub>=0.24' hf_transfer && python3 /prefetch.py" + restart: "no" + + vllm: + image: vllm/vllm-openai:v0.6.3 + container_name: am-vllm + restart: unless-stopped ports: - - "8765:8765" # FastAPI default port - - "8501:8501" # Streamlit port + - "${AM_VLLM_PORT:-8000}:8000" + environment: + HF_TOKEN: ${HF_TOKEN:-} + volumes: + - hf-cache:/root/.cache/huggingface + - lora-cache:/loras:ro + depends_on: + prefetch: + condition: service_completed_successfully deploy: resources: reservations: @@ -22,39 +66,88 @@ services: - driver: nvidia count: all capabilities: [gpu] - # Step by step: 1) Download models, 2) Start server, 3) Wait for server, 4) Start UI command: > - sh -c " - echo '📥 STEP 1: Downloading models from Hugging Face...' && - python /app/build/download_models.py && - echo '✅ STEP 1 COMPLETE: All models downloaded!' && - echo '' && - echo '🚀 STEP 2: Starting FastAPI server...' && - cd /app/build && - python server.py & - SERVER_PID=\$! && - echo '✅ STEP 2 COMPLETE: FastAPI server started (PID: '\$SERVER_PID')' && - echo '' && - echo '⏳ STEP 3: Waiting for server to be fully ready...' && - sleep 20 && - attempt=1 && - while ! curl -s http://localhost:8765/status > /dev/null 2>&1; do - echo '⏳ Attempt '\$attempt': Server not ready yet, waiting 30 more seconds...' && - sleep 30 && - attempt=\$((attempt + 1)) && - if [ \$attempt -gt 20 ]; then - echo '❌ Server failed to start after 100 seconds' && - exit 1 - fi - done && - echo '✅ STEP 3 COMPLETE: FastAPI server is ready and responding!' && - echo '' && - echo '🎨 STEP 4: Starting Streamlit frontend...' && - streamlit run /app/build/app_frontend.py --server.port 8501 --server.address 0.0.0.0 & - echo '✅ STEP 4 COMPLETE: Streamlit frontend started!' && - echo '' && - echo '🎉 ALL SYSTEMS READY!' && - echo '📡 FastAPI: http://localhost:8765' && - echo '🌐 Streamlit: http://localhost:8501' && - wait - " \ No newline at end of file + --model Qwen/Qwen2.5-7B-Instruct + --host 0.0.0.0 + --port 8000 + --max-lora-rank 64 + --max-loras 4 + --enable-lora + --lora-modules + sql=/loras/qwen2.5-7b/qwen25_sql_v1 + cypher=/loras/qwen2.5-7b/qwen25_cypher_v1 + bash=/loras/qwen2.5-7b/qwen25_bash_v1 + sparql=/loras/qwen2.5-7b/qwen25_sparql_v1 + mermaid=/loras/qwen2.5-7b/qwen25_mermaid_v1 + pii=/loras/qwen2.5-7b/qwen25_pii_v1 + quantum=/loras/qwen2.5-7b/qwen25_quantum_v1_grpo + legal=/loras/qwen2.5-7b/qwen25_legal_v1_grpo + chemistry=/loras/qwen2.5-7b/qwen25_chem_v1_grpo + reasoning=/loras/qwen2.5-7b/qwen25_reasoning_v1 + astrophysics=/loras/qwen2.5-7b/qwen25_astrophysics_v1 + biology=/loras/qwen2.5-7b/qwen25_biology_v1 + caselaw=/loras/qwen2.5-7b/qwen25_case_law_v1 + cti=/loras/qwen2.5-7b/qwen25_cti_v1_grpo + economics=/loras/qwen2.5-7b/qwen25_economics_v1 + finance=/loras/qwen2.5-7b/qwen25_finance_v1 + functioncall=/loras/qwen2.5-7b/qwen25_funccall_v1 + history=/loras/qwen2.5-7b/qwen25_history_v1 + marketing=/loras/qwen2.5-7b/qwen25_marketing_v1 + medicine=/loras/qwen2.5-7b/qwen25_medicine_v1 + musictheory=/loras/qwen2.5-7b/qwen25_music_theory_v1 + philosophy=/loras/qwen2.5-7b/qwen25_philosophy_v1 + psychology=/loras/qwen2.5-7b/qwen25_psychology_v1 + regex=/loras/qwen2.5-7b/qwen25_regex_v1 + sociology=/loras/qwen2.5-7b/qwen25_sociology_v1 + theorem=/loras/qwen2.5-7b/qwen25_theorem_v1_grpo + verilog=/loras/qwen2.5-7b/qwen25_verilog_v1 + dataframeds=/loras/qwen2.5-7b/qwen25_dataframe_ds_v23_sft + jsonschema=/loras/qwen2.5-7b/qwen25_json_schema_v23_sft + openapi=/loras/qwen2.5-7b/qwen25_openapi_v23_sft + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/v1/models', timeout=3)"] + interval: 15s + timeout: 5s + start_period: 900s # 30-adapter cold start can be long + retries: 3 + + server: + build: + context: . + dockerfile: docker/Dockerfile.server + container_name: am-server + restart: unless-stopped + ports: + # Override AM_SERVER_PORT in your .env if 8765 conflicts on your host. + - "${AM_SERVER_PORT:-8765}:8765" + environment: + VLLM_BASE: ${VLLM_BASE:-http://vllm:8000/v1} + AM_CATALOG: ${AM_CATALOG:-/app/catalogs/qwen25_30.yaml} + depends_on: + vllm: + condition: service_healthy + + ui: + build: + context: . + dockerfile: docker/Dockerfile.ui + args: + # Empty → the UI bundle uses relative paths (`/api/am/*`), and the + # Next.js server proxies them to the `server` container over the + # compose network. Same image works for localhost, public IP, and + # reverse-proxy deployments — no rebuild on host change. + NEXT_PUBLIC_AM_API_BASE: ${NEXT_PUBLIC_AM_API_BASE:-} + NEXT_PUBLIC_DEFAULT_MODE: ${NEXT_PUBLIC_DEFAULT_MODE:-router} + container_name: am-ui + restart: unless-stopped + ports: + - "${AM_UI_PORT:-7007}:7007" + depends_on: + server: + condition: service_healthy + +volumes: + hf-cache: + name: am-hf-cache + lora-cache: + name: am-lora-cache diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server new file mode 100644 index 0000000..1944a3e --- /dev/null +++ b/docker/Dockerfile.server @@ -0,0 +1,32 @@ +# Adaptive Minds — FastAPI server container. +# Thin: no torch / no transformers / no vLLM. Inference is proxied to the +# `vllm` service via VLLM_BASE. + +FROM python:3.11-slim + +LABEL org.opencontainers.image.title="adaptive-minds-server" +LABEL org.opencontainers.image.source="https://github.com/qpiai/adaptive-minds" + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + AM_CATALOG=/app/catalogs/qwen25_30.yaml + +WORKDIR /app + +# Install dependencies first so the layer caches between code edits. +COPY pyproject.toml README.md LICENSE ./ +COPY adaptive_minds ./adaptive_minds +RUN pip install --no-cache-dir -e ".[serve,tools]" + +# Copy catalogs + the package source (already done above for installs). +COPY catalogs ./catalogs + +EXPOSE 8765 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8765/health', timeout=3)" || exit 1 + +# Shell form so $AM_CATALOG is interpolated at runtime — lets compose / +# users override the catalog via env without rebuilding the image. +CMD exec python -m adaptive_minds.cli server \ + --catalog "$AM_CATALOG" --host 0.0.0.0 --port 8765 diff --git a/docker/Dockerfile.ui b/docker/Dockerfile.ui new file mode 100644 index 0000000..3e4e6e7 --- /dev/null +++ b/docker/Dockerfile.ui @@ -0,0 +1,53 @@ +# Multi-stage Node build for the Next.js chat UI (replaces the v0.1 +# Streamlit app shipped at bee4715). The Streamlit deps are no longer +# required to run the UI. +# +# Stage 1 (builder): install deps + `next build` with output:standalone. +# Stage 2 (runner): copy the standalone bundle + minimal node_modules. +# +# Standalone output keeps the runtime image small — the full node_modules +# stays in the builder stage and is dropped from the final image. + +FROM node:20-alpine AS builder +WORKDIR /app + +LABEL org.opencontainers.image.title="adaptive-minds-ui" +LABEL org.opencontainers.image.source="https://github.com/qpiai/adaptive-minds" + +# Install deps from lockfile if present; fall back to a clean install. +COPY ui/package.json ui/package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + +# NEXT_PUBLIC_AM_API_BASE is baked at build time. Empty (the default) → +# the UI uses relative `/api/am/*` paths and the Next.js server proxies +# to the `server` container over the docker network. Set it explicitly +# only to bypass the proxy (e.g. UI hosted separately from FastAPI). +ARG NEXT_PUBLIC_AM_API_BASE= +ENV NEXT_PUBLIC_AM_API_BASE=$NEXT_PUBLIC_AM_API_BASE +ARG NEXT_PUBLIC_DEFAULT_MODE=router +ENV NEXT_PUBLIC_DEFAULT_MODE=$NEXT_PUBLIC_DEFAULT_MODE + +COPY ui/ ./ +RUN npm run build + +# --------------------------------------------------------------------------- +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=7007 \ + HOSTNAME=0.0.0.0 + +# Standalone output: .next/standalone/ has the minimal server.js + needed +# node_modules; .next/static/ ships separately. +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +EXPOSE 7007 + +HEALTHCHECK --interval=15s --timeout=4s --start-period=20s --retries=3 \ + CMD wget -q -O- http://localhost:7007 > /dev/null || exit 1 + +CMD ["node", "server.js"] diff --git a/docker/prefetch.py b/docker/prefetch.py new file mode 100644 index 0000000..f035f22 --- /dev/null +++ b/docker/prefetch.py @@ -0,0 +1,42 @@ +"""Download the LoRA subdirs listed in $LORA_SUBDIRS into /loras. + +Used by the `prefetch` sidecar in docker-compose.yml so the `vllm` +service can mount /loras read-only and pass local paths to +`--lora-modules`. Idempotent — re-running over an already-populated +cache is a no-op. +""" +import os +import sys + +from huggingface_hub import snapshot_download + + +def main() -> int: + repo = os.environ.get("LORA_REPO", "pavan01729/adaptive-minds-loras") + subdirs = [s.strip() for s in os.environ.get("LORA_SUBDIRS", "").split(",") if s.strip()] + out_dir = os.environ.get("LORA_OUT_DIR", "/loras") + + if not subdirs: + print("prefetch: $LORA_SUBDIRS is empty; nothing to do", flush=True) + return 0 + + # Only the actual LoRA weights — skip tokenizer files so they don't + # override the base model's chat template at serving time. + for sub in subdirs: + print(f"prefetch: {repo}/{sub} → {out_dir}/{sub}", flush=True) + snapshot_download( + repo, + allow_patterns=[ + f"{sub}/adapter_config.json", + f"{sub}/adapter_model.safetensors", + ], + local_dir=out_dir, + ) + + print(f"prefetch: done ({len(subdirs)} adapter{'s' if len(subdirs) != 1 else ''})", + flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e8d3a36 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,209 @@ +# Architecture + +## Operating modes + +The framework exposes one base model + a library of LoRA adapters through +four operating modes. Routing and agentic reasoning are two regimes of the +*same* system, not separate codepaths. + +**🎯 Router** — one base-model call selects an expert from adapter metadata; +that adapter answers. (§5.2) + +
Router: query → router agent → expert adapter → output
+ +**🤖 Agent** — a THOUGHT → CALL → OBSERVATION → FINAL loop that invokes LoRA +experts *and* external tools across steps, then synthesises. (§5.4) + +
Agent: Think → Select Tool → Observe → Iterate over a tool registry, then synthesise
+ +**🪄 Auto** — a cheap classifier picks Router vs. Agent per query and stamps +the decision into the result. **🕸️ LangGraph** — the agent loop as a +`StateGraph`. Both are shown in the unified diagram below. (§5.5) + +
Unified modes: classifier dispatches to single-step routing or a multi-step agent over a shared tool registry
+ +## Modules + +The runtime is intentionally thin — every public function has a docstring +that says *why* it exists: + +| File | Paper section | Purpose | +|----------------------------------------|---------------|---------| +| `adaptive_minds/router.py` | §5.2 | Single-step routing | +| `adaptive_minds/agent.py` | §5.4 | Multi-step ReAct loop | +| `adaptive_minds/auto.py` | §5.5 | Router-vs-agent dispatcher (heuristic) | +| `adaptive_minds/langgraph_agent.py` | §5.4 | Agent loop as a `langgraph.StateGraph` | +| `adaptive_minds/common.py` | §5.3 | vLLM HTTP client + `Adapter` dataclass | +| `adaptive_minds/catalog.py` | §4 + §5.3 | YAML loader + `vllm serve` arg builder | +| `adaptive_minds/tools.py` | §5.4 | External tool registry (5 tools) | +| `adaptive_minds/external_tools.py` | §5.4 | Sandboxed tool handlers | +| `adaptive_minds/server.py` | — | FastAPI surface (`/route` `/agent` `/chat` …) | +| `adaptive_minds/cli.py` | — | `serve` / `server` / `route` / `agent` / `list` | + +The serving topology is flat: the **Next.js UI** (port 7007) calls the +**FastAPI server**, which holds no model weights and forwards every +inference over HTTP to a single **vLLM** engine that serves the base model +plus all LoRA adapters by name. Browser → API goes through the Next.js +proxy (`/api/am/*`), so the same image works on localhost, a public IP, or +behind a reverse proxy. + +## The catalog YAML drives everything + +A single YAML file (`catalogs/qwen25_30.yaml` ships in the repo) defines: + +- the base model (`base_model.hf_id`) +- the HF repo holding the adapters (`hub.repo`) +- the router prompt template and decoding (`router.*`) +- the agent loop budget and brain model (`agent.*`) +- one entry per LoRA adapter (`lora_adapters[*]`), each with a name, + HF subpath, description, system prompt, and keyword list + +The same YAML is consumed by two paths: + +1. **`adaptive-minds serve`** turns each adapter entry into a + `--lora-modules =/` argument and prints the + full `vllm serve` command. That single vLLM server then handles every + adapter by name through its OpenAI-compatible endpoint. +2. **The Python runtime** (`load_catalog`) turns the same entries into + `Adapter` instances that `run_router()` and `run_agent()` index by + lowercase id when calling the vLLM endpoint. + +This means there is one source of truth and zero state to keep in sync — +adding an adapter is "edit YAML, restart vLLM, done." + +## Router (single-step, §5.2) + +``` +query ──▶ base model classifier ──▶ adapter id ──▶ adapter call ──▶ response + │ + └── (keyword fallback if id ∉ catalog) +``` + +`run_router(query, catalog, cfg, ...)`: + +1. Build a `domain_list` of `id: description` lines from the catalog. +2. Render `cfg["router_prompt"]` (from YAML) with `{query}` + `{domain_list}`. +3. Call vLLM at `VLLM_BASE/chat/completions` with `model="base"`, + `stop=["\n"]`, temperature 0, ≤16 tokens. Parse the first token as the + adapter id. +4. If the id isn't in the catalog, fall back to `keyword_pick()` (counts + keyword hits per adapter). +5. Make a second vLLM call with `model=` and the (optional) + trained system prompt for that adapter. Return the response. + +Two vLLM calls total. Both go to the same endpoint; the `model` field is +the LoRA selector. + +## ReAct Agent (multi-step, §5.4) + +The agent's brain emits text in this grammar: + +``` +THOUGHT: +CALL: | +… +FINAL: +``` + +The runtime: + +1. Calls the brain with the system prompt + the current trace, stopping + only on `OBSERVATION:` and `User query:` (tokens the brain should + never emit itself). +2. Parses the response into `CALL` / `FINAL` actions in source order. + A response may contain multiple `CALL` lines — they execute in order + and their observations batch into a single OBSERVATION block fed back + next turn. A `FINAL` anywhere in the response ends the loop after the + preceding `CALL`s run. +3. For each `CALL | `: + - if `name` is in `TOOL_DESCRIPTIONS`, invoke that tool handler; + - if `name` is in the catalog, set the adapter and call vLLM with the + adapter's trained system prompt + `arg` as the user message; + - otherwise report "unknown name" back as the observation. +4. Append the brain response + observation block to the trace; loop until + `FINAL` or `max_steps` exhausted. +5. If step budget runs out without a `FINAL`, do one synthesis pass + asking the brain to commit a `FINAL` from the trace. + +The same loop covers both directly-routed cases (one CALL + one FINAL = +routing in another form) and multi-tool reasoning. + +## Auto (mode dispatch, §5.5) + +`run_auto(query, catalog, cfg, ...)` calls `needs_agent(query)` and then +delegates to `run_router` or `run_agent`, stamping the chosen mode and a +one-word `reason` into the result so the UI can show *why*. The paper +motivates an entropy-gated classifier — H(Q) over the base model's first +16 tokens, `H < 0.8 → Router`, `H > 1.5 → Agent` — but v0.1 ships a cheap +heuristic instead (operators, sequencing words, length, multi-sentence → +Agent; otherwise Router), avoiding a second model call. The hook is in +place to swap in the entropy gate later without changing callers. + +## LangGraph (StateGraph form, §5.4) + +`langgraph_agent.py` expresses the *same* agent loop as a +`langgraph.StateGraph` with three nodes — **plan → dispatch → synthesise** — +reusing `agent.py`'s `_dispatch_call` and prompt: + +``` +START → plan +plan → dispatch (CALL emitted) +plan → END (FINAL emitted) +dispatch → plan (step budget remaining) +dispatch → synthesise (budget exhausted) +synthesise → END +``` + +Behaviour matches the imperative agent; the value is observability — each +run is a sequence of node visits you can trace or drop into an existing +LangGraph pipeline. `langgraph` ships in the `[serve]` extra; the +imperative `agent.py` works without it. + +## Why the two stops are exactly those + +Earlier revisions stopped on `\nCALL:` and `\nFINAL:` to "prevent multi- +action per turn". That silently dropped every first verb line because +generation halted at the prefix transition. The fix in v0.1 is the +opposite: only stop on tokens the brain should never emit (OBSERVATION:, +User query:). Let the brain emit verbs freely; the parser decides what +to do with them. + +## External tools + +Five tools ship in v0.1 (see `adaptive_minds/external_tools.py`): + +| Name | Use | +|-------------|-----------------------------------------------------------| +| `calculator`| sympy expressions, symbolic + numeric | +| `code` | Python snippet in 12s subprocess sandbox | +| `shell` | bash one-liner in 10s subprocess sandbox | +| `websearch` | DuckDuckGo top-5 | +| `pulp` | LP/MIP solver — JSON spec or Python snippet | + +All run in-process or in subprocess (no external services beyond +DuckDuckGo). Adding a tool: implement a handler in `external_tools.py`, +add the entry to `EXTERNAL_TOOLS` + `TOOL_DESCRIPTIONS`. + +## Runtime entry points + +```python +from adaptive_minds import load_catalog, run_router, run_agent +from adaptive_minds.catalog import router_cfg + +cat = load_catalog("catalogs/qwen25_30.yaml") +cfg = router_cfg("catalogs/qwen25_30.yaml") + +r = run_router("Write SQL to find duplicate emails.", cat, cfg, 0.3, 256) +print(r["adapter_id"], "→", r["response"]) + +a = run_agent("Compute 2**32+17 then explain it as a finance metric.", + cat, cfg, 0.3, 1024) +print(a["response"]) +``` + +Or via the CLI: + +```bash +adaptive-minds route --catalog catalogs/qwen25_30.yaml --query "..." +adaptive-minds agent --catalog catalogs/qwen25_30.yaml --query "..." +``` diff --git a/docs/agent_architecture_2.png b/docs/agent_architecture_2.png new file mode 100644 index 0000000..0937057 Binary files /dev/null and b/docs/agent_architecture_2.png differ diff --git a/docs/am_routing_v4.png b/docs/am_routing_v4.png new file mode 100644 index 0000000..d07a54c Binary files /dev/null and b/docs/am_routing_v4.png differ diff --git a/docs/auto_mode_arch.png b/docs/auto_mode_arch.png new file mode 100644 index 0000000..7703b2c Binary files /dev/null and b/docs/auto_mode_arch.png differ diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..b1e7f53 Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/demo.mp4 b/docs/demo.mp4 new file mode 100644 index 0000000..cdb7814 Binary files /dev/null and b/docs/demo.mp4 differ diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..a1c6275 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..7274470 --- /dev/null +++ b/evals/README.md @@ -0,0 +1,79 @@ +# Evaluations + +Three scripts in this directory reproduce the paper's main numbers. All +require a vLLM server serving the catalog adapters by name (start one +with `adaptive-minds serve --catalog ...`); see the top-level README for +the launch command. + +## Table 1 — routing accuracy on a 30-adapter library + +Paper claim: **98.3 %** with the model-driven router; **31.7 %** with the +keyword baseline (Qwen3.5-9B, 30 adapters, 60 queries). + +```bash +# Model-driven router (Adaptive Minds Table 1 main result) +python -m evals.routing_table1 \ + --catalog catalogs/qwen25_30.yaml \ + --gold evals/gold/routing_gold.jsonl \ + --out evals/results/routing_router.json + +# Keyword baseline (no vLLM call — pure Python) +python -m evals.routing_keyword_baseline \ + --catalog catalogs/qwen25_30.yaml \ + --out evals/results/routing_keyword.json +``` + +The shipped gold set (`evals/gold/routing_gold.jsonl`) contains 151 +hand-labeled queries across the 9 paper-Table-2 specialists plus the +`reasoning` fallback. The original 30-adapter / 60-query Qwen3.5-9B +gold set used in the paper is internal; this public set is representative +but smaller. + +## Table 2 — 9-specialist gains under one shared recipe + +Paper claim: **+4.6 to +84.0 pp** strict-scorer over the base model across +nine benchmarks (Qwen2.5-7B-Instruct). + +Per-benchmark training and eval scripts are documented in +[`training/README.md`](../training/README.md). Each specialist uses the +same shared SFT recipe at `training/train_sft.py`; the only differences +are the dataset and the validator. The README links to dataset sources and +to the published adapter weights. + +## Table 3 — Vanilla / Router / Agent on MMLU + +Paper claim (Llama 3.1 8B, 100 domain-matched questions): Vanilla 56.0, +Router 62.0, Agent 58.0. + +```bash +python -m evals.mmlu_three_way \ + --catalog catalogs/qwen25_30.yaml \ + --n 10 \ + --out evals/results/mmlu_three_way.json +``` + +The MMLU↔adapter mapping defaults to a Qwen2.5-7B-friendly set inside +`mmlu_three_way.py`. Override it with `--mapping path/to/mapping.json` +when working with a different base model. + +## Smoke test (cheap, ~10 queries) + +```bash +python -m evals.routing_table1 --catalog catalogs/qwen25_30.yaml --limit 10 +``` + +This burns ~30 seconds against a running vLLM server and confirms the +router can reach the endpoint and produce sane choices. + +## Notes on reproducibility + +- **Adapter weights**: all 30 catalog adapters resolve to + `pavan01729/adaptive-minds-loras/qwen2.5-7b/` on the Hub. vLLM + downloads them on launch from the `--lora-modules` arguments emitted by + `adaptive-minds serve`. +- **Decoding**: routing uses temperature 0 for determinism; specialists + use temperature 0.3 (matching paper §5.4 defaults). Pass `--temperature` + to override. +- **Hardware**: a single L40S (48 GB) is enough to serve the 30 adapters + under vLLM with `--max-lora-rank 64`. Smaller GPUs work with fewer + adapters enabled. diff --git a/evals/gold/routing_gold.jsonl b/evals/gold/routing_gold.jsonl new file mode 100644 index 0000000..906df6e --- /dev/null +++ b/evals/gold/routing_gold.jsonl @@ -0,0 +1,151 @@ +{"id": "route_pii_v1_00", "query": "Please redact all PII from this customer email body and return the redacted text.", "expected_adapter": "pii"} +{"id": "route_pii_v1_01", "query": "Anonymize this conversation log: 'Hi I'm Jane Doe, jane@example.com'", "expected_adapter": "pii"} +{"id": "route_pii_v1_02", "query": "Mask any personally identifiable information in the following document.", "expected_adapter": "pii"} +{"id": "route_pii_v1_03", "query": "Replace names, emails, and phone numbers with placeholder labels.", "expected_adapter": "pii"} +{"id": "route_pii_v1_04", "query": "I need to scrub sensitive information from these chat transcripts before sharing.", "expected_adapter": "pii"} +{"id": "route_pii_v1_05", "query": "Detect and remove SSN, credit card, and address spans from this text.", "expected_adapter": "pii"} +{"id": "route_pii_v1_06", "query": "Apply PII redaction so that the user's identity is no longer recoverable.", "expected_adapter": "pii"} +{"id": "route_pii_v1_07", "query": "Strip out emails and phone numbers from this support ticket transcript.", "expected_adapter": "pii"} +{"id": "route_pii_v1_08", "query": "Return this paragraph with all personally identifiable info masked as [LABEL].", "expected_adapter": "pii"} +{"id": "route_pii_v1_09", "query": "Sanitize this clinical note by replacing patient names with [PERSON].", "expected_adapter": "pii"} +{"id": "route_pii_v1_10", "query": "Hide the user's IP address and email in this access log line.", "expected_adapter": "pii"} +{"id": "route_pii_v1_11", "query": "Remove the credit card number and CVV from this receipt.", "expected_adapter": "pii"} +{"id": "route_pii_v1_12", "query": "Anonymize this list of leads \u2014 keep the company but mask names + phones.", "expected_adapter": "pii"} +{"id": "route_pii_v1_13", "query": "Identify all PII spans in this paragraph and replace them with type tags.", "expected_adapter": "pii"} +{"id": "route_pii_v1_14", "query": "Replace passport numbers and birthdates with [ID] and [DATE].", "expected_adapter": "pii"} +{"id": "route_pii_v1_15", "query": "I need to mask all personal info in this chat log for compliance review.", "expected_adapter": "pii"} +{"id": "route_funccall_v1_00", "query": "Given the available functions, call the right one to fetch the weather in Tokyo.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_01", "query": "Make a function call to search the catalog for blue running shoes size 10.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_02", "query": "Invoke the appropriate API to retrieve user 12345's order history.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_03", "query": "Pick the right tool and call it: get the current stock price of AAPL.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_04", "query": "Call a function to schedule a meeting tomorrow at 3pm with John.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_05", "query": "Tool call: send an email to alice@x.com with subject 'Status update'.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_06", "query": "Use the available functions to add 'milk' and 'eggs' to my shopping list.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_07", "query": "Make the API call to translate 'hello world' from English to French.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_08", "query": "Call the right function to get my GitHub starred repositories.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_09", "query": "Invoke a tool to look up the population of Japan.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_10", "query": "Call get_user_profile with user_id=42.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_11", "query": "Pick the right tool to check the next available appointment slot.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_12", "query": "Use the function definitions to book a flight from SFO to JFK.", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_13", "query": "Function call: get_news_headlines(topic='AI', limit=5).", "expected_adapter": "functioncall"} +{"id": "route_funccall_v1_14", "query": "Make a call to get the latest cryptocurrency prices for BTC and ETH.", "expected_adapter": "functioncall"} +{"id": "route_sql_v1_00", "query": "Write a SQL query to find the top 10 customers by total revenue in 2023.", "expected_adapter": "sql"} +{"id": "route_sql_v1_01", "query": "Generate a SQL statement to select all employees in the Sales department.", "expected_adapter": "sql"} +{"id": "route_sql_v1_02", "query": "Spider task: question='which films grossed more than 100 million?' schema=films(id, title, gross)", "expected_adapter": "sql"} +{"id": "route_sql_v1_03", "query": "SQL: list product names where stock < 5 ordered by name.", "expected_adapter": "sql"} +{"id": "route_sql_v1_04", "query": "Translate to SQL: average salary per department where headcount > 50.", "expected_adapter": "sql"} +{"id": "route_sql_v1_05", "query": "Write SQL to JOIN customers and orders, returning customer names and order counts.", "expected_adapter": "sql"} +{"id": "route_sql_v1_06", "query": "SQL query: find duplicate emails in the users table.", "expected_adapter": "sql"} +{"id": "route_sql_v1_07", "query": "Generate a SQL UPDATE to set status='shipped' WHERE order_id=12345.", "expected_adapter": "sql"} +{"id": "route_sql_v1_08", "query": "Write a SQL aggregation: sum of sales by month for the last year.", "expected_adapter": "sql"} +{"id": "route_sql_v1_09", "query": "Translate to SQL: 'Show me all books published before 1990 by author Asimov'.", "expected_adapter": "sql"} +{"id": "route_sql_v1_10", "query": "Spider: 'list cities with population above 1M' schema=cities(id, name, pop, country).", "expected_adapter": "sql"} +{"id": "route_sql_v1_11", "query": "Write a SQL query for ranking products by sales using window functions.", "expected_adapter": "sql"} +{"id": "route_sql_v1_12", "query": "Generate SQL: SELECT count of distinct customers per region.", "expected_adapter": "sql"} +{"id": "route_sql_v1_13", "query": "Convert this question to SQL: 'How many orders shipped in May 2024?'", "expected_adapter": "sql"} +{"id": "route_sql_v1_14", "query": "Write a SQL query: top 5 most expensive items in the orders table.", "expected_adapter": "sql"} +{"id": "route_cypher_v1_00", "query": "Write a Cypher query to find all friends-of-friends of node X.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_01", "query": "Generate Cypher: MATCH the shortest path between Alice and Bob.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_02", "query": "Cypher: list all movies that Tom Hanks acted in.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_03", "query": "Translate to Cypher: 'Find all employees in the company graph who report to manager M'.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_04", "query": "Neo4j query to get all nodes connected to User(id=1) within 2 hops.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_05", "query": "Write Cypher to count outgoing :KNOWS relationships from each Person.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_06", "query": "Cypher query: find all triangle motifs in a friendship graph.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_07", "query": "Generate a Cypher MERGE statement to upsert a Product node.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_08", "query": "Translate to Cypher: 'Show me drugs that interact with both DrugA and DrugB'.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_09", "query": "Cypher: find disconnected components in the citation graph.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_10", "query": "Write a graph query to get all books authored by Asimov in Cypher syntax.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_11", "query": "Cypher: return the top 10 most-followed users by COUNT of incoming :FOLLOWS edges.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_12", "query": "Translate this English question to Cypher: 'What courses does Alice teach?'", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_13", "query": "Cypher MATCH pattern to find all paths from City A to City B via roads.", "expected_adapter": "cypher"} +{"id": "route_cypher_v1_14", "query": "Generate a Cypher OPTIONAL MATCH for users without a profile photo.", "expected_adapter": "cypher"} +{"id": "route_bash_v1_00", "query": "How do I find all .py files modified in the last 7 days?", "expected_adapter": "bash"} +{"id": "route_bash_v1_01", "query": "Bash command to recursively delete empty directories under /tmp.", "expected_adapter": "bash"} +{"id": "route_bash_v1_02", "query": "Write a shell command to count the number of lines in all .log files.", "expected_adapter": "bash"} +{"id": "route_bash_v1_03", "query": "How do I tar.gz the entire /home/user directory excluding .cache?", "expected_adapter": "bash"} +{"id": "route_bash_v1_04", "query": "Bash one-liner to grep for ERROR in nginx logs and show the surrounding 5 lines.", "expected_adapter": "bash"} +{"id": "route_bash_v1_05", "query": "Shell command: kill all processes whose name contains 'zombie'.", "expected_adapter": "bash"} +{"id": "route_bash_v1_06", "query": "How to use rsync to mirror /src to /dst, deleting extras?", "expected_adapter": "bash"} +{"id": "route_bash_v1_07", "query": "Bash: print the largest 10 files in the current directory tree.", "expected_adapter": "bash"} +{"id": "route_bash_v1_08", "query": "Find duplicate files by md5 hash and print their paths.", "expected_adapter": "bash"} +{"id": "route_bash_v1_09", "query": "Shell command to replace 'foo' with 'bar' in all .txt files in place.", "expected_adapter": "bash"} +{"id": "route_bash_v1_10", "query": "Bash: pipe ls output to xargs to chmod 644 every regular file.", "expected_adapter": "bash"} +{"id": "route_bash_v1_11", "query": "Shell: extract all email addresses from a log file using grep -oE.", "expected_adapter": "bash"} +{"id": "route_bash_v1_12", "query": "Bash one-liner to monitor disk usage of /var/log every 5 seconds.", "expected_adapter": "bash"} +{"id": "route_bash_v1_13", "query": "Find all symlinks under /etc that point to nonexistent targets.", "expected_adapter": "bash"} +{"id": "route_bash_v1_14", "query": "Shell command to convert epoch timestamp to ISO date for each line.", "expected_adapter": "bash"} +{"id": "route_sparql_v1_00", "query": "Write a SPARQL query for: 'What is the population of Berlin?' on Wikidata.", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_01", "query": "SPARQL on Wikidata: list all films directed by Christopher Nolan.", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_02", "query": "Generate SPARQL: 'When was Albert Einstein born?'", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_03", "query": "Wikidata SPARQL: countries that share a border with France.", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_04", "query": "SPARQL ASK: is the capital of Japan Tokyo?", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_05", "query": "Translate to SPARQL: 'What are the children of Queen Elizabeth II?'", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_06", "query": "SPARQL: list all programming languages with their inception dates.", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_07", "query": "Wikidata triple-store query: find chemical compounds named after Marie Curie.", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_08", "query": "SPARQL with qualifier path: 'When did Barack Obama become president?'", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_09", "query": "Generate Wikidata SPARQL: drugs treating diabetes ordered by approval date.", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_10", "query": "SPARQL: which Nobel laureates were born in Vienna?", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_11", "query": "Translate to SPARQL: 'List all Premier League clubs founded before 1900'.", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_12", "query": "Wikidata SPARQL: 'How many languages are spoken in India?'", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_13", "query": "SPARQL ASK: did Wikipedia exist before 2001?", "expected_adapter": "sparql"} +{"id": "route_sparql_v1_14", "query": "SPARQL on RDF: find paintings by van Gogh ordered by creation year.", "expected_adapter": "sparql"} +{"id": "route_regex_v1_00", "query": "Write a Python regex to match a US ZIP code (5 or 9 digit format).", "expected_adapter": "regex"} +{"id": "route_regex_v1_01", "query": "Regex pattern for validating an IPv4 address.", "expected_adapter": "regex"} +{"id": "route_regex_v1_02", "query": "Generate a regex that matches any valid email address.", "expected_adapter": "regex"} +{"id": "route_regex_v1_03", "query": "Regex: capture all phone numbers in (xxx) xxx-xxxx format.", "expected_adapter": "regex"} +{"id": "route_regex_v1_04", "query": "Write a regex to extract dates in YYYY-MM-DD format.", "expected_adapter": "regex"} +{"id": "route_regex_v1_05", "query": "Regex pattern matching hex color codes like #FFAA33 or #fff.", "expected_adapter": "regex"} +{"id": "route_regex_v1_06", "query": "Generate a regex for valid IBAN account numbers.", "expected_adapter": "regex"} +{"id": "route_regex_v1_07", "query": "Pattern matching: extract URLs from a text document.", "expected_adapter": "regex"} +{"id": "route_regex_v1_08", "query": "Write a regex that matches credit card numbers with optional dashes.", "expected_adapter": "regex"} +{"id": "route_regex_v1_09", "query": "Regex: a valid Python identifier (letter/underscore start, then alphanumeric).", "expected_adapter": "regex"} +{"id": "route_regex_v1_10", "query": "Match any valid HTML tag (opening or closing) with regex.", "expected_adapter": "regex"} +{"id": "route_regex_v1_11", "query": "Generate a regex for time strings in HH:MM 24-hour format.", "expected_adapter": "regex"} +{"id": "route_regex_v1_12", "query": "Regex: match a string that starts with at least 3 digits.", "expected_adapter": "regex"} +{"id": "route_regex_v1_13", "query": "Pattern for license plate format ABC-1234.", "expected_adapter": "regex"} +{"id": "route_regex_v1_14", "query": "Write a regex to find every word containing exactly two vowels.", "expected_adapter": "regex"} +{"id": "route_mermaid_v1_00", "query": "Generate a Mermaid sequence diagram for a user logging into a web app.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_01", "query": "Create a sequenceDiagram showing API \u2192 DB \u2192 Cache interactions.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_02", "query": "Mermaid: sequence diagram of a payment processing flow with Stripe.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_03", "query": "Draw a sequenceDiagram for the OAuth2 authorization code flow.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_04", "query": "Mermaid sequence diagram: file upload from browser through CDN to S3.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_05", "query": "Generate a Mermaid diagram of microservices communicating: A \u2192 B \u2192 C.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_06", "query": "Sequence diagram in Mermaid for a chat message round trip.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_07", "query": "Mermaid: sequenceDiagram for a video streaming session.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_08", "query": "Create a Mermaid sequence diagram of a gRPC client-server call.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_09", "query": "Mermaid sequenceDiagram: user adds item to cart and checks out.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_10", "query": "Generate a sequence diagram showing CI pipeline stages.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_11", "query": "Mermaid: order placement flow involving Customer, OrderService, PaymentService.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_12", "query": "Sequence diagram for a multi-factor authentication interaction in Mermaid.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_13", "query": "Create a Mermaid diagram for distributed tracing across 3 services.", "expected_adapter": "mermaid"} +{"id": "route_mermaid_v1_14", "query": "Mermaid sequenceDiagram of a webhook delivery and retry mechanism.", "expected_adapter": "mermaid"} +{"id": "route_reasoning_v1_00", "query": "If a train leaves Boston at 3pm at 60mph and another leaves NY at 4pm at 80mph, when do they meet?", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_01", "query": "Step-by-step proof: show that the sum of two odd numbers is even.", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_02", "query": "Reason through: how many integers between 1 and 100 are divisible by both 3 and 5?", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_03", "query": "Derive the formula for the area of a circle from first principles.", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_04", "query": "Multi-step problem: a tank fills in 6 hours, drains in 9 hours, both open how long?", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_05", "query": "Explain step by step why 2^10 = 1024.", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_06", "query": "Reason about: a farmer has 17 sheep, all but 9 die \u2014 how many remain?", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_07", "query": "Calculate by reasoning: how many 3-digit numbers contain a 7?", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_08", "query": "Show your work: prove sqrt(2) is irrational.", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_09", "query": "Step by step: how many ways can you arrange the letters in 'BANANA'?", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_10", "query": "Reason: if I double a number and add 4, I get 22; what's the number?", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_11", "query": "Explain why dividing by zero is undefined, step by step.", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_12", "query": "Multi-step calculation: compound interest on $1000 at 5% over 10 years.", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_13", "query": "Derive the velocity needed to escape Earth's gravity from energy conservation.", "expected_adapter": "reasoning"} +{"id": "route_reasoning_v1_14", "query": "Step-by-step: solve the quadratic 2x^2 + 3x - 5 = 0.", "expected_adapter": "reasoning"} +{"id": "route_base_00", "query": "Tell me a fun fact about whales.", "expected_adapter": "reasoning"} +{"id": "route_base_01", "query": "Write a haiku about autumn.", "expected_adapter": "reasoning"} +{"id": "route_base_02", "query": "Translate 'good morning' to Spanish, French, and Japanese.", "expected_adapter": "reasoning"} +{"id": "route_base_03", "query": "What's the difference between cilantro and parsley?", "expected_adapter": "reasoning"} +{"id": "route_base_04", "query": "Recommend three popular romance novels published in the last decade.", "expected_adapter": "reasoning"} +{"id": "route_base_05", "query": "Summarize the plot of The Great Gatsby in three sentences.", "expected_adapter": "reasoning"} +{"id": "route_base_06", "query": "Give me a recipe for chocolate chip cookies.", "expected_adapter": "reasoning"} +{"id": "route_base_07", "query": "What are some healthy breakfast ideas?", "expected_adapter": "reasoning"} +{"id": "route_base_08", "query": "Tell me about the history of the Eiffel Tower.", "expected_adapter": "reasoning"} +{"id": "route_base_09", "query": "What are the seven wonders of the world?", "expected_adapter": "reasoning"} +{"id": "route_base_10", "query": "Suggest a name for a new coffee shop.", "expected_adapter": "reasoning"} +{"id": "route_base_11", "query": "Compose a short toast for my parents' anniversary.", "expected_adapter": "reasoning"} +{"id": "route_base_12", "query": "Give me three ideas for a weekend trip from Seattle.", "expected_adapter": "reasoning"} +{"id": "route_base_13", "query": "What do dolphins typically eat?", "expected_adapter": "reasoning"} +{"id": "route_base_14", "query": "Write a short bedtime story for a 5-year-old.", "expected_adapter": "reasoning"} diff --git a/evals/mmlu_three_way.py b/evals/mmlu_three_way.py new file mode 100644 index 0000000..2678172 --- /dev/null +++ b/evals/mmlu_three_way.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Reproduce Table 3 (Vanilla / Router / Agent) on MMLU. + +Three inference strategies on MMLU subsets that match adapters in the catalog: + + 1. VANILLA — base model only (no adapters) + 2. ROUTER — single-step router picks one adapter per question + 3. AGENT — multi-step ReAct loop (may call multiple adapters + tools) + +The mapping from MMLU subjects to catalog adapter ids lives in --mapping, +defaulting to a Qwen-style mapping below. Adjust if your catalog ids differ. + +Usage: + python -m evals.mmlu_three_way \\ + --catalog catalogs/qwen25_30.yaml \\ + --n 100 \\ + --out evals/results/mmlu_three_way.json + +Requires VLLM_BASE pointing at a vLLM server serving the catalog adapters +plus the base model. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +import time +from collections import defaultdict +from pathlib import Path + +from adaptive_minds import load_catalog, run_agent, run_router, vllm_chat +from adaptive_minds.catalog import router_cfg + + +# MMLU subject → adapter id in the catalog. +DEFAULT_MAPPING = { + "college_chemistry": "chemistry", + "high_school_chemistry": "chemistry", + "professional_accounting": "finance", + "high_school_macroeconomics": "economics", + "college_computer_science": "datafreds", # placeholder + "machine_learning": "reasoning", + "college_medicine": "medicine", + "anatomy": "medicine", + "high_school_biology": "biology", + "high_school_physics": "reasoning", + "miscellaneous": "reasoning", +} + +ANSWER_LETTERS = ["A", "B", "C", "D"] + + +def load_questions(mapping: dict, n_per_subject: int) -> list[dict]: + """Load MMLU questions for the subjects in `mapping`.""" + try: + from datasets import load_dataset + except ImportError: + sys.exit("evals/mmlu_three_way.py requires `pip install datasets`") + + out = [] + for subject, adapter_id in mapping.items(): + try: + ds = load_dataset("cais/mmlu", subject, split="test") + except Exception: + try: + ds = load_dataset("cais/mmlu", subject, split="validation") + except Exception as e: + print(f" [warn] skipping {subject}: {e}") + continue + n = min(n_per_subject, len(ds)) + for i in range(n): + ex = ds[i] + choices_text = "\n".join( + f"{ANSWER_LETTERS[j]}. {c}" for j, c in enumerate(ex["choices"]) + ) + out.append({ + "subject": subject, + "domain": adapter_id, + "question": ex["question"], + "choices_text": choices_text, + "correct_answer": ANSWER_LETTERS[ex["answer"]], + }) + return out + + +def extract_answer(text: str) -> str | None: + """Extract a single A/B/C/D letter from model output.""" + t = (text or "").strip().upper() + if not t: + return None + if t[0] in ANSWER_LETTERS: + return t[0] + m = re.search(r"(?:ANSWER|CORRECT)\s*(?:IS|:)?\s*([A-D])", t) + if m: + return m.group(1) + m = re.search(r"\b([A-D])\b", t) + if m: + return m.group(1) + return None + + +def build_mcq_prompt(q: dict) -> str: + return ( + "Answer the following multiple-choice question. " + "Reply with ONLY the letter (A, B, C, or D).\n\n" + f"Question: {q['question']}\n{q['choices_text']}\n\nAnswer:" + ) + + +def eval_vanilla(questions: list[dict]) -> list[dict]: + out = [] + for i, q in enumerate(questions, 1): + r = vllm_chat("base", [{"role": "user", "content": build_mcq_prompt(q)}], + temperature=0.0, max_tokens=20) + pred = extract_answer(r.get("response") or "") + out.append({ + "idx": i, "subject": q["subject"], "domain": q["domain"], + "correct_answer": q["correct_answer"], "predicted": pred, + "correct": pred == q["correct_answer"], + "raw": (r.get("response") or "")[:80], + }) + return out + + +def eval_router(questions: list[dict], catalog, cfg) -> list[dict]: + out = [] + for i, q in enumerate(questions, 1): + r = run_router(build_mcq_prompt(q), catalog, cfg, + temperature=0.0, max_tokens=40) + pred = extract_answer(r.get("response") or "") + out.append({ + "idx": i, "subject": q["subject"], "domain": q["domain"], + "correct_answer": q["correct_answer"], "predicted": pred, + "correct": pred == q["correct_answer"], + "routed_to": r.get("adapter_id"), + "raw": (r.get("response") or "")[:80], + }) + return out + + +def eval_agent(questions: list[dict], catalog, cfg) -> list[dict]: + out = [] + for i, q in enumerate(questions, 1): + r = run_agent(build_mcq_prompt(q), catalog, cfg, + temperature=0.0, max_tokens=400) + pred = extract_answer(r.get("response") or "") + out.append({ + "idx": i, "subject": q["subject"], "domain": q["domain"], + "correct_answer": q["correct_answer"], "predicted": pred, + "correct": pred == q["correct_answer"], + "n_steps": len(r.get("steps") or []), + "raw": (r.get("response") or "")[:80], + }) + return out + + +def score(rows: list[dict], mode: str) -> dict: + total = len(rows) + correct = sum(1 for r in rows if r["correct"]) + by_domain = defaultdict(lambda: {"total": 0, "correct": 0}) + for r in rows: + by_domain[r["domain"]]["total"] += 1 + if r["correct"]: + by_domain[r["domain"]]["correct"] += 1 + return { + "mode": mode, + "total": total, + "correct": correct, + "accuracy_pct": round(correct / total * 100, 2) if total else 0, + "per_domain": { + k: {"accuracy_pct": round(v["correct"] / v["total"] * 100, 2), + "correct": v["correct"], "total": v["total"]} + for k, v in sorted(by_domain.items()) + }, + } + + +def main(): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, required=True) + p.add_argument("--n", type=int, default=10, + help="questions per subject (default 10 → ~100 total)") + p.add_argument("--mapping", type=Path, default=None, + help="JSON {mmlu_subject: adapter_id} mapping") + p.add_argument("--modes", default="vanilla,router,agent", + help="comma-separated subset of modes to run") + p.add_argument("--out", type=Path, default=None) + args = p.parse_args() + + catalog = load_catalog(args.catalog) + cfg = router_cfg(args.catalog) + mapping = (json.loads(args.mapping.read_text()) + if args.mapping else DEFAULT_MAPPING) + + print(f"[mmlu_three_way] loading {args.n} questions/subject across " + f"{len(mapping)} subjects") + questions = load_questions(mapping, args.n) + if not questions: + sys.exit("no questions loaded") + print(f" loaded {len(questions)} questions total") + + modes = [m.strip() for m in args.modes.split(",") if m.strip()] + all_scores = [] + raw = {} + for mode in modes: + print(f"\n[mode] {mode}") + t0 = time.time() + if mode == "vanilla": + rows = eval_vanilla(questions) + elif mode == "router": + rows = eval_router(questions, catalog, cfg) + elif mode == "agent": + rows = eval_agent(questions, catalog, cfg) + else: + print(f" unknown mode: {mode}; skipping") + continue + s = score(rows, mode) + s["elapsed_seconds"] = round(time.time() - t0, 1) + all_scores.append(s) + raw[mode] = rows + print(f" → {s['accuracy_pct']:.2f}% ({s['correct']}/{s['total']}) " + f"in {s['elapsed_seconds']:.1f}s") + + print() + print(f"{'Mode':<10}{'Acc':>8}{'Correct':>12}{'Time':>10}") + for s in all_scores: + print(f" {s['mode']:<8} {s['accuracy_pct']:>6.2f}% " + f"{s['correct']:>3}/{s['total']:<3} {s['elapsed_seconds']:>6.1f}s") + + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps({ + "catalog": str(args.catalog), + "mapping": mapping, + "n_per_subject": args.n, + "scores": all_scores, + "raw": raw, + }, indent=2, default=str)) + print(f"\n results → {args.out}") + + +if __name__ == "__main__": + main() diff --git a/evals/routing_keyword_baseline.py b/evals/routing_keyword_baseline.py new file mode 100644 index 0000000..8506ace --- /dev/null +++ b/evals/routing_keyword_baseline.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Keyword-matching baseline for Table 1. + +Reproduces the "Keyword" column — for each query, count keyword hits per +adapter (from the catalog YAML) and pick the highest-scoring entry. + +Usage: + python -m evals.routing_keyword_baseline \\ + --catalog catalogs/qwen25_30.yaml \\ + --gold evals/gold/routing_gold.jsonl + +Unlike `routing_table1.py`, this script does NOT need a vLLM server — the +classifier is pure Python. +""" +from __future__ import annotations + +import argparse +import json +import sys +from collections import defaultdict +from pathlib import Path + +from adaptive_minds import load_catalog, keyword_pick + + +def evaluate(catalog_path: Path, gold_path: Path, *, limit: int) -> dict: + cat = load_catalog(catalog_path) + if not cat: + sys.exit("catalog is empty") + rows = [] + with open(gold_path) as f: + for i, line in enumerate(f): + if limit and i >= limit: + break + rows.append(json.loads(line)) + + results = [] + correct = 0 + per_adapter = defaultdict(lambda: {"correct": 0, "total": 0}) + for row in rows: + q = row["query"] + expected = row["expected_adapter"] + chosen, scores = keyword_pick(q, cat) + is_correct = (chosen == expected) + correct += int(is_correct) + per_adapter[expected]["total"] += 1 + per_adapter[expected]["correct"] += int(is_correct) + results.append({ + "id": row.get("id"), "query": q, + "expected_adapter": expected, "chosen_adapter": chosen, + "correct": is_correct, + "top_scores": [(a, s) for a, s in scores[:5] if s > 0], + }) + + accuracy = correct / len(rows) * 100 if rows else 0 + return { + "catalog": str(catalog_path), + "gold": str(gold_path), + "method": "keyword_pick", + "n_queries": len(rows), + "correct": correct, + "accuracy_pct": round(accuracy, 2), + "per_adapter_accuracy": { + k: {"accuracy_pct": round(v["correct"] / v["total"] * 100, 2), + "correct": v["correct"], "total": v["total"]} + for k, v in sorted(per_adapter.items()) + }, + "results": results, + } + + +def main(): + here = Path(__file__).resolve().parent + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, required=True) + p.add_argument("--gold", type=Path, default=here / "gold" / "routing_gold.jsonl") + p.add_argument("--out", type=Path, default=None) + p.add_argument("--limit", type=int, default=0) + args = p.parse_args() + + s = evaluate(args.catalog, args.gold, limit=args.limit) + print(f" Keyword baseline accuracy: {s['accuracy_pct']:.2f}% " + f"({s['correct']}/{s['n_queries']})") + print() + print(" Per-adapter:") + for k, v in s["per_adapter_accuracy"].items(): + print(f" {k:<16s} {v['accuracy_pct']:>6.2f}% " + f"({v['correct']}/{v['total']})") + + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(s, indent=2, default=str)) + print(f"\n results → {args.out}") + + +if __name__ == "__main__": + main() diff --git a/evals/routing_table1.py b/evals/routing_table1.py new file mode 100644 index 0000000..e25fcce --- /dev/null +++ b/evals/routing_table1.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Reproduce Table 1 (routing accuracy) from the paper. + +For each query in evals/gold/routing_gold.jsonl, the script asks the base +model to pick an adapter from the catalog and compares the choice against +the hand-labeled ground truth. + +Usage: + python -m evals.routing_table1 \\ + --catalog catalogs/qwen25_30.yaml \\ + --gold evals/gold/routing_gold.jsonl \\ + --out evals/results/routing_eval.json + +Smoke test (first N queries): + python -m evals.routing_table1 --catalog catalogs/qwen25_30.yaml --limit 10 + +Requires VLLM_BASE pointing at a vLLM server launched with the catalog's +LoRA adapters loaded (see `adaptive-minds serve --catalog ...`). +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from collections import defaultdict +from pathlib import Path + +from adaptive_minds import load_catalog, run_router +from adaptive_minds.catalog import router_cfg + + +def evaluate(catalog_path: Path, gold_path: Path, *, limit: int, + temperature: float, max_tokens: int) -> dict: + cat = load_catalog(catalog_path) + if not cat: + sys.exit("catalog is empty") + cfg = router_cfg(catalog_path) + + rows = [] + with open(gold_path) as f: + for i, line in enumerate(f): + if limit and i >= limit: + break + rows.append(json.loads(line)) + + print(f"[eval] {len(rows)} queries, {len(cat)} adapters in catalog") + results = [] + correct = 0 + per_adapter = defaultdict(lambda: {"correct": 0, "total": 0}) + confusion = defaultdict(lambda: defaultdict(int)) + + t0 = time.time() + for i, row in enumerate(rows, 1): + q = row["query"] + expected = row["expected_adapter"] + out = run_router(q, cat, cfg, temperature, max_tokens) + chosen = out.get("adapter_id") or "" + is_correct = (chosen == expected) + if is_correct: + correct += 1 + per_adapter[expected]["total"] += 1 + per_adapter[expected]["correct"] += int(is_correct) + confusion[expected][chosen] += 1 + results.append({ + "id": row.get("id"), + "query": q, + "expected_adapter": expected, + "chosen_adapter": chosen, + "correct": is_correct, + "elapsed": out.get("elapsed"), + "error": out.get("error"), + }) + marker = "✓" if is_correct else "✗" + print(f" [{i:3d}/{len(rows)}] {marker} expected={expected:<13} " + f"got={chosen:<13} q={q[:60]}") + + accuracy = correct / len(rows) * 100 if rows else 0 + summary = { + "catalog": str(catalog_path), + "gold": str(gold_path), + "n_queries": len(rows), + "n_adapters_in_catalog": len(cat), + "correct": correct, + "accuracy_pct": round(accuracy, 2), + "elapsed_seconds": round(time.time() - t0, 2), + "per_adapter_accuracy": { + k: {"accuracy_pct": round(v["correct"] / v["total"] * 100, 2), + "correct": v["correct"], "total": v["total"]} + for k, v in sorted(per_adapter.items()) + }, + "confusion_matrix": {k: dict(v) for k, v in confusion.items()}, + "results": results, + } + return summary + + +def main(): + here = Path(__file__).resolve().parent + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, required=True) + p.add_argument("--gold", type=Path, default=here / "gold" / "routing_gold.jsonl") + p.add_argument("--out", type=Path, default=None) + p.add_argument("--limit", type=int, default=0, + help="0 = all queries; otherwise first N") + p.add_argument("--temperature", type=float, default=0.3) + p.add_argument("--max-tokens", type=int, default=256) + args = p.parse_args() + + summary = evaluate(args.catalog, args.gold, + limit=args.limit, + temperature=args.temperature, + max_tokens=args.max_tokens) + + print() + print(f" Accuracy: {summary['accuracy_pct']:.2f}% " + f"({summary['correct']}/{summary['n_queries']})") + print(f" Elapsed: {summary['elapsed_seconds']:.1f}s") + print() + print(" Per-adapter:") + for k, v in summary["per_adapter_accuracy"].items(): + print(f" {k:<16s} {v['accuracy_pct']:>6.2f}% " + f"({v['correct']}/{v['total']})") + + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(summary, indent=2, default=str)) + print(f"\n results → {args.out}") + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md index 3de584e..7df663d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,103 +1,18 @@ -# 🎯 Adaptive Minds Examples +# Examples -This directory contains simple examples demonstrating how to use Adaptive Minds. - -## 📋 Available Examples - -### 1. 🐍 Python API Examples - -**`basic_usage.py`** - Complete Python example showing: -- Server status checking -- Sending queries to different domain experts -- Handling responses and errors -- Clearing conversation history - -```bash -# Run the basic examples -python examples/basic_usage.py -``` - -### 2. 🌐 cURL Examples - -**`curl_examples.sh`** - Shell script with cURL commands: -- API endpoint examples -- JSON request formatting -- Response handling +Three runnable examples assuming a vLLM server is already up: ```bash -# Make the script executable and run -chmod +x examples/curl_examples.sh -./examples/curl_examples.sh +$(adaptive-minds serve --catalog catalogs/qwen25_30.yaml) & +export VLLM_BASE=http://localhost:8000/v1 ``` -### 3. 💬 Interactive Demo - -**`interactive_demo.py`** - Command-line chat interface: -- Real-time conversation with Adaptive Minds -- Built-in help and commands -- Example queries and tips - -```bash -# Start interactive chat -python examples/interactive_demo.py -``` - -## 🚀 Quick Start - -1. **Start Adaptive Minds:** - ```bash - docker compose up - ``` - -2. **Wait for initialization** (check logs for "🎉 ALL SYSTEMS READY!") - -3. **Run any example:** - ```bash - # Python examples - python examples/basic_usage.py - python examples/interactive_demo.py - - # cURL examples - ./examples/curl_examples.sh - ``` - -## 📊 Example Queries by Domain - -| Domain | Example Query | Expected Expert | -|--------|---------------|----------------| -| **General** | "Hello, how are you?" | General | -| **Chemistry** | "What is the molecular formula of water?" | Chemistry | -| **Finance** | "How does compound interest work?" | Finance | -| **AI/Tech** | "Explain machine learning" | AI | -| **Medical** | "What are symptoms of flu?" | Medical | - -## 🔧 API Endpoints Used - -- **POST** `/chat` - Send queries -- **GET** `/status` - Check system status -- **DELETE** `/chat/history` - Clear conversation history - -## 🐛 Troubleshooting - -**Connection errors:** -- Ensure Adaptive Minds is running: `docker compose up` -- Check server status: `curl http://localhost:8765/status` -- Wait for full initialization (~2-3 minutes on first run) - -**Python requirements:** -```bash -pip install requests -``` - -## 💡 Next Steps - -After trying these examples: - -1. **Explore the Web UI:** http://localhost:8501 -2. **Read the API docs:** Check the main README.md -3. **Build your own integration** using these examples as templates -4. **Try different query types** to see the intelligent routing in action - ---- +| File | What it does | +|----------------------|---------------------------------------------------------| +| `basic_router.py` | Three single-step routing examples (SQL, Cypher, PII). | +| `basic_agent.py` | Multi-step agent: calculator → finance expert → FINAL. | +| `curl_examples.sh` | Direct vLLM curl calls (no Python). | -**Need help?** Check the main README.md or open an issue on GitHub! +For a minimal smoke test before running the full 30-adapter catalog, +disable all but two adapters in `catalogs/qwen25_30.yaml` +(`enabled: false`) so vLLM only downloads what you need. diff --git a/examples/basic_agent.py b/examples/basic_agent.py new file mode 100644 index 0000000..93b371d --- /dev/null +++ b/examples/basic_agent.py @@ -0,0 +1,32 @@ +"""Multi-step ReAct agent — brain plans, tools/experts execute, brain synthesises. + +Run after launching a vLLM server with the catalog adapters: + + $(adaptive-minds serve --catalog catalogs/qwen25_30.yaml) & + export VLLM_BASE=http://localhost:8000/v1 + python examples/basic_agent.py +""" +from adaptive_minds import load_catalog, run_agent +from adaptive_minds.catalog import router_cfg + + +def main() -> None: + catalog_path = "catalogs/qwen25_30.yaml" + catalog = load_catalog(catalog_path) + cfg = router_cfg(catalog_path) + + query = ( + "Compute 2**32 + 17 with the calculator, then have the finance " + "expert explain what that figure could plausibly represent on a " + "company's balance sheet." + ) + + out = run_agent(query, catalog, cfg, temperature=0.3, max_tokens=1024) + print(f"Query: {query}\n") + print(f"Response:\n{out['response']}\n") + print(f"Mode: {out['mode']} • Steps: {len(out['steps'])} " + f"• Elapsed: {out['elapsed']:.1f}s") + + +if __name__ == "__main__": + main() diff --git a/examples/basic_router.py b/examples/basic_router.py new file mode 100644 index 0000000..395c350 --- /dev/null +++ b/examples/basic_router.py @@ -0,0 +1,31 @@ +"""Single-step router — pick one adapter and call it. + +Run after launching a vLLM server with the catalog adapters: + + $(adaptive-minds serve --catalog catalogs/qwen25_30.yaml) & + export VLLM_BASE=http://localhost:8000/v1 + python examples/basic_router.py +""" +from adaptive_minds import load_catalog, run_router +from adaptive_minds.catalog import router_cfg + + +def main() -> None: + catalog_path = "catalogs/qwen25_30.yaml" + catalog = load_catalog(catalog_path) + cfg = router_cfg(catalog_path) + + queries = [ + "Write a SQL query for top 10 customers by revenue in 2023.", + "Translate to Cypher: find all friends-of-friends of Alice.", + "Redact all PII from this transcript: 'I am Jane Doe, jane@x.com'.", + ] + for q in queries: + out = run_router(q, catalog, cfg, temperature=0.3, max_tokens=256) + print(f"\nQ: {q}") + print(f"→ adapter={out['adapter_id']}") + print(f"→ {out['response'][:200]}") + + +if __name__ == "__main__": + main() diff --git a/examples/basic_usage.py b/examples/basic_usage.py deleted file mode 100644 index 9c3ebe6..0000000 --- a/examples/basic_usage.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" -Basic Usage Examples for Adaptive Minds API - -This script demonstrates how to interact with the Adaptive Minds API -using simple Python requests. - -Prerequisites: -- Adaptive Minds server running (docker compose up) -- Server accessible at http://localhost:8765 -""" - -import requests -import json -import time - -# API Configuration -API_BASE_URL = "http://localhost:8765" - -def check_server_status(): - """Check if the Adaptive Minds server is running.""" - try: - response = requests.get(f"{API_BASE_URL}/status", timeout=5) - if response.status_code == 200: - data = response.json() - print("✅ Server is running!") - print(f"📊 Loaded adapters: {', '.join(data['loaded_adapters'])}") - print(f"🖥️ GPU memory: {data['gpu_memory_allocated']:.1f}GB") - return True - else: - print(f"❌ Server error: HTTP {response.status_code}") - return False - except requests.exceptions.RequestException as e: - print(f"❌ Cannot connect to server: {e}") - print("💡 Make sure to run: docker compose up") - return False - -def send_query(query: str): - """Send a query to Adaptive Minds and display the response.""" - try: - print(f"\n🤔 Query: {query}") - - response = requests.post( - f"{API_BASE_URL}/chat", - json={"query": query}, - timeout=30 - ) - - if response.status_code == 200: - data = response.json() - print(f"🎯 Selected Expert: {data['selected_adapter']}") - print(f"💭 Response: {data['response']}") - print(f"🔍 Reasoning: {data['reasoning']}") - else: - print(f"❌ Error: HTTP {response.status_code}") - print(response.text) - - except requests.exceptions.RequestException as e: - print(f"❌ Request failed: {e}") - -def clear_history(): - """Clear the conversation history.""" - try: - response = requests.delete(f"{API_BASE_URL}/chat/history") - if response.status_code == 200: - print("🧹 Conversation history cleared!") - else: - print(f"❌ Failed to clear history: HTTP {response.status_code}") - except requests.exceptions.RequestException as e: - print(f"❌ Request failed: {e}") - -def main(): - """Run example queries demonstrating different domain experts.""" - - print("🧠 Adaptive Minds - Basic Usage Examples") - print("=" * 50) - - # Check server status - if not check_server_status(): - return - - # Example queries for each domain - example_queries = [ - # General conversation - "Hello! How are you today?", - - # Chemistry expert - "What is the molecular formula of caffeine?", - - # Finance expert - "How does compound interest work?", - - # AI/Technology expert - "Explain what machine learning is", - - # Medical expert - "What are the symptoms of common cold?", - - # General knowledge - "What is the capital of France?" - ] - - print(f"\n🚀 Running {len(example_queries)} example queries...") - - for i, query in enumerate(example_queries, 1): - print(f"\n{'='*60}") - print(f"Example {i}/{len(example_queries)}") - print('='*60) - - send_query(query) - - # Small delay between queries - if i < len(example_queries): - time.sleep(2) - - print(f"\n{'='*60}") - print("✅ All examples completed!") - print("\n💡 Try your own queries:") - print(f" curl -X POST {API_BASE_URL}/chat \\") - print(' -H "Content-Type: application/json" \\') - print(' -d \'{"query": "Your question here"}\'') - -if __name__ == "__main__": - main() diff --git a/examples/curl_examples.sh b/examples/curl_examples.sh index f044d99..11964f0 100644 --- a/examples/curl_examples.sh +++ b/examples/curl_examples.sh @@ -1,68 +1,42 @@ -#!/bin/bash - -# Adaptive Minds - cURL Examples -# -# This script demonstrates how to interact with Adaptive Minds using cURL commands. -# Make sure the server is running: docker compose up - -API_BASE_URL="http://localhost:8765" - -echo "🧠 Adaptive Minds - cURL Examples" -echo "==================================" - -# Function to make API calls with better formatting -make_request() { - local query="$1" - local description="$2" - - echo "" - echo "📝 $description" - echo "Query: $query" - echo "---" - - curl -s -X POST "$API_BASE_URL/chat" \ +#!/usr/bin/env bash +# Adaptive Minds — vLLM endpoint examples (OpenAI-compatible /v1). +# +# Prereq: start a vLLM server using the catalog adapters, e.g. +# $(adaptive-minds serve --catalog catalogs/qwen25_30.yaml) & +# Then either source .env or: +# export VLLM_BASE=http://localhost:8000/v1 + +set -euo pipefail +VLLM_BASE="${VLLM_BASE:-http://localhost:8000/v1}" + +post() { + local model="$1"; shift + local user="$1"; shift + curl -sS "${VLLM_BASE}/chat/completions" \ -H "Content-Type: application/json" \ - -d "{\"query\": \"$query\"}" | \ - python3 -m json.tool 2>/dev/null || echo "❌ Failed to parse response" - - echo "" - sleep 2 + -d @- </dev/null - -if [ $? -ne 0 ]; then - echo "❌ Server not responding. Make sure to run: docker compose up" - exit 1 -fi - -echo "✅ Server is running!" -echo "" - -# Example queries for each domain expert -make_request "Hello! How are you?" "General conversation" - -make_request "What is the molecular formula of water?" "Chemistry query" - -make_request "How do I calculate compound interest?" "Finance query" - -make_request "What is the difference between AI and machine learning?" "AI/Technology query" - -make_request "What are the symptoms of flu?" "Medical query" +echo "== sanity: list models served by vLLM ==" +curl -sS "${VLLM_BASE}/models" | python3 -m json.tool | head -40 -make_request "Tell me a fun fact about space" "General knowledge" +echo +echo "== call the base model directly ==" +post "base" '"What is the molecular formula of caffeine?"' -# Show how to clear history -echo "🧹 Clearing conversation history..." -curl -s -X DELETE "$API_BASE_URL/chat/history" -echo "History cleared!" +echo +echo "== call the chemistry adapter directly ==" +post "chemistry" '"What is the molecular formula of caffeine?"' -echo "" -echo "✅ All examples completed!" -echo "" -echo "💡 Try your own queries:" -echo "curl -X POST $API_BASE_URL/chat \\" -echo " -H \"Content-Type: application/json\" \\" -echo " -d '{\"query\": \"Your question here\"}'" +echo +echo "== call the sql adapter directly ==" +post "sql" '"Write SQL: top 5 customers by revenue in 2023."' diff --git a/examples/interactive_demo.py b/examples/interactive_demo.py deleted file mode 100644 index 8e9efbc..0000000 --- a/examples/interactive_demo.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -""" -Interactive Demo for Adaptive Minds - -A simple interactive command-line interface to chat with Adaptive Minds. -Perfect for testing and demonstrating the system's capabilities. - -Usage: - python examples/interactive_demo.py - -Prerequisites: -- Adaptive Minds server running (docker compose up) -- Python requests library -""" - -import requests -import json -import sys - -# API Configuration -API_BASE_URL = "http://localhost:8765" - -def check_server(): - """Check if server is available.""" - try: - response = requests.get(f"{API_BASE_URL}/status", timeout=5) - return response.status_code == 200 - except: - return False - -def send_query(query: str): - """Send query and return response.""" - try: - response = requests.post( - f"{API_BASE_URL}/chat", - json={"query": query}, - timeout=30 - ) - if response.status_code == 200: - return response.json() - else: - return {"error": f"HTTP {response.status_code}"} - except Exception as e: - return {"error": str(e)} - -def clear_history(): - """Clear conversation history.""" - try: - requests.delete(f"{API_BASE_URL}/chat/history") - return True - except: - return False - -def print_response(data): - """Pretty print the response.""" - if "error" in data: - print(f"❌ Error: {data['error']}") - return - - print(f"🎯 Expert: {data.get('selected_adapter', 'Unknown')}") - print(f"💭 Response: {data.get('response', 'No response')}") - - # Show reasoning if available - reasoning = data.get('reasoning', '') - if reasoning and reasoning.strip(): - print(f"🔍 Why this expert: {reasoning}") - -def show_help(): - """Show available commands.""" - print("\n📋 Available commands:") - print(" /help - Show this help") - print(" /clear - Clear conversation history") - print(" /status - Show server status") - print(" /examples - Show example queries") - print(" /quit - Exit the demo") - print(" Or just type any question!\n") - -def show_examples(): - """Show example queries.""" - examples = [ - ("General", "Hello, how are you?"), - ("Chemistry", "What is the molecular formula of caffeine?"), - ("Finance", "How does compound interest work?"), - ("AI/Tech", "Explain machine learning"), - ("Medical", "What are symptoms of flu?"), - ("Knowledge", "What is the capital of Japan?") - ] - - print("\n💡 Example queries to try:") - for domain, query in examples: - print(f" {domain:10} → {query}") - print() - -def main(): - """Main interactive loop.""" - print("🧠 Adaptive Minds - Interactive Demo") - print("====================================") - - # Check server - if not check_server(): - print("❌ Cannot connect to Adaptive Minds server!") - print("💡 Make sure to run: docker compose up") - sys.exit(1) - - print("✅ Connected to Adaptive Minds server!") - print("Type /help for commands or just ask any question.") - print("Press Ctrl+C to exit anytime.\n") - - try: - while True: - # Get user input - user_input = input("🤔 You: ").strip() - - if not user_input: - continue - - # Handle commands - if user_input.startswith('/'): - command = user_input.lower() - - if command == '/help': - show_help() - elif command == '/clear': - if clear_history(): - print("🧹 Conversation history cleared!") - else: - print("❌ Failed to clear history") - elif command == '/status': - try: - response = requests.get(f"{API_BASE_URL}/status") - data = response.json() - print(f"📊 Status: {'✅ Running' if data.get('is_initialized') else '❌ Not ready'}") - print(f"🧠 Loaded experts: {', '.join(data.get('loaded_adapters', []))}") - print(f"🖥️ GPU memory: {data.get('gpu_memory_allocated', 0):.1f}GB") - except: - print("❌ Failed to get status") - elif command == '/examples': - show_examples() - elif command in ['/quit', '/exit']: - print("👋 Goodbye!") - break - else: - print(f"❓ Unknown command: {user_input}") - print("Type /help for available commands") - - continue - - # Send query to Adaptive Minds - print("🤖 Thinking...") - response = send_query(user_input) - print_response(response) - print() - - except KeyboardInterrupt: - print("\n👋 Goodbye!") - except Exception as e: - print(f"\n❌ Unexpected error: {e}") - -if __name__ == "__main__": - main() diff --git a/nanoam.py b/nanoam.py new file mode 100644 index 0000000..4ddf48a --- /dev/null +++ b/nanoam.py @@ -0,0 +1,295 @@ +"""nanoam.py — the entire Adaptive Minds framework in one file (≤300 lines). + +Two control flows over one base model + a catalog of LoRA adapters: + * Router — one LLM call picks an adapter; that adapter answers. + * Agent — ReAct loop: brain emits CALL/FINAL; runtime executes tools + + LoRA experts; FINAL terminates. + +The production runtime in ``adaptive_minds/`` adds FastAPI, sandboxed tools, +evals, and docker. Everything else is the same shape as below. + + VLLM_BASE=http://localhost:8000/v1 python nanoam.py route "What is caffeine?" + VLLM_BASE=http://localhost:8000/v1 python nanoam.py agent "Compute 2**10." + +Paper: https://arxiv.org/abs/2510.15416 +""" +from __future__ import annotations + +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +import requests +import yaml + +VLLM_BASE = os.environ.get("VLLM_BASE", "http://localhost:8000/v1") +CATALOG_PATH = os.environ.get( + "AM_CATALOG", str(Path(__file__).parent / "catalogs" / "qwen25_smoke.yaml"), +) + + +# ---- Catalog ---- + +@dataclass(frozen=True) +class Adapter: + """One catalog entry. ``id`` is the lowercase name vLLM serves it under.""" + id: str + description: str + system_prompt: str + keywords: list[str] + + +def load_catalog(path: str = CATALOG_PATH) -> tuple[dict[str, Adapter], dict]: + """Parse the catalog YAML; return (id→Adapter dict, full config).""" + cfg = yaml.safe_load(Path(path).read_text()) + catalog: dict[str, Adapter] = {} + for row in cfg.get("lora_adapters", []): + if not row.get("enabled", True): + continue + aid = row["name"].lower().replace(" ", "_") + catalog[aid] = Adapter( + id=aid, + description=row.get("description", ""), + system_prompt=row.get("system_prompt", ""), + keywords=row.get("keywords", []), + ) + return catalog, cfg + + +# ---- vLLM HTTP client ---- + +def chat(model: str, messages: list[dict], + temperature: float = 0.3, max_tokens: int = 512, + stop: list[str] | None = None) -> dict: + """One POST to vLLM's OpenAI-compatible endpoint. Returns + {ok, response, error}. ``model`` is the adapter name OR the base id.""" + body = {"model": model, "messages": messages, + "temperature": temperature, "max_tokens": max_tokens} + if stop: + body["stop"] = stop + try: + r = requests.post(f"{VLLM_BASE}/chat/completions", + json=body, timeout=180) + except requests.exceptions.ConnectionError: + return {"ok": False, "error": ( + f"vLLM unreachable at {VLLM_BASE}. " + "Run 'docker compose up -d' or set VLLM_BASE.")} + if r.status_code != 200: + return {"ok": False, "error": f"HTTP {r.status_code}: {r.text[:200]}"} + j = r.json() + return {"ok": True, "response": j["choices"][0]["message"]["content"]} + + +# ---- Built-in tools: two minimal handlers (production ships five) ---- + +def tool_calculator(arg: str) -> str: + """Evaluate a Python arithmetic expression in a restricted namespace. + Production uses sympy for symbolic work; same call surface.""" + try: + return str(eval(arg.strip(), {"__builtins__": {}}, {})) + except Exception as e: + return f"calculator error: {e}" + + +def tool_python(arg: str) -> str: + """Run a Python snippet that prints its result. Caps stdout at 4 KB.""" + import contextlib + import io + buf = io.StringIO() + try: + with contextlib.redirect_stdout(buf): + exec(arg, {"__builtins__": __builtins__}) + return (buf.getvalue() or "(no output — did you print()?)")[:4000] + except Exception as e: + return f"python error: {e}" + + +TOOLS: dict[str, tuple[str, callable]] = { + "calculator": ("evaluate a Python arithmetic expression. e.g. `2**16 + 17`", + tool_calculator), + "python": ("run a Python snippet and capture stdout (use print()).", + tool_python), +} + + +# ---- Router: one LLM call picks an adapter, that adapter answers ---- + +ROUTER_PROMPT = """\ +Analyze the user query below and select the single best domain expert from +the list. Respond with ONLY the expert's id (lowercase, no punctuation). + +Query: "{query}" + +Experts: +{domain_list} + +Selected expert:""" + + +def keyword_pick(query: str, catalog: dict[str, Adapter]) -> str: + """Cheap baseline used as a fallback when the LLM picks an unknown id.""" + q = query.lower() + best, best_score = next(iter(catalog)), 0 + for aid, a in catalog.items(): + score = sum(1 for k in a.keywords if k.lower() in q) + if score > best_score: + best, best_score = aid, score + return best + + +def run_router(query: str, catalog: dict[str, Adapter]) -> dict: + """(1) base model picks an adapter id, (2) that adapter answers. + Both steps hit the same vLLM endpoint; only ``model`` changes.""" + domain_list = "\n".join(f"- {a.id}: {a.description.strip()[:120]}" + for a in catalog.values()) + prompt = ROUTER_PROMPT.format(query=query, domain_list=domain_list) + r1 = chat("base", [{"role": "user", "content": prompt}], + temperature=0.0, max_tokens=16, stop=["\n"]) + if not r1["ok"]: + return r1 + chosen = (r1["response"] or "").strip().strip("`* ").split()[0].lower() + if chosen not in catalog: + chosen = keyword_pick(query, catalog) + a = catalog[chosen] + msgs = ([{"role": "system", "content": a.system_prompt}] + if a.system_prompt else []) + [{"role": "user", "content": query}] + r2 = chat(chosen, msgs, temperature=0.3, max_tokens=512) + return {"ok": r2["ok"], "adapter_id": chosen, + "response": r2.get("response"), "error": r2.get("error")} + + +# ---- Agent: ReAct loop over tools + LoRA experts ---- + +AGENT_PROMPT = """\ +You are the Adaptive Minds agent. You answer the user by orchestrating +tools and domain experts. + +Output format: + CALL: | execute a tool or consult an expert + FINAL: emit the final answer + +Tools (executed deterministically): +{tool_menu} + +Experts (LoRA fine-tunes — they GENERATE domain text): +{expert_menu} + +Rules: (1) plain-text arguments; (2) the runtime writes OBSERVATION lines, +not you; (3) batch independent CALLs in one turn when sensible. + +User query: {query} +""" + +_VERB = re.compile(r"^\s*(CALL|FINAL)\s*:\s*(.*)$", re.IGNORECASE) + + +def parse_actions(text: str) -> list[tuple[str, str, str]]: + """Return a list of (kind, name, arg). Walks line-by-line so multi-line + arguments survive until the next CALL/FINAL/OBSERVATION line.""" + lines = text.splitlines() + out, i = [], 0 + while i < len(lines): + m = _VERB.match(lines[i]) + if not m: + i += 1 + continue + kind, payload = m.group(1).upper(), m.group(2) + j = i + 1 + while j < len(lines): + s = lines[j].lstrip().upper() + if s.startswith(("CALL:", "FINAL:", "OBSERVATION:")): + break + j += 1 + if j > i + 1: + payload = payload + "\n" + "\n".join(lines[i + 1:j]) + if kind == "FINAL": + out.append(("FINAL", "", payload.strip())) + else: + head, _, arg = payload.partition("|") + out.append(("CALL", head.strip().lower(), arg.strip())) + i = j + return out + + +def dispatch(name: str, arg: str, catalog: dict[str, Adapter]) -> str: + """Run one CALL — either a tool or a LoRA expert.""" + if name in TOOLS: + return TOOLS[name][1](arg) + if name in catalog: + a = catalog[name] + msgs = [] + if a.system_prompt: + msgs.append({"role": "system", "content": a.system_prompt}) + msgs.append({"role": "user", "content": arg}) + r = chat(name, msgs, temperature=0.3, max_tokens=800) + return r.get("response") or r.get("error") or "(empty)" + return f"unknown name '{name}'. Tools: {list(TOOLS)}. Experts: {list(catalog)}." + + +def run_agent(query: str, catalog: dict[str, Adapter], + max_steps: int = 4) -> dict: + """Run the ReAct loop until FINAL or max_steps exhausted.""" + tool_menu = "\n".join(f" - {n:<10s} — {d}" for n, (d, _) in TOOLS.items()) + expert_menu = "\n".join(f" - {a.id:<24s} — {a.description[:120]}" + for a in sorted(catalog.values(), key=lambda x: x.id)) + sys_prompt = AGENT_PROMPT.format(tool_menu=tool_menu, expert_menu=expert_menu, query=query) + trace, final = "", None + for step in range(max_steps): + msg = (f"User query: {query}\n\n" + + (f"Trace so far:\n{trace}\n\n" if trace else "") + + "Plan your next move. Emit CALL: lines or a FINAL: line.") + r = chat("base", [{"role": "system", "content": sys_prompt}, + {"role": "user", "content": msg}], + temperature=0.2, max_tokens=1024, + stop=["\nOBSERVATION:", "\nUser query:"]) + if not r["ok"]: + return {"ok": False, "error": r["error"]} + trace += (r["response"] or "").rstrip() + "\n" + actions = parse_actions(r["response"] or "") + if not actions: + trace += "OBSERVATION: (no CALL/FINAL emitted)\n" + continue + obs_block = [] + for kind, name, arg in actions: + if kind == "FINAL": + final = arg + break + obs_block.append(f"OBSERVATION ({name}): {dispatch(name, arg, catalog)[:600]}") + if obs_block: + trace += "\n".join(obs_block) + "\n" + if final is not None: + break + return {"ok": True, "response": final or "(no FINAL emitted)", + "steps": step + 1, "trace": trace} + + +# ---- Main ---- + +def main(argv: list[str]) -> int: + if len(argv) < 3 or argv[1] not in ("route", "agent"): + print('usage: python nanoam.py {route|agent} ""', file=sys.stderr) + return 2 + mode, query = argv[1], " ".join(argv[2:]) + catalog, _ = load_catalog() + if not catalog: + print(f"no adapters in {CATALOG_PATH}", file=sys.stderr) + return 1 + t0 = time.time() + r = run_router(query, catalog) if mode == "route" else run_agent(query, catalog) + if not r.get("ok"): + print(f"ERROR: {r.get('error')}", file=sys.stderr) + return 1 + print(json.dumps({"mode": mode, "query": query, + "adapter_id": r.get("adapter_id"), + "response": r.get("response"), + "elapsed_s": round(time.time() - t0, 2), + "steps": r.get("steps")}, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/playground/README.md b/playground/README.md deleted file mode 100644 index 6c2f9d1..0000000 --- a/playground/README.md +++ /dev/null @@ -1,354 +0,0 @@ -# 🧠 Adaptive Minds Playground - -**The complete toolkit for training, managing, and deploying specialized AI experts.** - -Train custom LoRA adapters, manage multiple models, and deploy intelligent routing systems with ease. - -## 🚀 Quick Start - -### Option 1: Use Pre-trained Models (5 minutes) -```bash -# 1. Configure models (use example or customize) -cp models_config.yaml models_config.yaml.backup # Backup if exists -# Edit models_config.yaml to your needs - -# 2. Download all models -python manage_models.py sync - -# 3. Start the server -python server.py -``` - -### Option 2: Train Your Own Expert (30 minutes) -```bash -# 1. Train a LoRA on your dataset -python train/train.py --dataset your-dataset --lora-name my-expert - -# 2. Register it automatically -python manage_models.py add-lora \ - --name "MyExpert" \ - --path "./loras/my-expert" \ - --description "Expert in my domain" \ - --system-prompt "You are an expert in..." \ - --keywords domain specific keywords - -# 3. Rebuild and start -python manage_models.py rebuild -python server.py -``` - -### Option 3: Mix & Match (Flexible) -```bash -# Use some pre-trained + some custom + some from other users -# Edit models_config.yaml: -# - Enable/disable any adapter -# - Add your HF repos -# - Point to local LoRAs -# - Mix sources freely -``` - ---- - -## 📋 What You Get - -| Feature | Description | -|---------|-------------| -| **🎯 Smart Routing** | AI automatically selects the best expert for each query | -| **🔧 Easy Training** | Train LoRAs with any HuggingFace dataset | -| **📦 Model Management** | Download, sync, and manage models with CLI | -| **🌐 Web Interface** | Beautiful Streamlit frontend | -| **⚡ Fast API** | RESTful API for integration | -| **🔍 Auto-Discovery** | Find and register local LoRAs automatically | - ---- - -## 🛠️ Installation - -### Prerequisites -- Python 3.10+ -- CUDA-capable GPU (recommended) -- 16GB+ RAM (for 8B models) - -### Install Dependencies -```bash -# Basic server dependencies -uv sync - -# Add training dependencies (optional) -uv sync --extra training -``` - -### Environment Setup -```bash -# For HuggingFace models (optional) -export HF_TOKEN="your_huggingface_token_here" - -# For CUDA (if available) -export CUDA_VISIBLE_DEVICES=0 -``` - ---- - -## 📖 Detailed Usage - -### 1. Model Configuration - -Edit `models_config.yaml` to define your models: - -```yaml -# Base model (required) -base_model: - name: "llama-3.1-8B-instruct" - source: "huggingface" # or "local" - huggingface_id: "meta-llama/Llama-3.1-8B-Instruct" - local_path: "./base_model/llama-3.1-8B-instruct" - -# LoRA adapters (as many as you want) -lora_adapters: - - name: "General" - source: "huggingface" # or "local" - huggingface_id: "pavan01729/llama-8B-alpaca-2k" - local_path: "./loras/llama-8B-alpaca-2k" - description: "General purpose assistant" - system_prompt: "You are a helpful assistant..." - keywords: [general, chat, conversation] - enabled: true # Enable/disable as needed -``` - -### 2. Model Management CLI - -```bash -# List all configured models -python manage_models.py list - -# Download/sync all models -python manage_models.py sync - -# Validate configuration -python manage_models.py validate - -# Discover local LoRAs -python manage_models.py discover - -# Rebuild metadata.json -python manage_models.py rebuild -``` - -### 3. Training Custom LoRAs - -```bash -# Train on any HuggingFace dataset -python train/train.py \ - --dataset "medalpaca/medical_meadow_medical_flashcards" \ - --lora-name "medical-expert" \ - --steps 500 - -# Train with custom parameters -python train/train.py \ - --dataset "squad" \ - --lora-name "qa-expert" \ - --steps 1000 \ - --batch-size 4 \ - --lora-r 32 -``` - -### 4. Registering New LoRAs - -```bash -# Add a local LoRA -python manage_models.py add-lora \ - --name "MyExpert" \ - --path "./loras/my-expert" \ - --description "Expert in my domain" \ - --system-prompt "You are an expert in..." \ - --keywords domain specific keywords - -# Enable/disable adapters -python manage_models.py enable --name "MyExpert" -python manage_models.py disable --name "OldExpert" -``` - -### 5. Starting the Server - -```bash -# Start with all configured models -python server.py - -# Access the web interface -# http://localhost:8501 - -# Or use the API -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "Your question here"}' -``` - ---- - -## 🎯 Popular Use Cases - -### 1. **Domain-Specific Chatbots** -```bash -# Train a legal expert -python train/train.py --dataset "pile-of-law/pile-of-law" --lora-name "legal-expert" - -# Train a coding assistant -python train/train.py --dataset "codeparrot/github-code" --lora-name "coding-expert" -``` - -### 2. **Multi-Domain Support System** -```yaml -# In models_config.yaml - mix different sources -lora_adapters: - - name: "CustomerSupport" - source: "huggingface" - huggingface_id: "company/support-lora" - # ... - - name: "TechnicalExpert" - source: "local" - local_path: "./loras/technical-expert" - # ... - - name: "SalesExpert" - source: "huggingface" - huggingface_id: "sales-ai/sales-lora" - # ... -``` - -### 3. **Research Assistant** -```bash -# Train on scientific papers -python train/train.py --dataset "scientific_papers" --lora-name "research-expert" - -# Train on medical data -python train/train.py --dataset "medalpaca/medical_meadow" --lora-name "medical-expert" -``` - ---- - -## 📊 Available Datasets - -| Domain | Dataset | Description | -|--------|---------|-------------| -| **Medical** | `medalpaca/medical_meadow_medical_flashcards` | Medical knowledge | -| **Legal** | `pile-of-law/pile-of-law` | Legal documents | -| **Code** | `codeparrot/github-code` | Programming | -| **Math** | `microsoft/orca-math-word-problems-200k` | Mathematics | -| **Science** | `sciq` | Science questions | -| **General** | `squad`, `squad_v2` | General Q&A | - ---- - -## 🔧 Advanced Configuration - -### Custom Base Models -```yaml -base_model: - name: "custom-model" - source: "local" - local_path: "/path/to/your/model" -``` - -### Custom Router Prompts -```yaml -router: - prompt_template: | - Your custom routing logic here... - Query: "{query}" - Experts: {domain_list} - Choose: {domain_names} -``` - -### Training Parameters -```bash -# Advanced training -python train/train.py \ - --dataset "your-dataset" \ - --lora-name "expert" \ - --steps 2000 \ - --batch-size 8 \ - --learning-rate 1e-4 \ - --lora-r 64 -``` - ---- - -## 🐛 Troubleshooting - -### Common Issues - -**1. "No HF_TOKEN provided"** -```bash -export HF_TOKEN="your_token_here" -python manage_models.py sync -``` - -**2. "CUDA out of memory"** -```bash -# Reduce batch size -python train/train.py --batch-size 1 --dataset your-dataset --lora-name expert -``` - -**3. "Model not found"** -```bash -# Check if model exists -python manage_models.py validate -python manage_models.py list -``` - -**4. "LoRA not working"** -```bash -# Rebuild metadata -python manage_models.py rebuild -# Restart server -python server.py -``` - -### Performance Tips - -- **Memory**: Use smaller batch sizes for training -- **Speed**: Enable mixed precision (automatic) -- **Quality**: More training steps ≠ always better -- **Storage**: Clean up old checkpoints regularly - ---- - -## 📁 Directory Structure - -``` -playground/ -├── models_config.yaml # Your model configuration -├── manage_models.py # Model management CLI -├── server.py # Main server -├── metadata.json # Generated from config -├── train/ -│ ├── train.py # Training script -│ └── README.md # Training documentation -├── loras/ # Your LoRA adapters -│ ├── llama-8B-alpaca-2k/ -│ ├── my-custom-expert/ -│ └── ... -└── base_model/ # Base model - └── llama-3.1-8B-instruct/ -``` - ---- - -## 🤝 Contributing - -1. **Add new datasets**: Update the training documentation -2. **Improve routing**: Enhance the router logic -3. **Add features**: Extend the CLI tools -4. **Fix bugs**: Report and fix issues - ---- - -## 📚 Additional Resources - -- **Training Guide**: See `train/README.md` -- **API Documentation**: Check `server.py` endpoints -- **Model Management**: Use `python manage_models.py --help` -- **Web Interface**: Access `http://localhost:8501` - ---- - -**Ready to build your AI expert system? Start with `python manage_models.py sync`!** 🚀 diff --git a/playground/app_frontend.py b/playground/app_frontend.py deleted file mode 100644 index fd51998..0000000 --- a/playground/app_frontend.py +++ /dev/null @@ -1,167 +0,0 @@ -import streamlit as st -import requests -import json -from typing import Dict, Any - -# Page configuration -st.set_page_config( - page_title="Adaptive Minds", - page_icon="🧠", - layout="wide" -) - -st.title("🧠 Adaptive Minds") -st.markdown("*LangGraph-powered multi-agent system with intelligent routing*") - -# Configuration -API_BASE_URL = "http://localhost:8765" - -# Initialize session state -if 'chat_history' not in st.session_state: - st.session_state.chat_history = [] -if 'api_status' not in st.session_state: - st.session_state.api_status = None - -def check_api_status() -> Dict[str, Any]: - """Check if the FastAPI server is running and get status.""" - try: - response = requests.get(f"{API_BASE_URL}/status", timeout=5) - if response.status_code == 200: - return {"connected": True, "data": response.json()} - else: - return {"connected": False, "error": f"HTTP {response.status_code}"} - except requests.exceptions.RequestException as e: - return {"connected": False, "error": str(e)} - -def send_chat_message(query: str) -> Dict[str, Any]: - """Send a chat message to the FastAPI server.""" - try: - payload = {"query": query} - response = requests.post( - f"{API_BASE_URL}/chat", - json=payload, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - - if response.status_code == 200: - return {"success": True, "data": response.json()} - else: - return {"success": False, "error": f"HTTP {response.status_code}: {response.text}"} - except requests.exceptions.RequestException as e: - return {"success": False, "error": str(e)} - -def clear_chat_history(): - """Clear the chat history on the server.""" - try: - response = requests.delete(f"{API_BASE_URL}/chat/history", timeout=5) - return response.status_code == 200 - except requests.exceptions.RequestException: - return False - -# Sidebar for system status -with st.sidebar: - st.header("🔧 System Status") - - # Check API status - if st.button("🔄 Refresh Status"): - st.session_state.api_status = check_api_status() - st.rerun() - - if st.session_state.api_status is None: - st.session_state.api_status = check_api_status() - - status = st.session_state.api_status - - if status["connected"]: - st.success("✅ Connected to FastAPI server") - - # Display system info - data = status["data"] - st.subheader("📊 System Info") - st.write(f"**Initialized**: {'✅' if data['is_initialized'] else '❌'}") - st.write(f"**Loaded Adapters**: {len(data['loaded_adapters'])}") - - if data['loaded_adapters']: - st.write("**Adapters**:") - for adapter in data['loaded_adapters']: - st.write(f" • {adapter}") - - if data.get('gpu_memory_allocated'): - st.write(f"**GPU Memory**: {data['gpu_memory_allocated']:.1f}GB") - - # Clear chat history button - if st.button("🗑️ Clear Chat History"): - if clear_chat_history(): - st.session_state.chat_history = [] - st.success("Chat history cleared!") - st.rerun() - else: - st.error("Failed to clear chat history") - else: - st.error("❌ Cannot connect to FastAPI server") - st.write(f"**Error**: {status['error']}") - st.write(f"**Expected URL**: {API_BASE_URL}") - st.write("Make sure the FastAPI server is running on port 8765") - -# Display chat history -for message in st.session_state.chat_history: - if message["role"] == "user": - with st.chat_message("user"): - st.write(message["content"]) - elif message["role"] == "assistant": - with st.chat_message("assistant"): - st.write(f"🧠 **{message['adapter']} via Adaptive Minds**") - st.write(message["content"]) - elif message["role"] == "reasoning": - with st.expander("🔍 Router Agent Decision", expanded=False): - st.markdown(message["content"]) - -# Chat input -if prompt := st.chat_input("Ask me anything..."): - # Check if API is connected - if not st.session_state.api_status or not st.session_state.api_status["connected"]: - st.error("❌ Cannot connect to FastAPI server. Please check the server status in the sidebar.") - st.stop() - - # Add user message to chat history - st.session_state.chat_history.append({"role": "user", "content": prompt}) - - # Display user message - with st.chat_message("user"): - st.write(prompt) - - # Send to API and get response - with st.spinner("🤔 Processing your query..."): - result = send_chat_message(prompt) - - if result["success"]: - data = result["data"] - - # Display reasoning - with st.expander("🔍 Router Agent Decision", expanded=False): - st.markdown(data["reasoning"]) - - # Add reasoning to chat history - st.session_state.chat_history.append({ - "role": "reasoning", - "content": data["reasoning"] - }) - - # Display assistant response - with st.chat_message("assistant"): - st.write(f"🧠 **{data['selected_adapter']} via Adaptive Minds**") - st.write(data["response"]) - - # Add assistant response to chat history - st.session_state.chat_history.append({ - "role": "assistant", - "content": data["response"], - "adapter": data["selected_adapter"] - }) - else: - st.error(f"❌ Error: {result['error']}") - -# Footer -st.markdown("---") -st.markdown(f"*Connected to FastAPI server at {API_BASE_URL}*") diff --git a/playground/manage_models.py b/playground/manage_models.py deleted file mode 100644 index 4c32f9f..0000000 --- a/playground/manage_models.py +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env python3 -""" -Adaptive Minds - Model Management CLI - -Centralized tool to manage models, LoRAs, and configurations. - -Usage: - python manage_models.py sync # Download/sync all models - python manage_models.py list # List all configured models - python manage_models.py add-lora # Register a new local LoRA - python manage_models.py rebuild # Rebuild metadata.json - python manage_models.py validate # Validate configuration - python manage_models.py discover # Discover unconfigured local LoRAs -""" - -import argparse -import yaml -import json -import os -import sys -from pathlib import Path -from typing import Dict, List, Optional -import logging - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - -CONFIG_FILE = "models_config.yaml" -METADATA_FILE = "metadata.json" - -class ModelManager: - def __init__(self, config_path: str = CONFIG_FILE): - """Initialize model manager with configuration.""" - if not os.path.exists(config_path): - logger.error(f"Configuration file not found: {config_path}") - logger.info("💡 Create models_config.yaml first or copy from models_config.example.yaml") - sys.exit(1) - - with open(config_path) as f: - self.config = yaml.safe_load(f) - - # Login to HF if token available - hf_token = os.getenv('HF_TOKEN') - if hf_token and hf_token != "your_huggingface_token_here": - try: - from huggingface_hub import login - login(token=hf_token) - logger.info("✅ Logged in to Hugging Face") - except Exception as e: - logger.warning(f"⚠️ Failed to login to HF: {e}") - else: - logger.warning("⚠️ No HF_TOKEN provided. Some models may require authentication.") - logger.info("💡 Get your token from: https://huggingface.co/settings/tokens") - - def sync_models(self): - """Download/sync all configured models.""" - logger.info("🔄 Syncing models from config...") - - # Sync base model - base = self.config['base_model'] - if base['source'] == 'huggingface': - self._download_model(base['huggingface_id'], base['local_path'], "base model") - elif base['source'] == 'local': - if os.path.exists(base['local_path']): - logger.info(f"✅ Local base model found: {base['local_path']}") - else: - logger.error(f"❌ Local base model not found: {base['local_path']}") - - # Sync LoRA adapters - enabled_adapters = [a for a in self.config['lora_adapters'] if a.get('enabled', True)] - logger.info(f"📦 Found {len(enabled_adapters)} enabled LoRA adapters") - - for adapter in enabled_adapters: - if adapter['source'] == 'huggingface': - self._download_model( - adapter['huggingface_id'], - adapter['local_path'], - f"LoRA: {adapter['name']}" - ) - elif adapter['source'] == 'local': - if os.path.exists(adapter['local_path']): - logger.info(f"✅ Local LoRA found: {adapter['name']}") - else: - logger.warning(f"⚠️ Local LoRA missing: {adapter['name']} at {adapter['local_path']}") - - logger.info("\n✅ Sync complete!") - - def _download_model(self, model_id: str, local_path: str, model_type: str): - """Download model from HuggingFace.""" - logger.info(f"📥 Downloading {model_type}: {model_id}") - try: - from huggingface_hub import snapshot_download - snapshot_download( - repo_id=model_id, - local_dir=local_path, - resume_download=True - ) - logger.info(f"✅ Downloaded: {model_id}") - except Exception as e: - if "401" in str(e) or "gated" in str(e).lower() or "access" in str(e).lower(): - logger.error(f"❌ Access denied for {model_id}") - logger.error(f"💡 This model requires authentication. Please:") - logger.error(f" 1. Get a token from: https://huggingface.co/settings/tokens") - logger.error(f" 2. Accept the license at: https://huggingface.co/{model_id}") - logger.error(f" 3. Set HF_TOKEN environment variable") - else: - logger.error(f"❌ Failed to download {model_id}: {e}") - - def list_models(self): - """List all configured models.""" - print("\n📊 Configured Models\n" + "="*60) - - # Base model - base = self.config['base_model'] - print(f"\n🔹 Base Model: {base['name']}") - print(f" Source: {base['source']}") - print(f" Path: {base['local_path']}") - if base['source'] == 'huggingface': - print(f" HF ID: {base['huggingface_id']}") - - # Check if exists - exists = "📁" if os.path.exists(base['local_path']) else "❓" - print(f" Status: {exists}") - - # LoRA adapters - adapters = self.config['lora_adapters'] - enabled_count = sum(1 for a in adapters if a.get('enabled', True)) - print(f"\n🔹 LoRA Adapters ({len(adapters)} total, {enabled_count} enabled):\n") - - for i, adapter in enumerate(adapters, 1): - status = "✅" if adapter.get('enabled', True) else "❌" - exists = "📁" if os.path.exists(adapter['local_path']) else "❓" - print(f"{i}. {status} {exists} {adapter['name']}") - print(f" Source: {adapter['source']}") - if adapter['source'] == 'huggingface': - print(f" HF ID: {adapter.get('huggingface_id', 'N/A')}") - print(f" Path: {adapter['local_path']}") - print(f" Description: {adapter['description']}") - print() - - def rebuild_metadata(self): - """Rebuild metadata.json from config.""" - logger.info("🔨 Rebuilding metadata.json...") - - # Build metadata structure - metadata = { - "base_model": { - "name": self.config['base_model']['name'], - "path": self.config['base_model']['local_path'] - }, - "router_prompt_template": self.config['router']['prompt_template'], - "adapters": {}, - "_metadata": self.config['metadata'] - } - - # Add enabled adapters - enabled_count = 0 - for adapter in self.config['lora_adapters']: - if not adapter.get('enabled', True): - continue - - metadata['adapters'][adapter['name']] = { - "path": adapter['local_path'], - "description": adapter['description'], - "system_prompt": adapter['system_prompt'], - "keywords": adapter['keywords'] - } - enabled_count += 1 - - # Write metadata.json - with open(METADATA_FILE, 'w') as f: - json.dump(metadata, f, indent=2) - - logger.info(f"✅ Generated {METADATA_FILE} with {enabled_count} adapters") - - def add_local_lora(self, name: str, path: str, description: str, - system_prompt: str, keywords: List[str]): - """Add a new local LoRA to config.""" - logger.info(f"➕ Adding local LoRA: {name}") - - # Check if LoRA exists - if not os.path.exists(path): - logger.error(f"❌ LoRA not found at: {path}") - return False - - # Check if adapter_config.json exists (indicates it's a LoRA) - if not os.path.exists(os.path.join(path, "adapter_config.json")): - logger.warning(f"⚠️ No adapter_config.json found. This might not be a LoRA directory.") - - new_adapter = { - "name": name, - "source": "local", - "local_path": path, - "description": description, - "system_prompt": system_prompt, - "keywords": keywords, - "enabled": True - } - - self.config['lora_adapters'].append(new_adapter) - - # Save config - with open(CONFIG_FILE, 'w') as f: - yaml.dump(self.config, f, default_flow_style=False, sort_keys=False) - - logger.info(f"✅ Added {name} to {CONFIG_FILE}") - logger.info("💡 Run 'python manage_models.py rebuild' to update metadata.json") - return True - - def validate_config(self): - """Validate configuration and check file existence.""" - logger.info("🔍 Validating configuration...") - issues = [] - - # Check base model - base = self.config['base_model'] - if base['source'] == 'local' and not os.path.exists(base['local_path']): - issues.append(f"Base model not found: {base['local_path']}") - - # Check LoRAs - for adapter in self.config['lora_adapters']: - if not adapter.get('enabled', True): - continue - - if adapter['source'] == 'local' and not os.path.exists(adapter['local_path']): - issues.append(f"LoRA not found: {adapter['name']} at {adapter['local_path']}") - - if issues: - logger.warning("⚠️ Issues found:") - for issue in issues: - logger.warning(f" - {issue}") - return False - else: - logger.info("✅ Configuration valid!") - return True - - def discover_local_loras(self): - """Auto-discover untrained LoRAs in loras/ directory.""" - logger.info("🔍 Discovering local LoRAs...") - - loras_dir = Path("./loras") - if not loras_dir.exists(): - logger.warning("⚠️ ./loras directory not found") - return - - # Get configured LoRAs - configured_paths = {adapter['local_path'] for adapter in self.config['lora_adapters']} - - # Find LoRAs - discovered = [] - for item in loras_dir.iterdir(): - if item.is_dir(): - # Check if it looks like a LoRA (has adapter_config.json) - if (item / "adapter_config.json").exists(): - lora_path = f"./loras/{item.name}" - if lora_path not in configured_paths: - discovered.append(item.name) - - if discovered: - logger.info(f"\n📦 Found {len(discovered)} unconfigured LoRAs:") - for name in discovered: - logger.info(f" - {name}") - logger.info("\n💡 To add them, use:") - logger.info(" python manage_models.py add-lora --name --path ./loras/") - else: - logger.info("✅ All local LoRAs are configured") - - def enable_adapter(self, name: str): - """Enable a disabled adapter.""" - for adapter in self.config['lora_adapters']: - if adapter['name'] == name: - adapter['enabled'] = True - logger.info(f"✅ Enabled adapter: {name}") - - # Save config - with open(CONFIG_FILE, 'w') as f: - yaml.dump(self.config, f, default_flow_style=False, sort_keys=False) - return True - - logger.error(f"❌ Adapter not found: {name}") - return False - - def disable_adapter(self, name: str): - """Disable an adapter.""" - for adapter in self.config['lora_adapters']: - if adapter['name'] == name: - adapter['enabled'] = False - logger.info(f"✅ Disabled adapter: {name}") - - # Save config - with open(CONFIG_FILE, 'w') as f: - yaml.dump(self.config, f, default_flow_style=False, sort_keys=False) - return True - - logger.error(f"❌ Adapter not found: {name}") - return False - -def main(): - parser = argparse.ArgumentParser(description="Adaptive Minds Model Management") - subparsers = parser.add_subparsers(dest='command', help='Commands') - - # Sync command - subparsers.add_parser('sync', help='Download/sync all models from config') - - # List command - subparsers.add_parser('list', help='List all configured models') - - # Rebuild command - subparsers.add_parser('rebuild', help='Rebuild metadata.json from config') - - # Validate command - subparsers.add_parser('validate', help='Validate configuration') - - # Discover command - subparsers.add_parser('discover', help='Discover unconfigured local LoRAs') - - # Add LoRA command - add_parser = subparsers.add_parser('add-lora', help='Add a local LoRA to config') - add_parser.add_argument('--name', required=True, help='LoRA name') - add_parser.add_argument('--path', required=True, help='Local path to LoRA') - add_parser.add_argument('--description', required=True, help='Description') - add_parser.add_argument('--system-prompt', required=True, help='System prompt') - add_parser.add_argument('--keywords', nargs='+', required=True, help='Keywords for routing') - - # Enable/Disable commands - subparsers.add_parser('enable', help='Enable an adapter').add_argument('--name', required=True) - subparsers.add_parser('disable', help='Disable an adapter').add_argument('--name', required=True) - - args = parser.parse_args() - - if not args.command: - parser.print_help() - return - - manager = ModelManager() - - if args.command == 'sync': - manager.sync_models() - elif args.command == 'list': - manager.list_models() - elif args.command == 'rebuild': - manager.rebuild_metadata() - elif args.command == 'validate': - manager.validate_config() - elif args.command == 'discover': - manager.discover_local_loras() - elif args.command == 'add-lora': - manager.add_local_lora( - name=args.name, - path=args.path, - description=args.description, - system_prompt=args.system_prompt, - keywords=args.keywords - ) - elif args.command == 'enable': - manager.enable_adapter(args.name) - elif args.command == 'disable': - manager.disable_adapter(args.name) - -if __name__ == "__main__": - main() diff --git a/playground/metadata.json b/playground/metadata.json deleted file mode 100644 index 46785c7..0000000 --- a/playground/metadata.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "router_prompt_template": "Analyze this user query and select the most appropriate domain expert to handle it.\n\nQuery: \"{query}\"\n\nAvailable Domain Experts:\n{domain_list}\n\nInstructions:\n- Analyze the query carefully\n- Consider the main topic and intent\n- Choose the domain expert that best matches the query\n- If unsure or the query is general/casual, choose General\n- Respond with ONLY the domain name ({domain_names})\n\nSelected Domain:\n", - "adapters": { - "General": { - "path": "./loras/llama-8B-alpaca-2k", - "description": "General purpose assistant for everyday conversations and casual chat", - "system_prompt": "You are a helpful, knowledgeable assistant. Provide clear, detailed, and actionable responses. Give concrete examples, step-by-step instructions, and practical advice. Never mention links or URLs. Focus on delivering complete, useful information directly in your response.", - "keywords": [ - "general", - "chat", - "conversation", - "help", - "question", - "talk", - "discuss", - "explain", - "tell me", - "how are you", - "hello", - "hi", - "greetings", - "thanks", - "please", - "everyday questions" - ] - }, - "Chemistry": { - "path": "./loras/llama-8B-chemistry", - "description": "Chemistry expert for compounds, reactions, and laboratory procedures", - "system_prompt": "You are a chemistry expert. Provide detailed, accurate chemistry information with specific formulas, step-by-step reaction mechanisms, and practical procedures. Include safety information when relevant. Give complete, actionable answers without referring to external resources.", - "keywords": [ - "chemistry", - "chemical", - "molecule", - "compound", - "formula", - "acid", - "base", - "element", - "reaction", - "periodic table", - "organic", - "inorganic", - "laboratory", - "safety", - "chemical compounds", - "molecular structures" - ] - }, - "Finance": { - "path": "./loras/llama-8B-finance-alpaca", - "description": "Finance expert for investments, banking, and economic topics", - "system_prompt": "You are a finance expert. Provide detailed financial analysis, specific strategies, and actionable advice. Include concrete examples, calculations, and step-by-step guidance. Never mention external resources - deliver complete financial insights directly.", - "keywords": [ - "finance", - "financial", - "money", - "investment", - "stock", - "bond", - "trading", - "banking", - "loan", - "interest", - "economics", - "budget", - "cryptocurrency", - "company", - "business", - "financial planning" - ] - }, - "AI": { - "path": "./loras/llama-8B-gpt-ai", - "description": "AI and technology expert for machine learning and programming", - "system_prompt": "You are an AI and machine learning expert. Provide detailed, practical code examples and step-by-step explanations. Always include actual working code when asked. Explain technical concepts clearly with concrete examples. Never suggest looking things up elsewhere - provide the complete answer directly.", - "keywords": [ - "AI", - "artificial intelligence", - "machine learning", - "neural network", - "algorithm", - "programming", - "model", - "training", - "deep learning", - "NLP", - "code", - "python", - "javascript", - "technology", - "reinforcement learning", - "lora", - "fine-tuning" - ] - }, - "Medical": { - "path": "./loras/llama-8B-medical-alpaca", - "description": "Medical expert for healthcare and health information", - "system_prompt": "You are a medical knowledge expert. Provide detailed, evidence-based medical information with specific explanations about conditions, treatments, and procedures. Always include a disclaimer to consult healthcare professionals for personal medical advice. Give comprehensive answers without referring to external sources.", - "keywords": [ - "medical", - "medicine", - "health", - "disease", - "treatment", - "doctor", - "patient", - "symptom", - "diagnosis", - "healthcare", - "anatomy", - "physiology", - "drug", - "medical advice" - ] - } - }, - "_metadata": { - "version": "1.1.0", - "last_updated": "2025-10-03", - "description": "LoRA adapter configuration for Adaptive Minds", - "managed_by": "models_config.yaml - Use manage_models.py to modify" - } -} \ No newline at end of file diff --git a/playground/models_config.example.yaml b/playground/models_config.example.yaml deleted file mode 100644 index 7abb11e..0000000 --- a/playground/models_config.example.yaml +++ /dev/null @@ -1,71 +0,0 @@ -# ============================================================================ -# Adaptive Minds - Example Configuration -# ============================================================================ -# Copy this to models_config.yaml and customize for your needs -# ============================================================================ - -# Base Model Configuration -base_model: - name: "llama-3.1-8B-instruct" - source: "huggingface" - huggingface_id: "meta-llama/Llama-3.1-8B-Instruct" - local_path: "./base_model/llama-3.1-8B-instruct" - -# LoRA Adapters Configuration -lora_adapters: - - # Pre-trained General Assistant - - name: "General" - source: "huggingface" - huggingface_id: "pavan01729/llama-8B-alpaca-2k" - local_path: "./loras/llama-8B-alpaca-2k" - description: "General purpose assistant for everyday conversations" - system_prompt: "You are a helpful, knowledgeable assistant. Provide clear, detailed, and actionable responses." - keywords: [general, chat, conversation, help, question] - enabled: true - - # Example: Disabled adapter (won't be loaded) - - name: "DisabledExpert" - source: "huggingface" - huggingface_id: "someuser/disabled-lora" - local_path: "./loras/disabled-expert" - description: "This expert is disabled" - system_prompt: "You are a disabled expert" - keywords: [disabled, example] - enabled: false # This won't be loaded - - # Example: Local LoRA (trained by you) - # Uncomment after training: - # - name: "MyCustomExpert" - # source: "local" - # local_path: "./loras/my-custom-expert" - # description: "My custom trained expert" - # system_prompt: "You are an expert in my domain" - # keywords: [custom, domain, specific] - # enabled: true - -# Router Configuration -router: - prompt_template: | - Analyze this user query and select the most appropriate domain expert to handle it. - - Query: "{query}" - - Available Domain Experts: - {domain_list} - - Instructions: - - Analyze the query carefully - - Consider the main topic and intent - - Choose the domain expert that best matches the query - - If unsure or the query is general/casual, choose General - - Respond with ONLY the domain name ({domain_names}) - - Selected Domain: - -# Metadata -metadata: - version: "1.1.0" - last_updated: "2025-10-03" - description: "Example configuration for Adaptive Minds" - managed_by: "models_config.yaml - Use manage_models.py to modify" diff --git a/playground/models_config.yaml b/playground/models_config.yaml deleted file mode 100644 index 2a0a0c0..0000000 --- a/playground/models_config.yaml +++ /dev/null @@ -1,158 +0,0 @@ -# ============================================================================ -# Adaptive Minds - Models Configuration -# ============================================================================ -# This file defines all models and LoRA adapters for your system. -# Supports: HuggingFace repos, local paths, and custom configurations. -# -# Usage: -# python manage_models.py --sync # Download/sync all models -# python manage_models.py --list # List configured models -# python manage_models.py --rebuild # Rebuild metadata.json -# ============================================================================ - -# Base Model Configuration -base_model: - name: "qwen-2.5-7B-instruct" - source: "huggingface" # Options: huggingface, local, custom - huggingface_id: "Qwen/Qwen2.5-7B-Instruct" - local_path: "./base_model/qwen-2.5-7B-instruct" - # Alternative: Use local model - # source: "local" - # local_path: "/path/to/your/local/model" - -# LoRA Adapters Configuration -# Each adapter can be from HuggingFace, local path, or trained by you -lora_adapters: - - # Cool LoRA - General purpose with cool personality - - name: "Cool" - source: "huggingface" - huggingface_id: "bunnycore/Qwen-2.5-7b-Cool-lora" - local_path: "./loras/qwen-2.5-7b-cool-lora" - description: "Cool and casual assistant with a fun personality" - system_prompt: "You are a cool, helpful assistant with a fun and engaging personality. Provide clear, detailed responses while maintaining a casual and friendly tone. Give practical advice and examples. Keep responses conversational and relatable." - keywords: - - cool - - casual - - fun - - friendly - - general - - chat - - conversation - - help - - question - - talk - - discuss - - explain - - tell me - - how are you - - hello - - hi - - greetings - - thanks - - please - - everyday questions - enabled: true - - # Email LoRA - Specialized for email communication - - name: "Email" - source: "huggingface" - huggingface_id: "doubleyyh/email-tuned-qwen2-lora" - local_path: "./loras/email-tuned-qwen2-lora" - description: "Email communication expert for professional and personal correspondence" - system_prompt: "You are an email communication expert. Help users write professional emails, respond to messages, and improve their email etiquette. Provide clear, actionable advice for email composition, tone, and structure. Include examples of well-written emails when appropriate." - keywords: - - email - - communication - - professional - - correspondence - - writing - - business - - formal - - informal - - etiquette - - compose - - reply - - message - - letter - - correspondence - - business communication - - personal communication - enabled: true - - # Finance LoRA - Trained locally - - name: "Finance" - source: "local" - local_path: "./loras/finance" - description: "Finance expert for investments, banking, and economic topics" - system_prompt: "You are a finance expert. Provide detailed financial analysis, specific strategies, and actionable advice. Include concrete examples, calculations, and step-by-step guidance. Never mention external resources - deliver complete financial insights directly." - keywords: - - finance - - financial - - money - - investment - - stock - - bond - - trading - - banking - - loan - - interest - - economics - - budget - - cryptocurrency - - company - - business - - financial planning - enabled: true - - # Example 2: Local LoRA (trained by you) - # Uncomment and configure after training: - # - name: "YourCustomDomain" - # source: "local" - # local_path: "./loras/your-custom-lora" - # description: "Your custom domain expert" - # system_prompt: "You are an expert in..." - # keywords: - # - your - # - domain - # - keywords - # enabled: true - - # Example 3: From a different HuggingFace user - # - name: "LegalExpert" - # source: "huggingface" - # huggingface_id: "someuser/legal-llama-lora" - # local_path: "./loras/legal-expert" - # description: "Legal expert for law-related questions" - # system_prompt: "You are a legal expert..." - # keywords: - # - legal - # - law - # - contract - # enabled: false # Set to true when ready to use - -# Router Configuration -router: - prompt_template: | - Analyze this user query and select the most appropriate domain expert to handle it. - - Query: "{query}" - - Available Domain Experts: - {domain_list} - - Instructions: - - Analyze the query carefully - - Consider the main topic and intent - - Choose the domain expert that best matches the query - - If unsure or the query is general/casual, choose General - - Respond with ONLY the domain name ({domain_names}) - - Selected Domain: - -# Metadata -metadata: - version: "1.1.0" - last_updated: "2025-10-03" - description: "LoRA adapter configuration for Adaptive Minds" - managed_by: "models_config.yaml - Use manage_models.py to modify" diff --git a/playground/models_config_qwen.yaml b/playground/models_config_qwen.yaml deleted file mode 100644 index f68bb5f..0000000 --- a/playground/models_config_qwen.yaml +++ /dev/null @@ -1,134 +0,0 @@ -# ============================================================================ -# Adaptive Minds - Models Configuration -# ============================================================================ -# This file defines all models and LoRA adapters for your system. -# Supports: HuggingFace repos, local paths, and custom configurations. -# -# Usage: -# python manage_models.py --sync # Download/sync all models -# python manage_models.py --list # List configured models -# python manage_models.py --rebuild # Rebuild metadata.json -# ============================================================================ - -# Base Model Configuration -base_model: - name: "qwen-2.5-7B-instruct" - source: "huggingface" # Options: huggingface, local, custom - huggingface_id: "Qwen/Qwen2.5-7B-Instruct" - local_path: "./base_model/qwen-2.5-7B-instruct" - # Alternative: Use local model - # source: "local" - # local_path: "/path/to/your/local/model" - -# LoRA Adapters Configuration -# Each adapter can be from HuggingFace, local path, or trained by you -lora_adapters: - - # Cool LoRA - General purpose with cool personality - - name: "Cool" - source: "huggingface" - huggingface_id: "bunnycore/Qwen-2.5-7b-Cool-lora" - local_path: "./loras/qwen-2.5-7b-cool-lora" - description: "Cool and casual assistant with a fun personality" - system_prompt: "You are a cool, helpful assistant with a fun and engaging personality. Provide clear, detailed responses while maintaining a casual and friendly tone. Give practical advice and examples. Keep responses conversational and relatable." - keywords: - - cool - - casual - - fun - - friendly - - general - - chat - - conversation - - help - - question - - talk - - discuss - - explain - - tell me - - how are you - - hello - - hi - - greetings - - thanks - - please - - everyday questions - enabled: true - - # Email LoRA - Specialized for email communication - - name: "Email" - source: "huggingface" - huggingface_id: "doubleyyh/email-tuned-qwen2-lora" - local_path: "./loras/email-tuned-qwen2-lora" - description: "Email communication expert for professional and personal correspondence" - system_prompt: "You are an email communication expert. Help users write professional emails, respond to messages, and improve their email etiquette. Provide clear, actionable advice for email composition, tone, and structure. Include examples of well-written emails when appropriate." - keywords: - - email - - communication - - professional - - correspondence - - writing - - business - - formal - - informal - - etiquette - - compose - - reply - - message - - letter - - correspondence - - business communication - - personal communication - enabled: true - - - # Example 2: Local LoRA (trained by you) - # Uncomment and configure after training: - # - name: "YourCustomDomain" - # source: "local" - # local_path: "./loras/your-custom-lora" - # description: "Your custom domain expert" - # system_prompt: "You are an expert in..." - # keywords: - # - your - # - domain - # - keywords - # enabled: true - - # Example 3: From a different HuggingFace user - # - name: "LegalExpert" - # source: "huggingface" - # huggingface_id: "someuser/legal-llama-lora" - # local_path: "./loras/legal-expert" - # description: "Legal expert for law-related questions" - # system_prompt: "You are a legal expert..." - # keywords: - # - legal - # - law - # - contract - # enabled: false # Set to true when ready to use - -# Router Configuration -router: - prompt_template: | - Analyze this user query and select the most appropriate domain expert to handle it. - - Query: "{query}" - - Available Domain Experts: - {domain_list} - - Instructions: - - Analyze the query carefully - - Consider the main topic and intent - - Choose the domain expert that best matches the query - - If unsure or the query is general/casual, choose General - - Respond with ONLY the domain name ({domain_names}) - - Selected Domain: - -# Metadata -metadata: - version: "1.1.0" - last_updated: "2025-10-03" - description: "LoRA adapter configuration for Adaptive Minds" - managed_by: "models_config.yaml - Use manage_models.py to modify" diff --git a/playground/server.py b/playground/server.py deleted file mode 100644 index 9a225af..0000000 --- a/playground/server.py +++ /dev/null @@ -1,514 +0,0 @@ -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -import torch -from transformers import AutoTokenizer, AutoModelForCausalLM -from peft import PeftModel -import os -import gc -import json -import re -from typing import TypedDict, Annotated, Literal, List, Dict, Optional -from langgraph.graph import StateGraph, START, END -from langgraph.types import Command -import operator -import uvicorn - -app = FastAPI( - title="Adaptive Minds API", - description="LangGraph-powered multi-agent system with intelligent routing", - version="1.0.0" -) - -# Define the state that will be passed between agents -class AgentState(TypedDict): - query: str - selected_adapter: str - response: str - reasoning: str - messages: Annotated[List[dict], operator.add] - conversation_history: List[dict] - -# Global state management -class GlobalState: - base_model = None - tokenizer = None - loaded_adapters = {} - workflow = None - chat_history = [] # Global chat history - adapter_config = None # Loaded from JSON - router_prompt_template = None # Dynamic router prompt - -global_state = GlobalState() - -# ============================================================================ -# LOAD ADAPTER CONFIGURATION FROM JSON -# ============================================================================ -def load_adapter_config(config_path="./metadata.json"): - """Load LoRA adapter configuration from JSON file.""" - try: - with open(config_path, 'r') as f: - config = json.load(f) - return config - except FileNotFoundError: - raise HTTPException( - status_code=500, - detail=f"Adapter configuration file not found: {config_path}. Please create metadata.json" - ) - except json.JSONDecodeError as e: - raise HTTPException( - status_code=500, - detail=f"Invalid JSON in adapter configuration: {str(e)}" - ) - -def build_router_prompt(query: str, config: dict) -> str: - """Build dynamic router prompt from configuration.""" - adapters = config['adapters'] - template = config['router_prompt_template'] - - # Build domain list with descriptions (auto-generated from keywords) - domain_list_items = [] - for idx, (domain_name, adapter_info) in enumerate(adapters.items(), 1): - # Auto-generate routing description from keywords - keywords = adapter_info.get('keywords', []) - routing_desc = f"For {', '.join(keywords[:8])}" + (" and related topics" if len(keywords) > 8 else "") - - domain_list_items.append( - f"{idx}. {domain_name} - {routing_desc}" - ) - domain_list = "\n".join(domain_list_items) - - # Get domain names for instruction - domain_names = ", ".join(adapters.keys()) - - # Fill in template - prompt = template.format( - query=query, - domain_list=domain_list, - domain_names=domain_names - ) - - return prompt - -# ============================================================================ -# LoRA ADAPTER CONFIGURATION - Now loaded from metadata.json -# ============================================================================ -# To add your own custom LoRA adapter: -# 1. Edit metadata.json -# 2. Add a new entry to the "adapters" object with: -# - Unique domain name (key) -# - path: Path to your adapter directory -# - description: Brief description -# - system_prompt: Defines the expert's role and behavior -# - keywords: List of relevant keywords (routing description auto-generated from these) -# 3. Restart the server - the AI router will automatically recognize it! -# ============================================================================ - -LORA_ADAPTERS = {} # Will be loaded from JSON at startup - -def load_base_model(): - """Load the base model and tokenizer.""" - # Get base model path from metadata - if not hasattr(global_state, 'adapter_config') or 'base_model' not in global_state.adapter_config: - raise HTTPException(status_code=500, detail="Base model configuration not found in metadata") - - base_model_path = global_state.adapter_config['base_model']['path'] - print(f"🔄 Loading base model from: {base_model_path}") - - try: - tokenizer = AutoTokenizer.from_pretrained(base_model_path) - if tokenizer.pad_token is None: - tokenizer.pad_token = tokenizer.eos_token - - base_model = AutoModelForCausalLM.from_pretrained( - base_model_path, - torch_dtype=torch.float16, - device_map="auto", - trust_remote_code=True, - low_cpu_mem_usage=True - ) - - return base_model, tokenizer - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error loading base model: {str(e)}") - -def load_adapter(adapter_name): - """Load a specific LoRA adapter.""" - if adapter_name in global_state.loaded_adapters: - return global_state.loaded_adapters[adapter_name] - - try: - adapter_path = LORA_ADAPTERS[adapter_name]["path"] - - # Check if adapter_config.json exists directly in the path - if os.path.exists(os.path.join(adapter_path, "adapter_config.json")): - # Direct LoRA format - final_path = adapter_path - else: - # Check for checkpoint directories - checkpoint_dirs = [] - if os.path.exists(adapter_path): - for item in os.listdir(adapter_path): - item_path = os.path.join(adapter_path, item) - if os.path.isdir(item_path) and item.startswith("checkpoint-"): - checkpoint_dirs.append((item, int(item.split("-")[1]))) - - if checkpoint_dirs: - # Sort by checkpoint number and use the highest (latest) - checkpoint_dirs.sort(key=lambda x: x[1], reverse=True) - latest_checkpoint = checkpoint_dirs[0][0] - final_path = os.path.join(adapter_path, latest_checkpoint) - print(f"🔄 Using checkpoint: {latest_checkpoint}") - else: - # No checkpoints found, try the original path - final_path = adapter_path - - model = PeftModel.from_pretrained(global_state.base_model, final_path) - model.eval() - global_state.loaded_adapters[adapter_name] = model - return model - except KeyError: - raise HTTPException(status_code=404, detail=f"Adapter '{adapter_name}' not found in configuration") - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error loading {adapter_name} adapter: {str(e)}") - -def get_conversation_history(): - """Get the last 2 user-assistant exchanges from chat history.""" - history = [] - - # Get all user and assistant messages in order - user_messages = [] - assistant_messages = [] - - for message in global_state.chat_history: - if message["role"] == "user": - user_messages.append(message["content"]) - elif message["role"] == "assistant": - assistant_messages.append(message["content"]) - - # Pair them up and take the last 2 - pairs = list(zip(user_messages, assistant_messages)) - recent_pairs = pairs[-2:] if len(pairs) >= 2 else pairs - - # Convert to the expected format - for user_msg, assistant_msg in recent_pairs: - history.append({ - "user": user_msg, - "assistant": assistant_msg - }) - - return history - -def enhanced_router_agent(state: AgentState) -> AgentState: - """ - AI-Powered Router Agent: Uses the base model to intelligently select the best domain expert. - Router prompt is dynamically generated from metadata.json configuration. - """ - query = state["query"] - - # Initialize reasoning - reasoning_parts = [f"🔍 **Query**: '{query}'"] - - try: - # Build dynamic AI selection prompt from configuration - ai_prompt = build_router_prompt(query, global_state.adapter_config) - - # Generate AI selection - inputs = global_state.tokenizer(ai_prompt, return_tensors="pt", truncation=True, max_length=1024) - inputs = {k: v.to(global_state.base_model.device) for k, v in inputs.items()} - - with torch.no_grad(): - outputs = global_state.base_model.generate( - **inputs, - max_new_tokens=15, - temperature=0.3, - do_sample=True, - top_p=0.9, - pad_token_id=global_state.tokenizer.eos_token_id, - ) - - response = global_state.tokenizer.decode(outputs[0], skip_special_tokens=True) - ai_selection = response[len(ai_prompt):].strip() - - # Extract the first word/domain name from AI response - ai_domain = ai_selection.split()[0] if ai_selection.split() else "" - - # Map AI response to valid domains - selected_adapter = None - for adapter_name in LORA_ADAPTERS.keys(): - if adapter_name.lower() in ai_domain.lower(): - selected_adapter = adapter_name - break - - if selected_adapter: - reasoning_parts.extend([ - f"🤖 **AI Selection**: {selected_adapter}", - f"🎯 **AI Response**: '{ai_selection.strip()}'", - f"✅ **Decision**: {selected_adapter} via AI semantic analysis" - ]) - else: - # Fallback to General if AI response is unclear - selected_adapter = "General" - reasoning_parts.extend([ - f"⚠️ **AI Response Unclear**: '{ai_selection}'", - f"🔄 **Fallback**: Using General for safety" - ]) - - return { - **state, - "selected_adapter": selected_adapter, - "reasoning": "\n".join(reasoning_parts), - "conversation_history": state["conversation_history"] - } - - except Exception as e: - # Emergency fallback - error_reasoning = f"❌ **Router Error**: {str(e)}\n🔄 **Emergency Fallback**: Using General" - return { - **state, - "selected_adapter": "General", - "reasoning": error_reasoning, - "conversation_history": state["conversation_history"] - } - -def enhanced_expert_agent(state: AgentState) -> AgentState: - """ - Enhanced Expert Agent: Uses the selected adapter to generate domain-specific responses - with conversation history and improved prompt formatting. - """ - query = state["query"] - adapter_name = state["selected_adapter"] - conversation_history = state["conversation_history"] - - if adapter_name not in global_state.loaded_adapters: - return { - **state, - "response": f"Error: {adapter_name} adapter not loaded. Please initialize the system first." - } - - model = global_state.loaded_adapters[adapter_name] - system_prompt = LORA_ADAPTERS[adapter_name]["system_prompt"] - - try: - # Build conversation history context - history_context = "" - if conversation_history: - history_context = "\n\nPrevious conversation:\n" - for i, exchange in enumerate(conversation_history, 1): - history_context += f"User: {exchange['user']}\nAssistant: {exchange['assistant']}\n\n" - - # Enhanced prompt formatting based on adapter type - if adapter_name == "General": - # More conversational format for general chat - response_prompt = f"""<|system|> -{system_prompt}{history_context} -<|user|> -{query} -<|assistant|> -""" - else: - # Professional format for specialized domains - response_prompt = f"""System: {system_prompt}{history_context} - -Query: {query} -Response:""" - - # Tokenize and prepare inputs - inputs = global_state.tokenizer(response_prompt, return_tensors="pt", truncation=True, max_length=1024) - inputs = {k: v.to(global_state.base_model.device) for k, v in inputs.items()} - - # Generate response with optimized parameters - with torch.no_grad(): - outputs = model.generate( - **inputs, - max_new_tokens=512, - temperature=0.8, - do_sample=True, - top_p=0.92, - top_k=50, - repetition_penalty=1.15, - no_repeat_ngram_size=3, - pad_token_id=global_state.tokenizer.eos_token_id, - eos_token_id=global_state.tokenizer.eos_token_id - ) - - # Decode and clean response - full_response = global_state.tokenizer.decode(outputs[0], skip_special_tokens=True) - - # Remove prompt from response - if adapter_name == "General": - if "<|assistant|>" in full_response: - response = full_response.split("<|assistant|>")[-1].strip() - else: - response = full_response[len(response_prompt):].strip() - else: - if "Response:" in full_response: - response = full_response.split("Response:")[-1].strip() - else: - response = full_response[len(response_prompt):].strip() - - # Clean up response - response = response.replace("<|user|>", "").replace("<|system|>", "").strip() - - # Remove any remaining conversation history from response - if "Previous conversation:" in response: - response = response.split("Previous conversation:")[0].strip() - - # Remove hallucinated links and references - # Remove , [link], or generic link mentions - response = re.sub(r'|<\/link>|\[link\]|\(link\)', '', response, flags=re.IGNORECASE) - response = re.sub(r'|<\/url>|\[url\]|\(url\)', '', response, flags=re.IGNORECASE) - # Remove phrases suggesting external lookup - response = re.sub(r'you can find .{0,50}(on github|in the .{0,20} repo|on the website)', '', response, flags=re.IGNORECASE) - response = re.sub(r'check out .{0,50}(github|repository|website|documentation)', '', response, flags=re.IGNORECASE) - - # Clean up extra whitespace and punctuation - response = re.sub(r'\s+', ' ', response).strip() - response = re.sub(r'\.{2,}', '.', response) - - # Ensure response is not empty - if not response or len(response) < 10: - response = "I apologize, but I couldn't generate a proper response. Could you please rephrase your question?" - - return { - **state, - "response": response - } - - except Exception as e: - return { - **state, - "response": f"Error generating response: {str(e)}. Please try again." - } - -def create_workflow() -> StateGraph: - """Create the LangGraph workflow with enhanced agents.""" - workflow = StateGraph(AgentState) - - # Add nodes - workflow.add_node("router", enhanced_router_agent) - workflow.add_node("expert", enhanced_expert_agent) - - # Add edges - workflow.add_edge(START, "router") - workflow.add_edge("router", "expert") - workflow.add_edge("expert", END) - - return workflow.compile() - -# API Models -class ChatRequest(BaseModel): - query: str - -class ChatResponse(BaseModel): - response: str - selected_adapter: str - reasoning: str - -class SystemStatus(BaseModel): - is_initialized: bool - loaded_adapters: List[str] - gpu_memory_allocated: Optional[float] - gpu_memory_reserved: Optional[float] - -# Add startup event handler -@app.on_event("startup") -async def startup_event(): - """Initialize the system when the server starts.""" - global LORA_ADAPTERS - try: - # Load adapter configuration from JSON - print("📖 Loading adapter configuration from metadata.json...") - config = load_adapter_config() - global_state.adapter_config = config - LORA_ADAPTERS = config['adapters'] - global_state.router_prompt_template = config['router_prompt_template'] - print(f"✅ Loaded {len(LORA_ADAPTERS)} adapter configurations") - - # Load base model - print("🔄 Loading base model...") - base_model, tokenizer = load_base_model() - global_state.base_model = base_model - global_state.tokenizer = tokenizer - print("✅ Base model loaded successfully") - - # Load all adapters - print("🔄 Loading LoRA adapters...") - for adapter_name in LORA_ADAPTERS: - load_adapter(adapter_name) - print(f" ✅ Loaded {adapter_name}") - - # Initialize workflow - global_state.workflow = create_workflow() - - print("\n🎉 ALL SYSTEMS READY!") - print(f"🧠 Available domains: {', '.join(list(global_state.loaded_adapters.keys()))}") - print(f"📊 Configuration version: {config.get('_metadata', {}).get('version', 'N/A')}\n") - - except Exception as e: - print(f"❌ Error initializing system: {str(e)}") - import traceback - traceback.print_exc() - -# API Endpoints -@app.post("/chat", response_model=ChatResponse) -async def chat(request: ChatRequest): - if not global_state.workflow or not global_state.loaded_adapters: - raise HTTPException( - status_code=503, - detail="System initialization failed. Please check server logs and restart the server." - ) - - try: - # Create initial state - initial_state = { - "query": request.query, - "selected_adapter": "", - "response": "", - "reasoning": "", - "messages": [], - "conversation_history": get_conversation_history() - } - - # Run workflow - result = global_state.workflow.invoke(initial_state) - - # Update chat history - global_state.chat_history.extend([ - {"role": "user", "content": request.query}, - {"role": "assistant", "content": result["response"], "adapter": result["selected_adapter"]}, - {"role": "reasoning", "content": result["reasoning"]} - ]) - - return ChatResponse( - response=result["response"], - selected_adapter=result["selected_adapter"], - reasoning=result["reasoning"] - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.get("/status", response_model=SystemStatus) -async def get_status(): - gpu_memory_allocated = None - gpu_memory_reserved = None - - if torch.cuda.is_available(): - gpu_memory_allocated = torch.cuda.memory_allocated() / 1024**3 - gpu_memory_reserved = torch.cuda.memory_reserved() / 1024**3 - - return SystemStatus( - is_initialized=global_state.workflow is not None, - loaded_adapters=list(global_state.loaded_adapters.keys()), - gpu_memory_allocated=gpu_memory_allocated, - gpu_memory_reserved=gpu_memory_reserved - ) - -@app.delete("/chat/history") -async def clear_chat_history(): - """Clear the global chat history.""" - global_state.chat_history = [] - return {"message": "Chat history cleared"} - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8765) \ No newline at end of file diff --git a/playground/test_workflow.py b/playground/test_workflow.py deleted file mode 100644 index 8468754..0000000 --- a/playground/test_workflow.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for the generalized Adaptive Minds workflow. -This validates that all components work together correctly. -""" - -import os -import sys -import subprocess -import yaml -import json -from pathlib import Path - -def run_command(cmd, description): - """Run a command and return success status.""" - print(f"\n🔧 {description}") - print(f" Running: {cmd}") - - try: - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - if result.returncode == 0: - print(f" ✅ Success") - return True - else: - print(f" ❌ Failed: {result.stderr}") - return False - except Exception as e: - print(f" ❌ Error: {e}") - return False - -def check_file_exists(path, description): - """Check if a file exists.""" - if os.path.exists(path): - print(f"✅ {description}: {path}") - return True - else: - print(f"❌ {description}: {path} (not found)") - return False - -def validate_yaml_config(): - """Validate the YAML configuration.""" - print("\n📋 Validating models_config.yaml...") - - if not os.path.exists("models_config.yaml"): - print("❌ models_config.yaml not found") - return False - - try: - with open("models_config.yaml") as f: - config = yaml.safe_load(f) - - # Check required sections - required_sections = ['base_model', 'lora_adapters', 'router', 'metadata'] - for section in required_sections: - if section not in config: - print(f"❌ Missing section: {section}") - return False - - # Check base model - base = config['base_model'] - if 'name' not in base or 'source' not in base: - print("❌ Base model missing required fields") - return False - - # Check adapters - adapters = config['lora_adapters'] - if not isinstance(adapters, list): - print("❌ lora_adapters should be a list") - return False - - print(f"✅ Configuration valid ({len(adapters)} adapters configured)") - return True - - except Exception as e: - print(f"❌ YAML validation failed: {e}") - return False - -def test_manage_models(): - """Test the manage_models.py CLI.""" - print("\n🛠️ Testing manage_models.py...") - - # Test help - if not run_command("python manage_models.py --help", "Testing help command"): - return False - - # Test list command - if not run_command("python manage_models.py list", "Testing list command"): - return False - - # Test validate command - if not run_command("python manage_models.py validate", "Testing validate command"): - return False - - # Test discover command - if not run_command("python manage_models.py discover", "Testing discover command"): - return False - - print("✅ manage_models.py CLI working correctly") - return True - -def test_metadata_generation(): - """Test metadata.json generation.""" - print("\n📄 Testing metadata.json generation...") - - # Rebuild metadata - if not run_command("python manage_models.py rebuild", "Rebuilding metadata.json"): - return False - - # Check if metadata.json was created - if not check_file_exists("metadata.json", "Generated metadata.json"): - return False - - # Validate JSON structure - try: - with open("metadata.json") as f: - metadata = json.load(f) - - required_keys = ['router_prompt_template', 'adapters', '_metadata'] - for key in required_keys: - if key not in metadata: - print(f"❌ metadata.json missing key: {key}") - return False - - print(f"✅ metadata.json valid ({len(metadata['adapters'])} adapters)") - return True - - except Exception as e: - print(f"❌ JSON validation failed: {e}") - return False - -def test_training_script(): - """Test the training script.""" - print("\n🎓 Testing training script...") - - # Check if training script exists - if not check_file_exists("train/train.py", "Training script"): - return False - - # Test help - if not run_command("python train/train.py --help", "Testing training script help"): - return False - - print("✅ Training script accessible") - return True - -def test_server_components(): - """Test server components.""" - print("\n🌐 Testing server components...") - - # Check server.py - if not check_file_exists("server.py", "Main server"): - return False - - # Check if server can import dependencies - if not run_command("python -c 'import server; print(\"Server imports OK\")'", "Testing server imports"): - return False - - print("✅ Server components ready") - return True - -def main(): - """Run all tests.""" - print("🧪 Adaptive Minds Workflow Test") - print("=" * 50) - - # Change to playground directory - if not os.path.exists("models_config.yaml"): - print("❌ Not in playground directory or models_config.yaml missing") - print("💡 Run this from the playground directory") - return False - - tests = [ - ("YAML Configuration", validate_yaml_config), - ("Model Management CLI", test_manage_models), - ("Metadata Generation", test_metadata_generation), - ("Training Script", test_training_script), - ("Server Components", test_server_components), - ] - - results = [] - for test_name, test_func in tests: - print(f"\n{'='*20} {test_name} {'='*20}") - success = test_func() - results.append((test_name, success)) - - # Summary - print(f"\n{'='*50}") - print("📊 Test Results Summary") - print(f"{'='*50}") - - passed = 0 - for test_name, success in results: - status = "✅ PASS" if success else "❌ FAIL" - print(f"{status} {test_name}") - if success: - passed += 1 - - print(f"\n🎯 Overall: {passed}/{len(results)} tests passed") - - if passed == len(results): - print("\n🎉 All tests passed! Your workflow is ready to use.") - print("\n💡 Next steps:") - print(" 1. python manage_models.py sync # Download models") - print(" 2. python server.py # Start server") - print(" 3. python train/train.py --help # Train custom LoRAs") - else: - print(f"\n⚠️ {len(results) - passed} tests failed. Check the errors above.") - return False - - return True - -if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) diff --git a/playground/train/README.md b/playground/train/README.md deleted file mode 100644 index 7f24787..0000000 --- a/playground/train/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# 🧠 LoRA Training for Adaptive Minds - -Train new domain experts using Unsloth for 2-5x faster training with 70% less memory usage. - -## 🚀 Quick Start - -### Install Training Dependencies -```bash -# Install all dependencies including training -uv sync --extra training -``` - -### Simple Training - -**For Llama models (original):** -```bash -# Train a medical expert -uv run python train.py --dataset medalpaca/medical_meadow_medical_flashcards --lora-name medical-expert - -# Train a QA expert -uv run python train.py --dataset squad --lora-name qa-expert --steps 300 - -# Train with custom parameters -uv run python train.py --dataset your-dataset --lora-name custom-expert --steps 1000 --batch-size 4 --lora-r 32 -``` - -**For Qwen models (new):** -```bash -# Train a medical expert with Qwen -uv run python train_qwen.py --dataset medalpaca/medical_meadow_medical_flashcards --lora-name medical-expert - -# Train a QA expert with Qwen -uv run python train_qwen.py --dataset squad --lora-name qa-expert --steps 300 - -# Train with custom parameters -uv run python train_qwen.py --dataset your-dataset --lora-name custom-expert --steps 1000 --batch-size 4 --lora-r 32 -``` - -## 📋 Parameters - -- `--dataset`: Hugging Face dataset name (required) -- `--lora-name`: Name for your LoRA adapter (required) -- `--steps`: Training steps (default: 500) -- `--batch-size`: Batch size (default: 2) -- `--learning-rate`: Learning rate (default: 2e-4) -- `--lora-r`: LoRA rank (default: 16) - -## 📊 Dataset Format - -The script automatically handles these formats: -- `instruction` + `output` -- `question` + `answer` -- `input` + `output` -- Pre-formatted `text` field - -## 🔧 Using Your Trained LoRA - -1. **After training completes**, add to `server.py`: -```python -LORA_ADAPTERS = { - # ... existing adapters ... - "YourDomain": { - "path": "./loras/your-lora-name", - "description": "Your domain expert description", - "system_prompt": "You are a helpful assistant specializing in..." - }, -} -``` - -> **Note:** No keywords needed! The AI router uses semantic understanding to select the right expert. - -2. **Restart the server**: -```bash -python ../server.py -``` - -3. **Test your new expert**: -```bash -curl -X POST http://localhost:8765/chat \ - -H "Content-Type: application/json" \ - -d '{"query": "Your domain-specific question"}' -``` - -## 💡 Popular Datasets - -- **Medical**: `medalpaca/medical_meadow_medical_flashcards` -- **Legal**: `pile-of-law/pile-of-law` -- **Science**: `sciq` -- **Code**: `codeparrot/github-code` -- **Math**: `microsoft/orca-math-word-problems-200k` -- **General QA**: `squad`, `squad_v2` - -## 🎯 Training Tips - -- **Start small**: Use 500 steps for testing, increase for production -- **Memory**: Reduce `batch-size` if you get OOM errors -- **Quality**: More training steps ≠ always better, monitor for overfitting -- **LoRA rank**: Higher `lora-r` = more parameters but better adaptation - -## 🔍 Directory Structure - -``` -playground/train/ -├── train.py # Llama training script -├── train_qwen.py # Qwen training script -├── prepare_data.py # Data preparation utilities -├── train_lora.py # Advanced training script -├── configs/ # Training configurations -├── data/ # Local training data -└── datasets/ # Downloaded datasets -``` - -## ⚡ Requirements - -Training dependencies are in optional extras. Install with: -```bash -# Basic server dependencies -uv sync - -# Add training dependencies -uv sync --extra training -``` - -This installs Unsloth, Datasets, TRL, and other training dependencies. \ No newline at end of file diff --git a/playground/train/train.py b/playground/train/train.py deleted file mode 100644 index ddb271a..0000000 --- a/playground/train/train.py +++ /dev/null @@ -1,287 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple LoRA Training Script for Adaptive Minds - -Train a new domain expert with just a Hugging Face dataset and LoRA name. - -Usage: - python simple_train.py --dataset medalpaca/medical_meadow_medical_flashcards --lora-name medical-v2 - python simple_train.py --dataset squad --lora-name qa-expert --steps 300 -""" - -import argparse -import logging -import os -import sys -from typing import Optional - -# Import unsloth first for optimizations -from unsloth import FastLanguageModel, is_bfloat16_supported - -import torch -from datasets import load_dataset -from transformers import TrainingArguments -from trl import SFTTrainer - -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - -def format_prompt(example): - """Format training example into Llama chat format with Alpaca format as default.""" - instruction = None - response = None - input_text = "" - - # 1. Try Alpaca format first (instruction, input, output) - if "instruction" in example and "output" in example: - instruction = example["instruction"] - response = example["output"] - input_text = example.get("input", "") # Optional input field - - # Combine instruction and input if both exist - if input_text and input_text.strip(): - instruction = f"{instruction}\n\n{input_text}" - - # 2. Try other common formats - elif "question" in example and "answer" in example: - instruction = example["question"] - response = example["answer"] - elif "prompt" in example and "response" in example: - instruction = example["prompt"] - response = example["response"] - elif "input" in example and "output" in example: - instruction = example["input"] - response = example["output"] - elif "text" in example: - # For pre-formatted datasets - return example["text"] - elif "conversations" in example: - # Handle conversation format - convs = example["conversations"] - if len(convs) >= 2: - # Find human and assistant messages - for i in range(len(convs)-1): - if convs[i].get("from") in ["human", "user"] and convs[i+1].get("from") in ["gpt", "assistant"]: - instruction = convs[i]["value"] - response = convs[i+1]["value"] - break - else: - # Try to find any text-like fields - text_fields = [k for k in example.keys() if isinstance(example[k], str) and len(example[k]) > 10] - if len(text_fields) >= 2: - instruction = example[text_fields[0]] - response = example[text_fields[1]] - else: - # Skip examples we can't format - return None - - if not instruction or not response: - return None - - # Clean up text - instruction = str(instruction).strip() - response = str(response).strip() - - if not instruction or not response: - return None - - # Format in Llama 3.1 chat template - return f"""<|begin_of_text|><|start_header_id|>system<|end_header_id|> - -You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|> - -{instruction}<|eot_id|><|start_header_id|>assistant<|end_header_id|> - -{response}<|eot_id|>""" - -def inspect_dataset(dataset, sample_size=3): - """Inspect dataset structure to understand the format.""" - logger.info(f"📊 Dataset inspection:") - logger.info(f" Features: {list(dataset.features.keys())}") - logger.info(f" Size: {len(dataset)}") - - for i in range(min(sample_size, len(dataset))): - logger.info(f"\n Example {i+1}:") - example = dataset[i] - for key, value in example.items(): - value_str = str(value)[:100] + "..." if len(str(value)) > 100 else str(value) - logger.info(f" {key}: {value_str}") - -def train_lora( - dataset_name: str, - lora_name: str, - steps: int = 500, - batch_size: int = 2, - learning_rate: float = 2e-4, - lora_r: int = 16 -): - """Train a LoRA adapter.""" - - # Paths - base_model_path = "../base_model/llama-3.1-8B-instruct" - output_dir = f"../loras/{lora_name}" - - logger.info(f"🚀 Training LoRA: {lora_name}") - logger.info(f"📊 Dataset: {dataset_name}") - logger.info(f"💾 Output: {output_dir}") - - # Load model - logger.info("Loading base model...") - model, tokenizer = FastLanguageModel.from_pretrained( - model_name=base_model_path, - max_seq_length=2048, - dtype=None, - load_in_4bit=True, - ) - - # Add LoRA - model = FastLanguageModel.get_peft_model( - model, - r=lora_r, - target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], - lora_alpha=lora_r, - lora_dropout=0, - bias="none", - use_gradient_checkpointing="unsloth", - random_state=42, - ) - - # Load dataset - logger.info(f"Loading dataset: {dataset_name}") - try: - dataset = load_dataset(dataset_name, split="train") - except: - # Try with different splits - try: - dataset = load_dataset(dataset_name)["train"] - except: - dataset = load_dataset(dataset_name) - # Take the first available split - split_name = list(dataset.keys())[0] - dataset = dataset[split_name] - logger.info(f"Using split: {split_name}") - - logger.info(f"Dataset size: {len(dataset)}") - - # Inspect dataset structure - inspect_dataset(dataset) - - # Format dataset - def formatting_func(examples): - texts = [] - for i in range(len(examples[list(examples.keys())[0]])): - example = {key: examples[key][i] for key in examples.keys()} - formatted = format_prompt(example) - if formatted: - texts.append(formatted) - return {"text": texts} - - dataset = dataset.map(formatting_func, batched=True, remove_columns=dataset.column_names) - - # Filter out None values - dataset = dataset.filter(lambda x: x["text"] is not None) - logger.info(f"Formatted dataset size: {len(dataset)}") - - if len(dataset) == 0: - logger.error("No valid examples found in dataset!") - return - - # Split dataset into train/eval for best checkpoint selection - split_dataset = dataset.train_test_split(test_size=0.05, seed=42) - train_dataset = split_dataset["train"] - eval_dataset = split_dataset["test"] - logger.info(f"Train size: {len(train_dataset)}, Eval size: {len(eval_dataset)}") - - # Training arguments - training_args = TrainingArguments( - output_dir=output_dir, - per_device_train_batch_size=batch_size, - per_device_eval_batch_size=batch_size, - gradient_accumulation_steps=4, - warmup_steps=5, - max_steps=steps, - learning_rate=learning_rate, - fp16=not is_bfloat16_supported(), - bf16=is_bfloat16_supported(), - logging_steps=1, - optim="adamw_8bit", - weight_decay=0.01, - lr_scheduler_type="linear", - seed=42, - # Checkpoint strategy: keep only best and last checkpoints - save_strategy="steps", - save_steps=steps // 4, # Evaluate and save 4 times during training - save_total_limit=2, # Keep only 2 checkpoints (best + last) - eval_strategy="steps", - eval_steps=steps // 4, # Evaluate at same frequency as saving - load_best_model_at_end=True, # Load best checkpoint at end - metric_for_best_model="loss", # Use loss to determine best model - greater_is_better=False, # Lower loss is better - ) - - # Trainer - trainer = SFTTrainer( - model=model, - tokenizer=tokenizer, - train_dataset=train_dataset, - eval_dataset=eval_dataset, - dataset_text_field="text", - max_seq_length=2048, - dataset_num_proc=2, - packing=False, - args=training_args, - ) - - # Train - logger.info("🎯 Starting training...") - logger.info("💾 Checkpoint strategy: Keeping only best (based on eval loss) and most recent checkpoints") - trainer.train() - - # Save - logger.info("💾 Saving model...") - model.save_pretrained(output_dir) - tokenizer.save_pretrained(output_dir) - - logger.info("✅ Training completed!") - logger.info(f"📁 LoRA saved to: {output_dir}") - - # NEW: Auto-register option - logger.info("\n💡 Next steps:") - logger.info(" 1. Register your LoRA:") - logger.info(f' python manage_models.py add-lora \\') - logger.info(f' --name "{lora_name}" \\') - logger.info(f' --path "{output_dir.replace("../", "./")}" \\') - logger.info(f' --description "Your custom trained expert" \\') - logger.info(f' --system-prompt "You are an expert in your domain..." \\') - logger.info(f' --keywords domain specific keywords') - logger.info(" 2. Rebuild metadata: python manage_models.py rebuild") - logger.info(" 3. Start server: python server.py") - - # Check if manage_models.py exists and offer auto-registration - if os.path.exists("../manage_models.py"): - logger.info("\n🤖 Auto-registration available!") - logger.info(" Run this command to auto-register:") - logger.info(f' python ../manage_models.py add-lora --name "{lora_name}" --path "{output_dir.replace("../", "./")}" --description "Custom trained expert" --system-prompt "You are an expert in your domain" --keywords custom domain expert') - -def main(): - parser = argparse.ArgumentParser(description="Simple LoRA training for Adaptive Minds") - parser.add_argument("--dataset", type=str, required=True, help="Hugging Face dataset name (e.g., squad, medalpaca/medical_meadow)") - parser.add_argument("--lora-name", type=str, required=True, help="Name for the LoRA (e.g., medical-expert)") - parser.add_argument("--steps", type=int, default=500, help="Training steps (default: 500)") - parser.add_argument("--batch-size", type=int, default=2, help="Batch size (default: 2)") - parser.add_argument("--learning-rate", type=float, default=2e-4, help="Learning rate (default: 2e-4)") - parser.add_argument("--lora-r", type=int, default=16, help="LoRA rank (default: 16)") - - args = parser.parse_args() - - train_lora( - dataset_name=args.dataset, - lora_name=args.lora_name, - steps=args.steps, - batch_size=args.batch_size, - learning_rate=args.learning_rate, - lora_r=args.lora_r - ) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/playground/train/train_qwen.py b/playground/train/train_qwen.py deleted file mode 100644 index a5c706b..0000000 --- a/playground/train/train_qwen.py +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env python3 -""" -Qwen LoRA Training Script for Adaptive Minds - -Train a new domain expert with Qwen-2.5-7B-Instruct as base model. - -Usage: - python train_qwen.py --dataset medalpaca/medical_meadow_medical_flashcards --lora-name medical-expert - python train_qwen.py --dataset squad --lora-name qa-expert --steps 300 -""" - -import argparse -import logging -import os -import sys -from typing import Optional - -# Import unsloth first for optimizations -from unsloth import FastLanguageModel, is_bfloat16_supported - -import torch -from datasets import load_dataset -from transformers import TrainingArguments -from trl import SFTTrainer - -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - -def format_prompt_qwen(example): - """Format training example into Qwen chat format.""" - instruction = None - response = None - input_text = "" - - # 1. Try Alpaca format first (instruction, input, output) - if "instruction" in example and "output" in example: - instruction = example["instruction"] - response = example["output"] - input_text = example.get("input", "") # Optional input field - - # Combine instruction and input if both exist - if input_text and input_text.strip(): - instruction = f"{instruction}\n\n{input_text}" - - # 2. Try other common formats - elif "question" in example and "answer" in example: - instruction = example["question"] - response = example["answer"] - elif "prompt" in example and "response" in example: - instruction = example["prompt"] - response = example["response"] - elif "input" in example and "output" in example: - instruction = example["input"] - response = example["output"] - elif "text" in example: - # For pre-formatted datasets - return example["text"] - elif "conversations" in example: - # Handle conversation format - convs = example["conversations"] - if len(convs) >= 2: - # Find human and assistant messages - for i in range(len(convs)-1): - if convs[i].get("from") in ["human", "user"] and convs[i+1].get("from") in ["gpt", "assistant"]: - instruction = convs[i]["value"] - response = convs[i+1]["value"] - break - else: - # Try to find any text-like fields - text_fields = [k for k in example.keys() if isinstance(example[k], str) and len(example[k]) > 10] - if len(text_fields) >= 2: - instruction = example[text_fields[0]] - response = example[text_fields[1]] - else: - # Skip examples we can't format - return None - - if not instruction or not response: - return None - - # Clean up text - instruction = str(instruction).strip() - response = str(response).strip() - - if not instruction or not response: - return None - - # Format in Qwen chat template - return f"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\n{instruction}<|im_end|>\n<|im_start|>assistant\n{response}<|im_end|>" - -def inspect_dataset(dataset, sample_size=3): - """Inspect dataset structure to understand the format.""" - logger.info(f"📊 Dataset inspection:") - logger.info(f" Features: {list(dataset.features.keys())}") - logger.info(f" Size: {len(dataset)}") - - for i in range(min(sample_size, len(dataset))): - logger.info(f"\n Example {i+1}:") - example = dataset[i] - for key, value in example.items(): - value_str = str(value)[:100] + "..." if len(str(value)) > 100 else str(value) - logger.info(f" {key}: {value_str}") - -def train_lora_qwen( - dataset_name: str, - lora_name: str, - steps: int = 500, - batch_size: int = 2, - learning_rate: float = 2e-4, - lora_r: int = 16 -): - """Train a LoRA adapter for Qwen-2.5-7B-Instruct.""" - - # Paths - using Qwen base model - base_model_path = "../base_model/qwen-2.5-7B-instruct" - output_dir = f"../loras/{lora_name}" - - logger.info(f"🚀 Training Qwen LoRA: {lora_name}") - logger.info(f"📊 Dataset: {dataset_name}") - logger.info(f"💾 Output: {output_dir}") - logger.info(f"🔧 Base Model: Qwen-2.5-7B-Instruct") - - # Load model - logger.info("Loading Qwen-2.5-7B-Instruct base model...") - model, tokenizer = FastLanguageModel.from_pretrained( - model_name=base_model_path, - max_seq_length=2048, - dtype=None, - load_in_4bit=True, - ) - - # Add LoRA - Qwen uses different target modules - model = FastLanguageModel.get_peft_model( - model, - r=lora_r, - target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], - lora_alpha=lora_r, - lora_dropout=0, - bias="none", - use_gradient_checkpointing="unsloth", - random_state=42, - ) - - # Load dataset - logger.info(f"Loading dataset: {dataset_name}") - try: - dataset = load_dataset(dataset_name, split="train") - except: - # Try with different splits - try: - dataset = load_dataset(dataset_name)["train"] - except: - dataset = load_dataset(dataset_name) - # Take the first available split - split_name = list(dataset.keys())[0] - dataset = dataset[split_name] - logger.info(f"Using split: {split_name}") - - logger.info(f"Dataset size: {len(dataset)}") - - # Inspect dataset structure - inspect_dataset(dataset) - - # Format dataset with Qwen chat template - def formatting_func(examples): - texts = [] - for i in range(len(examples[list(examples.keys())[0]])): - example = {key: examples[key][i] for key in examples.keys()} - formatted = format_prompt_qwen(example) - if formatted: - texts.append(formatted) - return {"text": texts} - - dataset = dataset.map(formatting_func, batched=True, remove_columns=dataset.column_names) - - # Filter out None values - dataset = dataset.filter(lambda x: x["text"] is not None) - logger.info(f"Formatted dataset size: {len(dataset)}") - - if len(dataset) == 0: - logger.error("No valid examples found in dataset!") - return - - # Split dataset into train/eval for best checkpoint selection - split_dataset = dataset.train_test_split(test_size=0.05, seed=42) - train_dataset = split_dataset["train"] - eval_dataset = split_dataset["test"] - logger.info(f"Train size: {len(train_dataset)}, Eval size: {len(eval_dataset)}") - - # Training arguments optimized for Qwen - training_args = TrainingArguments( - output_dir=output_dir, - per_device_train_batch_size=batch_size, - per_device_eval_batch_size=batch_size, - gradient_accumulation_steps=4, - warmup_steps=5, - max_steps=steps, - learning_rate=learning_rate, - fp16=not is_bfloat16_supported(), - bf16=is_bfloat16_supported(), - logging_steps=1, - optim="adamw_8bit", - weight_decay=0.01, - lr_scheduler_type="linear", - seed=42, - # Checkpoint strategy: keep only best and last checkpoints - save_strategy="steps", - save_steps=steps // 4, # Evaluate and save 4 times during training - save_total_limit=2, # Keep only 2 checkpoints (best + last) - eval_strategy="steps", - eval_steps=steps // 4, # Evaluate at same frequency as saving - load_best_model_at_end=True, # Load best checkpoint at end - metric_for_best_model="loss", # Use loss to determine best model - greater_is_better=False, # Lower loss is better - ) - - # Trainer - trainer = SFTTrainer( - model=model, - tokenizer=tokenizer, - train_dataset=train_dataset, - eval_dataset=eval_dataset, - dataset_text_field="text", - max_seq_length=2048, - dataset_num_proc=2, - packing=False, - args=training_args, - ) - - # Train - logger.info("🎯 Starting Qwen LoRA training...") - logger.info("💾 Checkpoint strategy: Keeping only best (based on eval loss) and most recent checkpoints") - trainer.train() - - # Save - logger.info("💾 Saving Qwen LoRA...") - model.save_pretrained(output_dir) - tokenizer.save_pretrained(output_dir) - - # Also save the final model directly (not in checkpoint format) for easier loading - final_model_dir = output_dir + "_final" - logger.info(f"💾 Saving final model to: {final_model_dir}") - model.save_pretrained(final_model_dir) - tokenizer.save_pretrained(final_model_dir) - - logger.info("✅ Qwen LoRA training completed!") - logger.info(f"📁 LoRA saved to: {output_dir}") - - # Auto-register instructions - logger.info("\n💡 Next steps:") - logger.info(" 1. Register your Qwen LoRA:") - logger.info(f' python manage_models.py add-lora \\') - logger.info(f' --name "{lora_name}" \\') - logger.info(f' --path "{output_dir.replace("../", "./")}" \\') - logger.info(f' --description "Your custom trained Qwen expert" \\') - logger.info(f' --system-prompt "You are an expert in your domain..." \\') - logger.info(f' --keywords domain specific keywords') - logger.info(" 2. Rebuild metadata: python manage_models.py rebuild") - logger.info(" 3. Start server: python server.py") - - # Check if manage_models.py exists and offer auto-registration - if os.path.exists("../manage_models.py"): - logger.info("\n🤖 Auto-registration available!") - logger.info(" Run this command to auto-register:") - logger.info(f' python ../manage_models.py add-lora --name "{lora_name}" --path "{output_dir.replace("../", "./")}" --description "Custom trained Qwen expert" --system-prompt "You are an expert in your domain" --keywords custom domain expert') - -def main(): - parser = argparse.ArgumentParser(description="Qwen LoRA training for Adaptive Minds") - parser.add_argument("--dataset", type=str, required=True, help="Hugging Face dataset name (e.g., squad, medalpaca/medical_meadow)") - parser.add_argument("--lora-name", type=str, required=True, help="Name for the LoRA (e.g., medical-expert)") - parser.add_argument("--steps", type=int, default=500, help="Training steps (default: 500)") - parser.add_argument("--batch-size", type=int, default=2, help="Batch size (default: 2)") - parser.add_argument("--learning-rate", type=float, default=2e-4, help="Learning rate (default: 2e-4)") - parser.add_argument("--lora-r", type=int, default=16, help="LoRA rank (default: 16)") - - args = parser.parse_args() - - train_lora_qwen( - dataset_name=args.dataset, - lora_name=args.lora_name, - steps=args.steps, - batch_size=args.batch_size, - learning_rate=args.learning_rate, - lora_r=args.lora_r - ) - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index d4a1018..98f5aca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,53 +1,79 @@ [project] name = "adaptive-minds" -version = "1.0.0" -description = "AI-Powered Multi-Agent System with Intelligent Domain Routing" +version = "0.1.0" +description = "LoRA adapters as callable tools for agent orchestration." authors = [ - {name = "Adaptive Minds Team"} + { name = "Pavan C Shekar", email = "pavan.s@qpiai.tech" }, + { name = "Aswanth Krishnan", email = "Aswanth.krishnan@qpiai.tech" }, ] readme = "README.md" -license = {file = "LICENSE"} +license = { file = "LICENSE" } requires-python = ">=3.10" +keywords = ["lora", "agents", "react", "vllm", "peft", "routing"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] dependencies = [ - "fastapi>=0.68.0", - "pydantic>=1.8.0", - "torch>=2.0.0", - "transformers>=4.30.0", - "peft>=0.5.0", - "langgraph>=0.0.10", - "uvicorn>=0.15.0", - "boto3>=1.26.0", - "botocore>=1.29.0", - "huggingface_hub>=0.16.0", - "streamlit>=1.28.0", - "requests>=2.25.0", - "datasets>=2.14.0", - "trl>=0.7.0", - "accelerate>=0.24.0", - "bitsandbytes>=0.41.0" + "requests>=2.31", + "pyyaml>=6", + "typer>=0.12", + "huggingface_hub>=0.24", ] [project.optional-dependencies] training = [ - "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git", - "wandb>=0.16.0", - "xformers>=0.0.22" + "torch>=2.4", + "transformers>=4.46", + "peft>=0.13", + "trl>=0.11", + "datasets>=2.20", + "accelerate>=0.30", +] +serve = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "pydantic>=2.0", + # LangGraph backs the /chat?mode=langgraph endpoint. Pure-python install; + # the imperative agent.py works without it. + "langgraph>=0.2", +] +# The chat UI is a Next.js app under `ui/` — built and served via +# `docker compose up -d` (the `ui` service uses docker/Dockerfile.ui) +# or `cd ui && npm install && npm run dev` for local development. +# No Python-side UI dependency. +vllm = [ + "vllm>=0.6.3", +] +evals = [ + "datasets>=2.20", +] +tools = [ + "sympy>=1.13", + "ddgs>=6.0", + "pulp>=2.8", +] +dev = [ + "pytest>=8", + "httpx>=0.27", + "ruff>=0.6", ] - -[project.urls] -Repository = "https://github.com/qpiai/adaptive-minds" -Documentation = "https://github.com/qpiai/adaptive-minds#readme" [project.scripts] -adaptive-minds-server = "playground.server:main" -adaptive-minds-download = "playground.download_models:main" - -[tool.hatch.build.targets.wheel] -packages = ["build", "playground", "examples"] +adaptive-minds = "adaptive_minds.cli:app" -[tool.hatch.metadata] -allow-direct-references = true +[project.urls] +Repository = "https://github.com/qpiai/adaptive-minds" +Adapters = "https://huggingface.co/pavan01729/adaptive-minds-loras" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["adaptive_minds"] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..8b8ff65 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,21 @@ +# Scripts + +One-offs that don't ship in the wheel. + +| script | what it does | +|---|---| +| `capture_demo.py` | Drive the running Next.js UI (port 7007) with Playwright + Chromium through all four modes (Router → Agent → Auto → LangGraph), and write 1080p footage to `video/public/app.mp4` + a static `docs/screenshot.png`. | +| `build_demo.sh` | One-shot pipeline: capture → Remotion render (branded intro/outro) → high-quality `docs/demo.mp4` + `docs/demo.gif`. | + +```bash +# After `docker compose up -d` is healthy: +pip install playwright pillow +playwright install chromium + +scripts/build_demo.sh +# → docs/demo.mp4, docs/demo.gif, docs/screenshot.png +``` + +Tunables (env): `AM_UI_URL` (default `http://localhost:7007`), `GIF_WIDTH`, +`GIF_FPS`. The Remotion project lives in [`video/`](../video/) — see its +README to restyle the intro/outro. diff --git a/scripts/build_demo.sh b/scripts/build_demo.sh new file mode 100755 index 0000000..2ac5773 --- /dev/null +++ b/scripts/build_demo.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Build the full demo: capture app footage → Remotion intro/outro render → +# high-quality MP4 + GIF for the README. +# +# Prereqs: docker compose stack healthy on :7007, Python venv with +# playwright + chromium, Node + `npm install` already run in video/. +# +# scripts/build_demo.sh +# # → docs/demo.mp4 (1080p, branded intro + footage + outro) +# # → docs/demo.gif (README hero) +# # → docs/screenshot.png +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PY="${PYTHON:-python}" +GIF_WIDTH="${GIF_WIDTH:-1000}" +GIF_FPS="${GIF_FPS:-15}" + +echo "==> 1/4 Capturing 1080p light-mode app footage with Playwright" +"$PY" scripts/capture_demo.py + +echo "==> 2/4 Installing Remotion deps + staging BGM (if present)" +[ -d video/node_modules ] || npm --prefix video install +if [ -f docs/bgm.mp3 ]; then + cp -f docs/bgm.mp3 video/public/bgm.mp3 +else + echo " (no docs/bgm.mp3 — generating a silent placeholder track)" + ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t 2 \ + -q:a 9 video/public/bgm.mp3 >/dev/null 2>&1 +fi + +echo "==> 3/4 Rendering branded video with Remotion → docs/demo.mp4" +npm --prefix video run render + +echo "==> 4/4 Encoding high-quality GIF → docs/demo.gif" +PAL="$(mktemp --suffix=.png)" +VF="fps=${GIF_FPS},scale=${GIF_WIDTH}:-1:flags=lanczos" +ffmpeg -y -i docs/demo.mp4 -vf "${VF},palettegen=max_colors=200" "$PAL" \ + >/dev/null 2>&1 +ffmpeg -y -i docs/demo.mp4 -i "$PAL" \ + -lavfi "${VF}[x];[x][1:v]paletteuse=dither=sierra2_4a" \ + -loop 0 docs/demo.gif >/dev/null 2>&1 +rm -f "$PAL" +if command -v gifsicle >/dev/null 2>&1; then + gifsicle -O3 --lossy=40 -o docs/demo.gif docs/demo.gif 2>/dev/null || true +fi + +echo "==> done:" +ls -lh docs/demo.mp4 docs/demo.gif docs/screenshot.png diff --git a/scripts/capture_demo.py b/scripts/capture_demo.py new file mode 100644 index 0000000..c18ba5a --- /dev/null +++ b/scripts/capture_demo.py @@ -0,0 +1,166 @@ +"""Capture 1080p footage of the running Next.js chat UI for the demo video. + +Run AFTER ``docker compose up -d`` is healthy: + + pip install playwright pillow + playwright install chromium + python scripts/capture_demo.py + # → video/public/app.mp4 (raw footage, fed to Remotion) + # → docs/screenshot.png (static README fallback) + +Then composite the branded intro/outro and render the final video+GIF: + + scripts/build_demo.sh # capture → Remotion render → GIF (one shot) + +The script drives a scripted walkthrough of all four modes — Router (SQL +adapter + decision graph), Agent (calculator trace), Auto (heuristic +dispatch), and LangGraph (state-graph viz) — typing each query with a +human-like delay so the recording reads naturally. + +Kept in ``scripts/`` (not the package) so it doesn't ship in the wheel. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +UI_URL = os.environ.get("AM_UI_URL", "http://localhost:7007") +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" +APP_MP4 = ROOT / "video" / "public" / "app.mp4" +DOCS.mkdir(parents=True, exist_ok=True) +APP_MP4.parent.mkdir(parents=True, exist_ok=True) + +# Capture size MUST equal record_video_size, otherwise Playwright pins the page +# to the top-left of a larger frame (leaving blank margins) — it only scales +# DOWN to fit, never up. We lay out at a smaller 16:9 viewport so the UI reads +# big (zoomed-in), then lanczos-upscale to 1080p in ffmpeg so Remotion uses the +# footage 1:1 and it fills the whole frame. device_scale_factor=2 keeps it crisp. +CAP_W = int(os.environ.get("AM_CAP_W", "1280")) +CAP_H = int(os.environ.get("AM_CAP_H", "720")) +OUT_W, OUT_H = 1920, 1080 + +# (mode label, query, whether to expand the trace afterwards) +# Short, punchy demo: a simple route, then a multi-step LangGraph run. +SCRIPT: list[tuple[str, str, bool]] = [ + ("Router", "Top 5 customers by total revenue — write the SQL.", False), + ("LangGraph", "Find the SMILES for caffeine, then summarise its pharmacology.", True), +] + + +def _wait_for_idle(page, timeout_ms: int = 120_000) -> None: + """Wait until the run completes (the 'running (…)' pulse disappears).""" + page.wait_for_function( + "() => !document.body.innerText.includes('running (')", + timeout=timeout_ms, + ) + + +def _send(page, mode: str, query: str, expand_trace: bool) -> None: + page.get_by_role("button", name=mode, exact=False).first.click() + time.sleep(0.6) + box = page.locator("textarea") + box.click() + box.fill("") + box.type(query, delay=30) # human-like keystrokes for the video + time.sleep(0.4) + box.press("Enter") # show off Enter-to-send + _wait_for_idle(page) + time.sleep(0.8) + if expand_trace: + try: + page.get_by_text("show trace").first.click(timeout=2_000) + time.sleep(1.0) + except Exception: + pass + time.sleep(1.5) # let the viewer read the answer + + +def main() -> int: + try: + from playwright.sync_api import sync_playwright + except ImportError: + print("ERROR: install playwright first:\n" + " pip install playwright pillow\n" + " playwright install chromium", file=sys.stderr) + return 1 + + record_dir = DOCS / ".rec" + if record_dir.exists(): + shutil.rmtree(record_dir) + record_dir.mkdir(parents=True) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context( + viewport={"width": CAP_W, "height": CAP_H}, + device_scale_factor=2, + record_video_dir=str(record_dir), + record_video_size={"width": CAP_W, "height": CAP_H}, + ) + # Capture in LIGHT mode (the no-flash script reads this on load). + context.add_init_script( + "try{localStorage.setItem('am-theme','light')}catch(e){}" + ) + page = context.new_page() + print(f"[capture_demo] opening {UI_URL}", flush=True) + page.goto(UI_URL, wait_until="networkidle", timeout=30_000) + page.wait_for_selector('body[data-testid="app-ready"]', timeout=10_000) + time.sleep(1.4) # hold on the empty state + + hero_taken = False + for mode, query, expand in SCRIPT: + print(f"[capture_demo] {mode}: {query[:48]}…", flush=True) + _send(page, mode, query, expand) + if not hero_taken and mode == "Router": + page.screenshot(path=str(DOCS / "screenshot.png"), full_page=False) + print(f" → {DOCS / 'screenshot.png'}", flush=True) + hero_taken = True + + # Never ship a demo with a transient server error baked in. + body = page.inner_text("body") + bad = next((m for m in ("Internal Server Error", "Failed to fetch", + "(no response)") if m in body), None) + if bad: + context.close() + browser.close() + print(f"ERROR: a response errored during capture ({bad!r}); " + "re-run the capture.", file=sys.stderr) + return 2 + + time.sleep(1.0) + context.close() # finalises the .webm + browser.close() + + webm = next(iter(record_dir.glob("*.webm")), None) + if not webm: + print("ERROR: no .webm recorded", file=sys.stderr) + return 1 + + if not shutil.which("ffmpeg"): + print(f"[capture_demo] ffmpeg not on PATH; raw footage at {webm}", file=sys.stderr) + return 1 + + # Transcode to a clean H.264 mp4 that Remotion consumes as static input. + print(f"[capture_demo] ffmpeg → {APP_MP4}", flush=True) + subprocess.run([ + "ffmpeg", "-y", "-i", str(webm), + "-vf", f"scale={OUT_W}:{OUT_H}:flags=lanczos,fps=30", + "-c:v", "libx264", "-crf", "18", "-preset", "medium", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", + str(APP_MP4), + ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + print(f" → {APP_MP4} ({APP_MP4.stat().st_size / 1e6:.1f} MB)", flush=True) + + shutil.rmtree(record_dir, ignore_errors=True) + print("[capture_demo] done. Next: scripts/build_demo.sh " + "(or `npm --prefix video run render`).", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.sh b/setup.sh deleted file mode 100644 index 2f7587c..0000000 --- a/setup.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -# Setup script for Adaptive Minds using uv - -set -e # Exit on any error - -echo "🚀 Setting up Adaptive Minds environment with uv..." - -# Check if uv is installed -if ! command -v uv &> /dev/null; then - echo "❌ uv is not installed. Please install it first:" - echo " curl -LsSf https://astral.sh/uv/install.sh | sh" - exit 1 -fi - -# Check for HF_TOKEN -if [ -z "$HF_TOKEN" ]; then - if [ ! -f .env ]; then - echo "❌ HF_TOKEN not found. Please:" - echo " 1. Get a token from: https://huggingface.co/settings/tokens" - echo " 2. Accept Llama 3.1 license at: https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct" - echo " 3. Create .env file: echo 'HF_TOKEN=your_token_here' > .env" - exit 1 - fi -fi - -echo "✅ Installing dependencies with uv..." -uv sync - -echo "✅ Downloading models..." -cd playground -uv run python download_models.py -cd .. - -echo "🎉 Setup complete! You can now:" -echo " • Start server: cd playground && uv run python server.py" -echo " • Test API: curl http://localhost:8765/status" -echo " • Run examples: uv run python examples/basic_usage.py" \ No newline at end of file diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..a3b89f6 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,102 @@ +"""Pure-Python smoke tests — no vLLM, no GPU. + +Verifies that the shipped catalog YAML round-trips through the loader and +produces the expected adapter ids, hf_ids, and CLI argv shape. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from adaptive_minds import Adapter, keyword_pick, load_catalog +from adaptive_minds.catalog import ( + load_config, router_cfg, vllm_lora_args, _adapter_id, +) + + +HERE = Path(__file__).resolve().parents[1] +CATALOG = HERE / "catalogs" / "qwen25_30.yaml" + + +def test_catalog_file_exists(): + assert CATALOG.exists(), f"missing {CATALOG}" + + +def test_load_config_returns_dict_with_required_keys(): + cfg = load_config(CATALOG) + assert "base_model" in cfg + assert "lora_adapters" in cfg + assert isinstance(cfg["lora_adapters"], list) + assert len(cfg["lora_adapters"]) == 30, ( + f"expected 30 paper-headline adapters, got {len(cfg['lora_adapters'])}") + + +def test_load_catalog_returns_adapter_instances(): + cat = load_catalog(CATALOG) + assert len(cat) >= 1 + a = next(iter(cat.values())) + assert isinstance(a, Adapter) + assert a.id and a.name and a.hf_id + + +def test_adapter_ids_are_lowercase_no_spaces(): + cat = load_catalog(CATALOG) + for aid in cat: + assert aid == _adapter_id(aid), f"id not normalised: {aid!r}" + assert " " not in aid + assert aid == aid.lower() + + +def test_hf_ids_point_at_expected_repo(): + cat = load_catalog(CATALOG) + for a in cat.values(): + assert a.hf_id.startswith("pavan01729/adaptive-minds-loras/"), ( + f"{a.id} hf_id={a.hf_id!r} does not point at expected Hub repo") + + +def test_keyword_pick_returns_an_id_in_catalog(): + cat = load_catalog(CATALOG) + chosen, scores = keyword_pick("Write a SQL query for top customers", cat) + assert chosen in cat + assert isinstance(scores, list) + + +def test_keyword_pick_routes_sql_query_to_sql(): + cat = load_catalog(CATALOG) + chosen, _ = keyword_pick( + "Write a SQL select query joining orders and customers", cat) + assert chosen == "sql" + + +def test_vllm_lora_args_shape(): + args = vllm_lora_args(CATALOG) + assert args[0] == "--enable-lora" + assert args[1] == "--lora-modules" + # remaining entries are name=hf_id pairs + for mod in args[2:]: + assert "=" in mod + name, hf_id = mod.split("=", 1) + assert name == name.lower() + assert hf_id.startswith("pavan01729/adaptive-minds-loras/") + + +def test_router_cfg_has_required_keys(): + cfg = router_cfg(CATALOG) + for k in ("router_prompt", "router_temperature", "router_max_tokens", + "agent_max_steps", "agent_brain", "agent_brain_max_tokens"): + assert k in cfg, f"router_cfg missing {k}" + assert "{query}" in cfg["router_prompt"] + assert "{domain_list}" in cfg["router_prompt"] + + +def test_imports_dont_require_vllm(): + """Importing adaptive_minds should not pull vLLM/torch/transformers.""" + import sys + for forbidden in ("vllm", "torch", "transformers", "peft"): + assert forbidden not in sys.modules, ( + f"{forbidden} unexpectedly imported by adaptive_minds") + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_catalog_smoke.py b/tests/test_catalog_smoke.py new file mode 100644 index 0000000..1b60836 --- /dev/null +++ b/tests/test_catalog_smoke.py @@ -0,0 +1,39 @@ +"""Catalog YAML smoke test: the 2-adapter smoke catalog is the canonical +quickstart payload for docker compose; it must parse + have known shape.""" +from __future__ import annotations + +from pathlib import Path + +from adaptive_minds.catalog import load_catalog, load_config, router_cfg + +SMOKE = Path(__file__).resolve().parents[1] / "catalogs" / "qwen25_smoke.yaml" + + +def test_smoke_catalog_parses_two_adapters() -> None: + """The smoke catalog has exactly two enabled adapters with stable ids.""" + cat = load_catalog(SMOKE) + assert set(cat.keys()) == {"chemistry", "sql"} + chem = cat["chemistry"] + assert chem.id == "chemistry" + assert chem.system_prompt # adapter must declare a system prompt + assert chem.keywords, "smoke chemistry needs keyword baseline coverage" + sql = cat["sql"] + assert sql.id == "sql" + assert "sql" in sql.keywords + + +def test_smoke_catalog_router_cfg_shape() -> None: + """router_cfg(smoke) returns the keys the runtime needs at call time.""" + cfg = router_cfg(SMOKE) + assert "router_prompt" in cfg + # The prompt template must mention both placeholders or formatting breaks. + assert "{query}" in cfg["router_prompt"] + assert "{domain_list}" in cfg["router_prompt"] + assert cfg.get("base_model_id"), "router_cfg must surface the base model id" + + +def test_smoke_catalog_base_model() -> None: + """Smoke catalog ships with Qwen2.5-7B-Instruct as the base.""" + cfg = load_config(SMOKE) + assert cfg["base_model"]["hf_id"] == "Qwen/Qwen2.5-7B-Instruct" + assert int(cfg["base_model"]["max_lora_rank"]) >= 32 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a416629 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,88 @@ +"""Tests for the typer CLI. Hermetic — no vLLM, no GPU, no network.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from adaptive_minds.cli import app + +SMOKE = Path(__file__).resolve().parents[1] / "catalogs" / "qwen25_smoke.yaml" + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_help_root(runner: CliRunner) -> None: + """Top-level --help lists every subcommand we ship.""" + r = runner.invoke(app, ["--help"]) + assert r.exit_code == 0 + for sub in ("serve", "server", "route", "agent", "list"): + assert sub in r.stdout, f"missing subcommand in help: {sub}" + + +def test_list_prints_adapters(runner: CliRunner) -> None: + """`adaptive-minds list` prints one tab-separated row per adapter.""" + r = runner.invoke(app, ["list", "--catalog", str(SMOKE)]) + assert r.exit_code == 0 + lines = [line for line in r.stdout.splitlines() if line.strip()] + assert len(lines) == 2, f"smoke catalog should print 2 rows, got: {lines}" + ids = {line.split("\t", 1)[0] for line in lines} + assert ids == {"chemistry", "sql"} + + +def test_list_missing_catalog_exits_nonzero(runner: CliRunner, tmp_path: Path) -> None: + """Missing catalog → typer's `exists=True` rejects with exit code 2.""" + missing = tmp_path / "nope.yaml" + r = runner.invoke(app, ["list", "--catalog", str(missing)]) + assert r.exit_code != 0 + + +def test_serve_prints_vllm_command(runner: CliRunner) -> None: + """`serve` is a launch-helper — it should print the vllm command, not run it.""" + r = runner.invoke(app, ["serve", "--catalog", str(SMOKE)]) + assert r.exit_code == 0 + out = r.stdout + assert out.startswith("vllm serve"), f"unexpected first token: {out[:30]!r}" + assert "Qwen/Qwen2.5-7B-Instruct" in out + assert "--max-lora-rank" in out + # Both adapters should appear in --lora-modules + assert "chemistry=" in out + assert "sql=" in out + + +def test_serve_respects_base_model_override(runner: CliRunner) -> None: + """`--base-model X` swaps the model id without touching the catalog.""" + r = runner.invoke(app, ["serve", "--catalog", str(SMOKE), + "--base-model", "meta-llama/Llama-3.1-8B-Instruct"]) + assert r.exit_code == 0 + assert "meta-llama/Llama-3.1-8B-Instruct" in r.stdout + assert "Qwen/Qwen2.5-7B-Instruct" not in r.stdout + + +def test_serve_missing_base_model_errors(runner: CliRunner, tmp_path: Path) -> None: + """A catalog without base_model.hf_id and no --base-model → exit 1.""" + bad = tmp_path / "no_base.yaml" + bad.write_text(yaml.safe_dump({ + "router": {"prompt_template": "{query}\n{domain_list}"}, + "lora_adapters": [{"name": "X", "hf_subdir": "x", "enabled": True}], + })) + r = runner.invoke(app, ["serve", "--catalog", str(bad)]) + assert r.exit_code == 1 + assert "no base_model" in r.stdout.lower() + + +def test_route_requires_query(runner: CliRunner) -> None: + """`route` without --query should fail before any vLLM call.""" + r = runner.invoke(app, ["route", "--catalog", str(SMOKE)]) + assert r.exit_code != 0 + + +def test_agent_requires_query(runner: CliRunner) -> None: + """`agent` without --query should fail before any vLLM call.""" + r = runner.invoke(app, ["agent", "--catalog", str(SMOKE)]) + assert r.exit_code != 0 diff --git a/tests/test_modes.py b/tests/test_modes.py new file mode 100644 index 0000000..12c7f4e --- /dev/null +++ b/tests/test_modes.py @@ -0,0 +1,91 @@ +"""Tests for the auto-mode dispatcher + the /chat mode literal. + +LangGraph is tested only at the import + lazy-resolve layer (no live +graph compilation) so the suite stays hermetic and fast. +""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +import adaptive_minds.server as server_mod +from adaptive_minds.auto import needs_agent + + +# ---------- needs_agent heuristic ------------------------------------------ + +@pytest.mark.parametrize("q,expected", [ + ("What is the capital of France?", False), + ("Write SQL for top 5 customers.", False), + ("Compute 2**16+17, then explain it as a finance metric.", True), + ("Find the SMILES for caffeine and then describe its mechanism.", True), + ("Step by step: derive the determinant of a 3x3 matrix.", True), +]) +def test_needs_agent_heuristic(q: str, expected: bool) -> None: + use_agent, _reason = needs_agent(q) + assert use_agent is expected, f"{q!r} → {use_agent}, expected {expected}" + + +def test_needs_agent_reason_tags() -> None: + """The reason tag is a short, stable word the UI can render.""" + _u, r = needs_agent("compute 2 + 3 then summarise") + assert r in {"operators", "sequencing", "long", "multi-sentence", + "single-domain", "empty"} + + +def test_needs_agent_empty() -> None: + use_agent, reason = needs_agent("") + assert use_agent is False + assert reason == "empty" + + +# ---------- /chat mode dispatch -------------------------------------------- + +@pytest.fixture +def client(monkeypatch): + """Mounted TestClient with the smoke catalog pre-loaded.""" + from pathlib import Path + smoke = Path(__file__).resolve().parents[1] / "catalogs" / "qwen25_smoke.yaml" + server_mod.configure(smoke) + monkeypatch.setattr( + server_mod, "VLLM_BASE", "http://test.invalid:8000/v1", + ) + return TestClient(server_mod.app) + + +def test_chat_accepts_all_four_modes(client) -> None: + """ChatRequest.mode literal must accept router / agent / auto / langgraph.""" + fake = {"ok": True, "response": "ok", "mode": "test"} + with patch("adaptive_minds.server.run_router", return_value=fake), \ + patch("adaptive_minds.server.run_agent", return_value=fake), \ + patch("adaptive_minds.server.run_auto", return_value=fake), \ + patch("adaptive_minds.langgraph_agent.run_langgraph_agent", + return_value=fake, create=True): + for mode in ("router", "agent", "auto", "langgraph"): + r = client.post("/chat", json={"query": "x", "mode": mode}) + assert r.status_code == 200, f"{mode}: {r.status_code} {r.text}" + assert r.json()["request_mode"] == mode + + +def test_chat_rejects_unknown_mode(client) -> None: + """Pydantic rejects any mode outside the four-literal set with 422.""" + r = client.post("/chat", json={"query": "x", "mode": "wat"}) + assert r.status_code == 422 + + +# ---------- DEFAULT_AGENT_PROMPT.format() regression ----------------------- + +def test_default_agent_prompt_formats() -> None: + """run_agent() str.format()s the prompt. Any unescaped `{...}` in the + template (e.g. the awk example `{print "too long"}`) raises KeyError at + request time. This caught it once; keep it caught.""" + from adaptive_minds.agent import DEFAULT_AGENT_PROMPT + rendered = DEFAULT_AGENT_PROMPT.format( + tool_menu=" - calculator — sympy", + expert_menu=" - sql — SQL expert", + query="hello", + ) + assert "User query: hello" in rendered + assert '{print "too long"}' in rendered # the awk literal survives diff --git a/tests/test_prefetch.py b/tests/test_prefetch.py new file mode 100644 index 0000000..c3d0d31 --- /dev/null +++ b/tests/test_prefetch.py @@ -0,0 +1,72 @@ +"""Tests for docker/prefetch.py. Hermetic — mocks the HF Hub call.""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +PREFETCH_PATH = Path(__file__).resolve().parents[1] / "docker" / "prefetch.py" + + +def _load_prefetch_module(): + """Import docker/prefetch.py as a module (it's not on sys.path).""" + spec = importlib.util.spec_from_file_location("docker_prefetch", PREFETCH_PATH) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + sys.modules["docker_prefetch"] = mod + spec.loader.exec_module(mod) # type: ignore[union-attr] + return mod + + +@pytest.fixture +def prefetch(): + return _load_prefetch_module() + + +def test_empty_subdirs_noop(monkeypatch: pytest.MonkeyPatch, prefetch, capsys) -> None: + """No LORA_SUBDIRS → exits 0, prints a clear no-op message, no HF call.""" + monkeypatch.setenv("LORA_SUBDIRS", "") + with patch.object(prefetch, "snapshot_download") as dl: + rc = prefetch.main() + assert rc == 0 + dl.assert_not_called() + assert "nothing to do" in capsys.readouterr().out.lower() + + +def test_subdirs_trigger_snapshot_download_per_entry( + monkeypatch: pytest.MonkeyPatch, prefetch, tmp_path: Path, +) -> None: + """One snapshot_download call per comma-separated subdir, with the + correct allow_patterns (weights only — no tokenizer files).""" + monkeypatch.setenv("LORA_REPO", "fake/repo") + monkeypatch.setenv("LORA_SUBDIRS", + "qwen2.5-7b/qwen25_chem_v1_grpo,qwen2.5-7b/qwen25_sql_v1") + monkeypatch.setenv("LORA_OUT_DIR", str(tmp_path)) + fake_dl = MagicMock(return_value=str(tmp_path)) + with patch.object(prefetch, "snapshot_download", fake_dl): + rc = prefetch.main() + assert rc == 0 + assert fake_dl.call_count == 2 + # Inspect each call: repo positional, allow_patterns kw, no tokenizer files. + for call in fake_dl.call_args_list: + assert call.args[0] == "fake/repo" + patterns = call.kwargs["allow_patterns"] + assert any(p.endswith("/adapter_config.json") for p in patterns) + assert any(p.endswith("/adapter_model.safetensors") for p in patterns) + assert not any("tokenizer" in p for p in patterns), ( + "tokenizer files must be excluded so they can't override the " + "base model's chat template at serving time") + assert call.kwargs["local_dir"] == str(tmp_path) + + +def test_lora_out_dir_default(monkeypatch: pytest.MonkeyPatch, prefetch) -> None: + """Default LORA_OUT_DIR is /loras (matches the docker-compose mount).""" + monkeypatch.setenv("LORA_SUBDIRS", "x") + monkeypatch.delenv("LORA_OUT_DIR", raising=False) + fake_dl = MagicMock() + with patch.object(prefetch, "snapshot_download", fake_dl): + prefetch.main() + assert fake_dl.call_args.kwargs["local_dir"] == "/loras" diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..3671b5c --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,101 @@ +"""FastAPI route shape tests — no vLLM, no live network. + +Uses fastapi.TestClient for /health, /adapters, and pydantic validation +of /route, /agent, /chat request bodies. Actual inference calls are +mocked so the suite is hermetic. +""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from adaptive_minds import server as server_mod + + +HERE = Path(__file__).resolve().parents[1] +CATALOG = HERE / "catalogs" / "qwen25_30.yaml" + + +@pytest.fixture(scope="module", autouse=True) +def _load_catalog(): + server_mod.configure(CATALOG) + + +@pytest.fixture +def client(): + return TestClient(server_mod.app) + + +def test_health_reports_loaded_catalog(client): + r = client.get("/health") + assert r.status_code == 200 + j = r.json() + assert j["ok"] is True + assert j["n_adapters"] == 30 + assert j["catalog"] and "qwen25_30.yaml" in j["catalog"] + assert "calculator" in j["tools"] + + +def test_adapters_returns_full_catalog(client): + r = client.get("/adapters") + assert r.status_code == 200 + body = r.json() + assert len(body) == 30 + ids = {a["id"] for a in body} + assert {"sql", "chemistry", "pii", "finance"}.issubset(ids) + + +def test_route_validates_query_required(client): + r = client.post("/route", json={}) + assert r.status_code == 422 + + +def test_route_validates_query_max_length(client): + r = client.post("/route", json={"query": "x" * 9000}) + assert r.status_code == 422 + + +def test_route_invokes_run_router(client): + fake = {"ok": True, "adapter_id": "sql", "response": "SELECT 1;", + "elapsed": 0.1, "mode": "router", "steps": []} + with patch("adaptive_minds.server.run_router", return_value=fake) as m: + r = client.post("/route", json={"query": "SQL for top 5 customers"}) + assert r.status_code == 200 + assert r.json()["adapter_id"] == "sql" + m.assert_called_once() + + +def test_agent_invokes_run_agent(client): + fake = {"ok": True, "response": "the answer is 42", + "elapsed": 1.0, "mode": "agent", "steps": [], + "adapter_id": "base"} + with patch("adaptive_minds.server.run_agent", return_value=fake) as m: + r = client.post("/agent", json={"query": "What is 6*7?"}) + assert r.status_code == 200 + assert r.json()["response"] == "the answer is 42" + m.assert_called_once() + + +def test_chat_dispatches_by_mode(client): + router_fake = {"ok": True, "response": "r"} + agent_fake = {"ok": True, "response": "a"} + with patch("adaptive_minds.server.run_router", return_value=router_fake) as mr, \ + patch("adaptive_minds.server.run_agent", return_value=agent_fake) as ma: + r1 = client.post("/chat", json={"query": "x", "mode": "router"}) + r2 = client.post("/chat", json={"query": "x", "mode": "agent"}) + assert r1.json()["response"] == "r" and r1.json()["request_mode"] == "router" + assert r2.json()["response"] == "a" and r2.json()["request_mode"] == "agent" + mr.assert_called_once() + ma.assert_called_once() + + +def test_unloaded_catalog_returns_503(monkeypatch): + # Force a fresh state with no catalog loaded. + monkeypatch.setattr(server_mod, "_state", server_mod._State()) + c = TestClient(server_mod.app) + r = c.get("/adapters") + assert r.status_code == 503 + assert "Catalog not loaded" in r.json()["detail"] diff --git a/training/README.md b/training/README.md new file mode 100644 index 0000000..217ff1e --- /dev/null +++ b/training/README.md @@ -0,0 +1,104 @@ +# Training Recipes — Paper Table 2 + +Every specialist in the paper's Table 2 uses **one shared SFT recipe**: +`training/train_sft.py`. Only the dataset and adapter name change between +runs. This is the design point the paper emphasises — gains come from +dataset/task selection, not from per-benchmark hyperparameter tuning. + +## The shared recipe + +| Setting | Value | +|-----------------------|----------------------------------------------------| +| Base model | `Qwen/Qwen2.5-7B-Instruct` | +| LoRA rank / alpha | r=32, α=64 | +| LoRA target modules | q,k,v,o,gate,up,down | +| Dropout | 0.05 | +| Epochs | 3 | +| Batch size | 8 per device × 2 grad accum (effective 16) | +| Max sequence length | 1024 (override per dataset) | +| Learning rate | 2e-4, cosine schedule, 5 % warmup | +| Weight decay | 0.0 | +| Seed | 42 | +| Precision | bf16 | +| Hardware tested | single NVIDIA L40S (48 GB) | + +Install training deps with `pip install -e ".[training]"`. + +## Paper Table 2 — benchmark mapping + +The strict-scorer gains below are from the paper, all under the shared +recipe above. "Adapter" is the HF id under +`pavan01729/adaptive-minds-loras/qwen2.5-7b/`. + +| Benchmark | n | Base | Specialist gain | Adapter | Dataset notes | +|----------------------------|-----|--------|-----------------|-------------------------------|-------------------------------------| +| Spider-dev (SQL) | 300 | 12.3 % | **+29.4 pp** | `qwen25_sql_v1` | Spider train split (NL→SQL) | +| Text2Cypher | 300 | 0.0 % | **+39.3 pp** | `qwen25_cypher_v1` | Neo4j NL→Cypher (HF: `tomasonjo/text2cypher`) | +| nl2bash | 606 | 8.4 % | **+8.9 pp** | `qwen25_bash_v1` | NL2Bash (HF: `xlangai/nl2bash`) | +| LC-QuAD (SPARQL) | 100 | 0.0 % | **+59.0 pp** | `qwen25_sparql_v1` | LC-QuAD 2.0 NL→SPARQL | +| MermaidSeqBench | 100 | 4.5 % | **+16.7 pp** | `qwen25_mermaid_v1` | Internal Mermaid sequence diagrams | +| PII (NER+redaction) | 100 | 2.7 % | **+76.3 pp** | `qwen25_pii_v1` | Synthetic PII spans | +| Qiskit-HumanEval-hard | 151 | 10.6 % | **+4.6 pp** | `qwen25_quantum_v1_grpo` | SFT warm + GRPO (execution reward) | +| LEDGAR (legal, 3-stage) | 100 | 0.0 % | **+84.0 pp** | `qwen25_legal_v1_grpo` | 3-stage curriculum SFT → GRPO | +| ChEMBL canonical SMILES | 300 | 6.3 % | **+30.4 pp** | `qwen25_chem_v1_grpo` | SFT warm + GRPO (QED reward) | + +Strong-base reasoning rows (no capability gap): + +| Benchmark | Base | Specialist | Δ | Adapter | +|-------------|--------|------------|--------|-------------------------------| +| MATH-500 | 69.2 % | 67.2 % | −2.0 | `qwen25_reasoning_v1` | +| GSM8K-250 | 86.0 % | 88.0 % | +2.0 | `qwen25_reasoning_v1` | + +These two are documented in the paper as "specialists help where the base +has a real capability gap" — when the base is already strong the recipe +moves accuracy by less than ±2 pp (within seed noise). + +## Running the shared recipe + +```bash +python training/train_sft.py \ + --dataset hf://your-org/your-dataset \ + --base-model Qwen/Qwen2.5-7B-Instruct \ + --lora-name qwen25_my_specialist_v1 \ + --output-dir ./loras +``` + +Expected dataset schema: rows with `prompt` and `completion` columns. +The script joins them as `{prompt} {completion}` and trains causally on +the joined string. + +### Datasets — where to get them + +Most benchmarks use publicly available datasets: + +- **Spider**: +- **Text2Cypher**: +- **nl2bash**: +- **LC-QuAD 2.0**: +- **LEDGAR**: (subset `ledgar`) +- **ChEMBL**: +- **Qiskit-HumanEval**: + +PII, MermaidSeqBench, and the in-house validators used for the strict +scorer are described in the paper's appendices. + +## GRPO + 3-stage curriculum (legal, chemistry, quantum) + +These three specialists use the SFT recipe above as a warm-start +(stage 1), then GRPO with an execution-based reward (stage 2 / 3). The +GRPO step is not yet included in v0.1 — the published adapters above are +the final-stage checkpoints. The full GRPO recipe is on the roadmap (see +README §Roadmap). + +## Notes + +- The recipe is intentionally not tuned per benchmark. The paper's claim + is that the same recipe produces meaningful gains across nine very + different tasks — that uniformity is the result, not a missing detail. +- For datasets larger than ~50 K rows you may want to bump + `--max-seq-length`. The default 1024 was chosen for the median + Table-2 dataset. +- The adapters published on the Hub were trained on internal GPUs + (single L40S). On smaller cards reduce `per_device_train_batch_size` to + 2–4 and proportionally bump `gradient_accumulation_steps` to keep the + effective batch at 16. diff --git a/training/train_sft.py b/training/train_sft.py new file mode 100644 index 0000000..b15928a --- /dev/null +++ b/training/train_sft.py @@ -0,0 +1,157 @@ +"""Shared SFT recipe — the one training script used for every paper +specialist in Table 2. + +The recipe (r=32, α=64, 3 epochs, batch 8×2, LR 2e-4 cosine, seed 42) is +identical across the 9 benchmarks. Only the dataset, the base model, and +the output adapter name change. The dataset is expected to be a parquet +or JSONL file with `prompt` and `completion` columns; each row is rendered +as `{prompt} {completion}` and trained on causally. + +Example — train the SQL specialist: + + python training/train_sft.py \\ + --dataset hf://pavan01729/spider-sft \\ + --base-model Qwen/Qwen2.5-7B-Instruct \\ + --lora-name qwen25_sql_v1 \\ + --output-dir ./loras/ + +GRPO and 3-stage curriculum specialists (legal, chemistry, quantum) use +this script for stage 1 (SFT warm-start), then the GRPO step is run +separately — see the per-benchmark sections in `training/README.md`. +""" +from __future__ import annotations + +import argparse +import json +import os +import time +from pathlib import Path + +os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True") +os.environ.setdefault("TORCHDYNAMO_DISABLE", "1") + +RECIPE = dict( + max_seq_length=1024, + lora_r=32, lora_alpha=64, lora_dropout=0.05, + target_modules=["q_proj", "k_proj", "v_proj", "o_proj", + "gate_proj", "up_proj", "down_proj"], + learning_rate=2e-4, + num_train_epochs=3, + per_device_train_batch_size=8, + gradient_accumulation_steps=2, + warmup_ratio=0.05, + lr_scheduler_type="cosine", + weight_decay=0.0, + seed=42, +) + + +def format_example(row: dict) -> dict: + """Render `prompt` + `completion` columns into a single training string.""" + return {"text": f"{row['prompt']} {row['completion']}"} + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--dataset", required=True, + help="HF dataset id, parquet path, or jsonl path") + p.add_argument("--base-model", required=True, + help="HF id or local path to the base model") + p.add_argument("--lora-name", required=True, + help="Output adapter directory name (under --output-dir)") + p.add_argument("--output-dir", type=Path, default=Path("./loras")) + p.add_argument("--epochs", type=int, default=RECIPE["num_train_epochs"]) + p.add_argument("--max-seq-length", type=int, + default=RECIPE["max_seq_length"]) + args = p.parse_args() + + # Heavy imports done lazily so --help works without GPU deps. + import torch + from datasets import load_dataset + from peft import LoraConfig, get_peft_model + from transformers import AutoModelForCausalLM, AutoTokenizer + from trl import SFTConfig, SFTTrainer + + out_dir = args.output_dir / args.lora_name + out_dir.mkdir(parents=True, exist_ok=True) + + print(f"[load] base={args.base_model}") + tokenizer = AutoTokenizer.from_pretrained(args.base_model) + if tokenizer.pad_token_id is None: + tokenizer.pad_token = tokenizer.eos_token + model = AutoModelForCausalLM.from_pretrained( + args.base_model, torch_dtype=torch.bfloat16, device_map="cuda", + attn_implementation="sdpa", + ) + + lora_cfg = LoraConfig( + r=RECIPE["lora_r"], lora_alpha=RECIPE["lora_alpha"], + lora_dropout=RECIPE["lora_dropout"], bias="none", + target_modules=RECIPE["target_modules"], task_type="CAUSAL_LM", + ) + model = get_peft_model(model, lora_cfg) + trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) + print(f"[lora] trainable={trainable / 1e6:.1f}M") + + # Load dataset — supports hf://, parquet, jsonl. + src = args.dataset + if src.startswith("hf://"): + raw = load_dataset(src[len("hf://"):], split="train") + elif src.endswith(".parquet"): + raw = load_dataset("parquet", data_files=src)["train"] + elif src.endswith(".jsonl") or src.endswith(".json"): + raw = load_dataset("json", data_files=src)["train"] + else: + raw = load_dataset(src, split="train") + print(f"[data] {len(raw):,} (prompt, completion) pairs") + ds = raw.map(format_example, remove_columns=raw.column_names) + print(f"[data] sample text[0]: {ds[0]['text'][:200]!r}") + + cfg = SFTConfig( + output_dir=str(out_dir), + learning_rate=RECIPE["learning_rate"], + warmup_ratio=RECIPE["warmup_ratio"], + lr_scheduler_type=RECIPE["lr_scheduler_type"], + weight_decay=RECIPE["weight_decay"], + per_device_train_batch_size=RECIPE["per_device_train_batch_size"], + gradient_accumulation_steps=RECIPE["gradient_accumulation_steps"], + num_train_epochs=args.epochs, + bf16=True, fp16=False, + max_length=args.max_seq_length, + save_strategy="epoch", save_total_limit=2, + logging_steps=5, report_to="none", seed=RECIPE["seed"], + dataset_text_field="text", + ) + trainer = SFTTrainer( + model=model, args=cfg, train_dataset=ds, + processing_class=tokenizer, + ) + t0 = time.time() + stats = trainer.train() + elapsed = time.time() - t0 + trainer.save_model(str(out_dir)) + try: + tokenizer.save_pretrained(str(out_dir)) + except Exception as e: + print(f"[warn] tokenizer save: {e}") + + metrics = stats.metrics if hasattr(stats, "metrics") else {} + log = { + "adapter": args.lora_name, + "base_model": args.base_model, + "data": {"source": args.dataset, "rows": len(raw)}, + "recipe": RECIPE, + "epochs_actual": args.epochs, + "final_train_loss": metrics.get("train_loss"), + "train_runtime_seconds": metrics.get("train_runtime", elapsed), + "peak_gpu_mem_gb": round(torch.cuda.max_memory_allocated() / 1024**3, 2), + "trainable_params_million": round(trainable / 1e6, 2), + } + (out_dir / "train_log.json").write_text( + json.dumps(log, indent=2, default=str)) + print(f"[log] {out_dir / 'train_log.json'}") + + +if __name__ == "__main__": + main() diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 0000000..1ba65cf --- /dev/null +++ b/ui/.env.example @@ -0,0 +1,15 @@ +# Where the browser fetches the FastAPI server. The server has CORS open +# (allow_origins=["*"]) so the browser hits it directly — no Next.js +# server-side proxy needed. +# +# In docker compose this resolves at build time to the host port mapping +# (default http://localhost:8765). Outside compose, set it to wherever your +# adaptive-minds server is listening. +NEXT_PUBLIC_AM_API_BASE=http://localhost:8765 + +# Default mode the UI boots in. One of: +# "router" — one LLM call → adapter answers +# "agent" — ReAct loop over tools + LoRA experts +# "auto" — heuristic chooser: simple → router, complex → agent +# "langgraph" — same ReAct loop, expressed as a LangGraph StateGraph +NEXT_PUBLIC_DEFAULT_MODE=router diff --git a/ui/next.config.mjs b/ui/next.config.mjs new file mode 100644 index 0000000..bd65e4b --- /dev/null +++ b/ui/next.config.mjs @@ -0,0 +1,24 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Standalone output keeps the runtime image small — only .next/standalone/ + // is copied into the runner stage in docker/Dockerfile.ui. + output: "standalone", + reactStrictMode: true, + // Default to relative URLs (`/api/am/*`) so the bundle is portable across + // hosts. The rewrite below proxies to the FastAPI server over the docker + // network. Override NEXT_PUBLIC_AM_API_BASE only to bypass the proxy. + // Use `||` not `??` so an empty-string env (which Docker ARG defaults + // to) also falls back to the default — `??` only catches nullish. + env: { + NEXT_PUBLIC_AM_API_BASE: process.env.NEXT_PUBLIC_AM_API_BASE || "/api/am", + }, + async rewrites() { + const upstream = + process.env.AM_UPSTREAM_BASE ?? "http://server:8765"; + return [ + { source: "/api/am/:path*", destination: `${upstream}/:path*` }, + ]; + }, +}; + +export default nextConfig; diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..92b3bd1 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,3488 @@ +{ + "name": "adaptive-minds-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "adaptive-minds-ui", + "version": "0.1.0", + "dependencies": { + "@xyflow/react": "^12.10.2", + "framer-motion": "^12.0.0", + "highlight.js": "^11.10.0", + "next": "14.2.35", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.29", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", + "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..8c6f82b --- /dev/null +++ b/ui/package.json @@ -0,0 +1,32 @@ +{ + "name": "adaptive-minds-ui", + "version": "0.1.0", + "private": true, + "description": "Next.js chat UI for the Adaptive Minds router + agent.", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start -H 0.0.0.0 -p ${PORT:-7007}", + "lint": "next lint" + }, + "dependencies": { + "@xyflow/react": "^12.10.2", + "framer-motion": "^12.0.0", + "highlight.js": "^11.10.0", + "next": "14.2.35", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/ui/postcss.config.mjs b/ui/postcss.config.mjs new file mode 100644 index 0000000..2ef30fc --- /dev/null +++ b/ui/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css new file mode 100644 index 0000000..d5213d1 --- /dev/null +++ b/ui/src/app/globals.css @@ -0,0 +1,103 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Light GitHub theme is the base; .dark overrides below recolor code blocks. */ +@import "highlight.js/styles/github.css"; + +/* ---- theme tokens -------------------------------------------------------- */ +:root { + /* light (default tokens; dark mode overrides under .dark) */ + --bg-0: #f7f8fa; + --bg-1: #eef1f5; + --scroll-thumb: rgba(15, 23, 42, 0.18); + --scroll-thumb-hover: rgba(15, 23, 42, 0.3); + --fg: #0f172a; +} + +.dark { + --bg-0: #07070b; + --bg-1: #0d0d13; + --scroll-thumb: rgba(255, 255, 255, 0.08); + --scroll-thumb-hover: rgba(255, 255, 255, 0.14); + --fg: #e5e7eb; +} + +html, +body { + background-color: var(--bg-0); + color: var(--fg); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, sans-serif; + height: 100%; +} + +/* Light: airy cool-white wash. Dark: the original deep gradient. */ +body { + background-image: + radial-gradient(circle at 18% 0%, rgba(56, 189, 248, 0.1), transparent 42%), + radial-gradient(circle at 82% 100%, rgba(16, 185, 129, 0.08), transparent 42%), + linear-gradient(180deg, var(--bg-0), var(--bg-1)); + background-attachment: fixed; +} +.dark body { + background-image: + radial-gradient(circle at 20% 0%, rgba(99, 102, 241, 0.08), transparent 40%), + radial-gradient(circle at 80% 100%, rgba(16, 185, 129, 0.06), transparent 40%), + linear-gradient(180deg, var(--bg-0), var(--bg-1)); +} + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { + background: var(--scroll-thumb); + border-radius: 8px; +} +::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); } + +@layer components { + .panel { + @apply rounded-2xl border border-neutral-200 bg-white/80 shadow-lg backdrop-blur; + } + .dark .panel { + @apply border-neutral-800/80 bg-neutral-950/40 shadow-xl; + } +} + +/* ---- ReactMarkdown code blocks ------------------------------------------ */ +.prose pre { + background: #f6f8fa !important; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 0.75rem 1rem; + overflow-x: auto; +} +.prose code { + background: rgba(15, 23, 42, 0.06); + padding: 1px 6px; + border-radius: 4px; +} +.prose pre code { background: transparent !important; padding: 0; } + +/* Dark code blocks: deep bg + GitHub-dark token palette (compact override). */ +.dark .prose pre { background: #0c0c14 !important; border-color: #26262f; } +.dark .prose code { background: rgba(255, 255, 255, 0.06); } +.dark .hljs { color: #c9d1d9; background: transparent; } +.dark .hljs-comment, .dark .hljs-quote { color: #8b949e; } +.dark .hljs-keyword, .dark .hljs-selector-tag, .dark .hljs-built_in { color: #ff7b72; } +.dark .hljs-string, .dark .hljs-attr, .dark .hljs-template-tag { color: #a5d6ff; } +.dark .hljs-number, .dark .hljs-literal { color: #79c0ff; } +.dark .hljs-title, .dark .hljs-name, .dark .hljs-section { color: #d2a8ff; } +.dark .hljs-type { color: #ffa657; } +.dark .hljs-variable, .dark .hljs-tag { color: #7ee787; } + +/* ---- xyflow controls: light is default; theme the dark variant ---------- */ +.dark .react-flow__controls { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06); +} +.dark .react-flow__controls-button { + background: #14141c; + border-bottom: 1px solid #26262f; + color: #cbd5e1; +} +.dark .react-flow__controls-button:hover { background: #1d1d28; } +.dark .react-flow__controls-button svg { fill: currentColor; } diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx new file mode 100644 index 0000000..839eb7d --- /dev/null +++ b/ui/src/app/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata, Viewport } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Adaptive Minds", + description: + "LoRA adapters as callable tools for one base model — router + ReAct agent over vLLM.", +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f7f8fa" }, + { media: "(prefers-color-scheme: dark)", color: "#07070b" }, + ], + width: "device-width", + initialScale: 1, +}; + +// Set the theme class before first paint to avoid a flash. Default is dark; +// only an explicit `am-theme=light` in localStorage switches to light. +const THEME_INIT = ` +(function(){try{ + if(localStorage.getItem('am-theme')==='light'){ + document.documentElement.classList.remove('dark'); + }else{ + document.documentElement.classList.add('dark'); + } +}catch(e){document.documentElement.classList.add('dark');}})(); +`; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +