diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..df15187a6597c6ceee9cd5badb2f5dbf38161bbc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# DigiPal - Digital Pet Application with MCP Server +# Dockerfile for HuggingFace Spaces deployment + +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Create user with ID 1000 for HF Spaces compatibility +RUN useradd -m -u 1000 user +USER user +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH + +# Switch to app directory and set ownership +WORKDIR $HOME/app + +# Install system dependencies (as root temporarily) +USER root +RUN apt-get update && apt-get install -y \ + git \ + curl \ + build-essential \ + libsndfile1 \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Switch back to user +USER user + +# Copy requirements first for better caching +COPY --chown=user requirements-hf.txt requirements.txt + +# Install Python dependencies +RUN pip install --user --no-cache-dir -r requirements.txt + +# Copy application code +COPY --chown=user . . + +# Create necessary directories +RUN mkdir -p assets/images assets/backups logs + +# Set environment variables +ENV PYTHONPATH=$HOME/app +ENV GRADIO_SERVER_NAME=0.0.0.0 +ENV GRADIO_SERVER_PORT=7860 +ENV DIGIPAL_ENV=production +ENV DIGIPAL_LOG_LEVEL=INFO + +# Expose port for Gradio +EXPOSE 7860 + +# Run the application +CMD ["python", "app.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..01ac88afa9e288b451a2e890fd298bbc7cfa2b07 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +--- +title: DigiPal - AI Digital Pet +emoji: ๐ฅ +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +license: mit +short_description: AI-powered digital pet inspired by Digimon World 1 +--- + +# ๐ฅ DigiPal - Your AI Digital Pet + +A sophisticated digital pet application inspired by Digimon World 1, featuring real-time AI interaction, dynamic evolution, and immersive pet care mechanics. + +## โจ Features + +### ๐ค Advanced AI Integration +- **Natural Language Processing** with Qwen3-0.6B for contextual conversations +- **Speech Recognition** via Kyutai speech-to-text +- **Dynamic Image Generation** using FLUX.1-dev for real-time pet visualization + +### ๐ฎ Rich Pet Mechanics +- **7 Life Stages**: Egg โ Baby โ Child โ Adult โ Champion โ Ultimate โ Elderly +- **Care System**: Feed, train, and nurture your DigiPal with 20+ care actions +- **Evolution System**: Your care quality determines evolution paths +- **Generational Inheritance**: Perfect care = 25% stat inheritance to next generation + +### ๐ Persistent World +- **Real-time Updates**: Pets age and evolve even when you're away +- **Backup System**: Automatic save states and recovery +- **Authentication**: Secure HuggingFace token integration + +## ๐ Quick Start + +### Online Mode (Recommended) +1. Enter your HuggingFace API token for full AI features +2. Select your starter egg from 4 unique types +3. Begin caring for your DigiPal through its life journey + +### Offline Mode +1. Check "Enable Offline Mode" +2. Enter any placeholder token +3. Experience core mechanics without AI features + +## ๐ฏ Gameplay Loop + +1. **Hatch Your Egg**: Choose from Flame, Ocean, Forest, or Sky eggs +2. **Daily Care**: Feed, train, play, and interact with your pet +3. **Watch Evolution**: Care quality determines evolution outcomes +4. **Generational Play**: When pets reach end-of-life, their DNA influences the next generation + +## ๐ Care Guide + +### ๐ Feeding +- **Fruits**: Increase happiness and health +- **Vegetables**: Boost training effectiveness +- **Treats**: Special happiness boost but use sparingly + +### ๐๏ธ Training +- **Strength**: Physical power and combat readiness +- **Intelligence**: Learning speed and AI interaction quality +- **Endurance**: Longevity and resistance to illness + +### ๐ Care Quality +Your pet's care level affects everything: +- **Perfect Care** (90-100%): Best evolution options, 25% inheritance +- **Excellent Care** (80-89%): Great evolutions, 20% inheritance +- **Good Care** (70-79%): Standard growth, 15% inheritance + +## ๐จ Generated Content + +DigiPal creates unique visual content for your pet using FLUX.1-dev, with intelligent caching for performance. Each pet's appearance reflects its species, life stage, and care history. + +## ๐ Privacy & Security + +- Secure token storage with encryption +- Local data processing where possible +- Optional offline mode for privacy-conscious users +- Automatic backup system protects your progress + +--- + +**Ready to start your DigiPal journey? ๐** diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0c03805969e4e90c52a0029c3532d1c05f8f0e1b --- /dev/null +++ b/__init__.py @@ -0,0 +1,6 @@ +""" +DigiPal - A digital pet application with AI communication and MCP server capabilities. +""" + +__version__ = "0.1.0" +__author__ = "DigiPal Team" \ No newline at end of file diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..025a50e0b3306a4877ee795c5c9113e2297f95bd Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..954c9f00859aaf4bcd6ab20f7c452d6c2e6b5f63 --- /dev/null +++ b/app.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +DigiPal - HuggingFace Spaces Entry Point +Simplified launcher for HuggingFace Spaces deployment. +""" + +import sys +import os +import logging +from pathlib import Path + +# Add the project root to Python path +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from digipal.core.digipal_core import DigiPalCore +from digipal.storage.storage_manager import StorageManager +from digipal.ai.communication import AICommunication +from digipal.auth.auth_manager import AuthManager +from digipal.storage.database import DatabaseConnection +from digipal.ui.gradio_interface import GradioInterface +from config import get_config + +# Configure logging for HF Spaces +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +def main(): + """Main function for HuggingFace Spaces deployment.""" + logger.info("๐ฅ Starting DigiPal on HuggingFace Spaces...") + + try: + # Get configuration + config = get_config() + + # Override for HF Spaces + config.gradio.server_name = "0.0.0.0" + config.gradio.server_port = 7860 + config.gradio.share = False + config.env = "production" + + # Initialize storage manager + db_path = "assets/digipal.db" + os.makedirs(os.path.dirname(db_path), exist_ok=True) + storage_manager = StorageManager(db_path) + logger.info(f"๐พ Storage initialized: {db_path}") + + # Initialize AI communication + ai_communication = AICommunication() + logger.info("๐ค AI system initialized") + + # Initialize DigiPal core + digipal_core = DigiPalCore(storage_manager, ai_communication) + logger.info("๐ฎ DigiPal core ready") + + # Initialize auth manager + db_connection = DatabaseConnection(db_path) + auth_manager = AuthManager(db_connection) + logger.info("๐ Authentication ready") + + # Initialize Gradio interface + gradio_interface = GradioInterface(digipal_core, auth_manager) + logger.info("๐ Interface ready") + + logger.info("โ DigiPal ready on HuggingFace Spaces!") + + # Launch the interface + gradio_interface.launch_interface( + share=False, + server_name="0.0.0.0", + server_port=7860, + debug=False, + show_error=True, + quiet=False + ) + + except Exception as e: + logger.error(f"โ Failed to start DigiPal: {e}") + raise e + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backups/backup_automatic_20250725_114923_5347.db.gz b/backups/backup_automatic_20250725_114923_5347.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..85fd4a0a6ba0e50bf356775f4546d81ba25c7193 --- /dev/null +++ b/backups/backup_automatic_20250725_114923_5347.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a87bc3a0117d6e6f9588f2e418a7e66263362daba99339213a491c92e9154fc2 +size 2728 diff --git a/backups/backup_automatic_20250725_133639_4471.db.gz b/backups/backup_automatic_20250725_133639_4471.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..ddc4f9d14f74470e2d606c21b5ae0665c409bf45 --- /dev/null +++ b/backups/backup_automatic_20250725_133639_4471.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c44bed58a2c71b237c6f22dc01a02cfaad4839d27c31bc1eea2a7386b5f0883 +size 5380 diff --git a/backups/backup_metadata.json b/backups/backup_metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..788851c4dc3d3a1320c54a93c0871d01deed22b0 --- /dev/null +++ b/backups/backup_metadata.json @@ -0,0 +1,112 @@ +{ + "pre_operation_20250724_181331_9931": { + "backup_id": "pre_operation_20250724_181331_9931", + "timestamp": "2025-07-24T18:13:31.571414", + "backup_type": "pre_operation", + "file_path": "assets/backups/backup_pre_operation_20250724_181331_9931.db.gz", + "checksum": "54b50a139fdb009c2ac259b65ba4fe61aa456427cce80401885a08a5d52ed27e", + "size_bytes": 3522, + "user_id": null, + "pet_id": null, + "description": "Pre-operation backup for: save_pet (context: {\"pet_id\": \"6ec11f65-1f77-442d-8080-eb0805ad0d1b\", \"user_id\": \"offline_14075641f857c575\"})" + }, + "pre_operation_20250724_203639_8119": { + "backup_id": "pre_operation_20250724_203639_8119", + "timestamp": "2025-07-24T20:36:39.165973", + "backup_type": "pre_operation", + "file_path": "assets/backups/backup_pre_operation_20250724_203639_8119.db.gz", + "checksum": "307166b34980dbd888eceecc5a878fb6a3b88974e01c5abcef9cc1c517dd4a74", + "size_bytes": 4877, + "user_id": null, + "pet_id": null, + "description": "Pre-operation backup for: save_pet (context: {\"pet_id\": \"e8ecb462-4d30-4ff5-9a1e-59c7b1560aee\", \"user_id\": \"offline_test_user\"})" + }, + "pre_operation_20250724_203640_6552": { + "backup_id": "pre_operation_20250724_203640_6552", + "timestamp": "2025-07-24T20:36:40.505842", + "backup_type": "pre_operation", + "file_path": "assets/backups/backup_pre_operation_20250724_203640_6552.db.gz", + "checksum": "462f91e4ceb9fbb3a4da8c3293afbb11621eed677605bad3295ce534358dfd61", + "size_bytes": 4982, + "user_id": null, + "pet_id": null, + "description": "Pre-operation backup for: save_pet (context: {\"pet_id\": \"e8ecb462-4d30-4ff5-9a1e-59c7b1560aee\", \"user_id\": \"offline_test_user\"})" + }, + "pre_operation_20250724_203659_2315": { + "backup_id": "pre_operation_20250724_203659_2315", + "timestamp": "2025-07-24T20:36:59.233483", + "backup_type": "pre_operation", + "file_path": "assets/backups/backup_pre_operation_20250724_203659_2315.db.gz", + "checksum": "4aa8720e3fc61b3b51c18eea7f892613e64e2062bf79bdc86b9eb945b36a0c52", + "size_bytes": 5148, + "user_id": null, + "pet_id": null, + "description": "Pre-operation backup for: save_pet (context: {\"pet_id\": \"3f5d0cf4-9778-493e-9b3f-6b9bfadc82bf\", \"user_id\": \"offline_9641d1601a04163b\"})" + }, + "pre_operation_20250724_203705_4609": { + "backup_id": "pre_operation_20250724_203705_4609", + "timestamp": "2025-07-24T20:37:05.858506", + "backup_type": "pre_operation", + "file_path": "assets/backups/backup_pre_operation_20250724_203705_4609.db.gz", + "checksum": "9df347df0ae4f0aad48de390f09423eea68d3ad6d5c1bf57f53922d7ab7f988e", + "size_bytes": 5275, + "user_id": null, + "pet_id": null, + "description": "Pre-operation backup for: save_pet (context: {\"pet_id\": \"3f5d0cf4-9778-493e-9b3f-6b9bfadc82bf\", \"user_id\": \"offline_9641d1601a04163b\"})" + }, + "automatic_20250725_003639_7879": { + "backup_id": "automatic_20250725_003639_7879", + "timestamp": "2025-07-25T00:36:39.195234", + "backup_type": "automatic", + "file_path": "assets/backups/backup_automatic_20250725_003639_7879.db.gz", + "checksum": "114e3819654d66ab5df44014ebd3351f68db42811d11dcf84fbbd91d04f8f548", + "size_bytes": 5380, + "user_id": null, + "pet_id": null, + "description": "Scheduled automatic backup" + }, + "automatic_20250725_073639_3575": { + "backup_id": "automatic_20250725_073639_3575", + "timestamp": "2025-07-25T07:36:39.071863", + "backup_type": "automatic", + "file_path": "assets/backups/backup_automatic_20250725_073639_3575.db.gz", + "checksum": "3a5fee38e6037b1de40329a979909d6d18dece2b7b3519dae23d5961a7f69abc", + "size_bytes": 5380, + "user_id": null, + "pet_id": null, + "description": "Scheduled automatic backup" + }, + "automatic_20250725_133639_4471": { + "backup_id": "automatic_20250725_133639_4471", + "timestamp": "2025-07-25T13:36:39.126779", + "backup_type": "automatic", + "file_path": "assets/backups/backup_automatic_20250725_133639_4471.db.gz", + "checksum": "0c44bed58a2c71b237c6f22dc01a02cfaad4839d27c31bc1eea2a7386b5f0883", + "size_bytes": 5380, + "user_id": null, + "pet_id": null, + "description": "Scheduled automatic backup" + }, + "automatic_20250725_203639_7911": { + "backup_id": "automatic_20250725_203639_7911", + "timestamp": "2025-07-25T20:36:39.073314", + "backup_type": "automatic", + "file_path": "assets/backups/backup_automatic_20250725_203639_7911.db.gz", + "checksum": "0af54843e544210b936fa48d46c5ea842f8a710e9505bbb96882aab60e14def5", + "size_bytes": 5380, + "user_id": null, + "pet_id": null, + "description": "Scheduled automatic backup" + }, + "automatic_20250726_033638_7142": { + "backup_id": "automatic_20250726_033638_7142", + "timestamp": "2025-07-26T03:36:38.970952", + "backup_type": "automatic", + "file_path": "assets/backups/backup_automatic_20250726_033638_7142.db.gz", + "checksum": "03cf2d9b3624d7b594e2200be22d80bdc5729c3018b4a1dcb0de4ad2169550de", + "size_bytes": 5380, + "user_id": null, + "pet_id": null, + "description": "Scheduled automatic backup" + } +} \ No newline at end of file diff --git a/backups/backup_pre_operation_20250725_114923_0195.db.gz b/backups/backup_pre_operation_20250725_114923_0195.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..3f224f0a586c5e09529558c19377c67813ad9657 --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_0195.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f880d5b94ba61558eae10a0d010c31c2de176ce1f2fc211ec1693a6214b92a8e +size 3289 diff --git a/backups/backup_pre_operation_20250725_114923_0627.db.gz b/backups/backup_pre_operation_20250725_114923_0627.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..2baffae8de4bcec162caaa7c4d9c4d48320b6cd5 --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_0627.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:433327e33b5504e1bd3a254f1e8954e6480fee2e672db9db75a526610e17611c +size 3583 diff --git a/backups/backup_pre_operation_20250725_114923_3347.db.gz b/backups/backup_pre_operation_20250725_114923_3347.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..a277701711bf7792e640345aa9efc8008855c2a0 --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_3347.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d797dbc3a2dcb7a2826ef279090d8a9e9398a7b16dcae124772bde31170ac81 +size 2918 diff --git a/backups/backup_pre_operation_20250725_114923_3459.db.gz b/backups/backup_pre_operation_20250725_114923_3459.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..2752501a1dd62e73d6dfb6fe4a9c34e8e1840deb --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_3459.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bf3350cefa96a2aa81534180f1234459f64b2505f740fbd566bf92d4de48c6e +size 3442 diff --git a/backups/backup_pre_operation_20250725_114923_5379.db.gz b/backups/backup_pre_operation_20250725_114923_5379.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..fd11cfefae6de2788e93d38569393f34aa9c0400 --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_5379.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61880230367bf90b7eeb57670744977c2f72ee61474fdf88fd907b1951834cfb +size 3768 diff --git a/backups/backup_pre_operation_20250725_114923_8771.db.gz b/backups/backup_pre_operation_20250725_114923_8771.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..765a7f6bfb381fe2f3ad249df543cd02a946c349 --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_8771.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a17ecd54b8574ec843eaf47341e67c1a7ef8bef48295e93321ecca043d1489b1 +size 3707 diff --git a/backups/backup_pre_operation_20250725_114923_9475.db.gz b/backups/backup_pre_operation_20250725_114923_9475.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..96973d002d406ceee01dac3fe2576be6c0bd39f6 --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_9475.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5052638f4228eb6f89ec98b8f81d4d67278e1caf6f070c0b125e5993f786d91c +size 3138 diff --git a/backups/backup_pre_operation_20250725_114923_9731.db.gz b/backups/backup_pre_operation_20250725_114923_9731.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..e2d3b2335238e85b2e4e3f610564d3329d06cd93 --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_9731.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a95cb356dd093de19e842f43d4bdedcd5e0226acb95156611f82fe0f138b0dc +size 3810 diff --git a/backups/backup_pre_operation_20250725_114923_9779.db.gz b/backups/backup_pre_operation_20250725_114923_9779.db.gz new file mode 100644 index 0000000000000000000000000000000000000000..ea893564de4ccacca3eeb68621c5cf486effec54 --- /dev/null +++ b/backups/backup_pre_operation_20250725_114923_9779.db.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17fdec64d28f5d7d5f1b0e617544564e8657771f7e51182669066f54a4484e66 +size 3062 diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..e0a55fa01d0bec2ef1b468d28eeafde3f89c49b7 --- /dev/null +++ b/config.py @@ -0,0 +1,296 @@ +""" +DigiPal Configuration Management +Handles environment-specific configuration for deployment +""" + +import os +from typing import Optional, Dict, Any +from dataclasses import dataclass +from pathlib import Path +import logging + + +@dataclass +class DatabaseConfig: + """Database configuration settings""" + path: str = "digipal.db" + backup_dir: str = "assets/backups" + backup_interval_hours: int = 24 + max_backups: int = 10 + + +@dataclass +class AIModelConfig: + """AI model configuration settings""" + qwen_model: str = "Qwen/Qwen3-0.6B" + kyutai_model: str = "kyutai/stt-2.6b-en_fr-trfs" + flux_model: str = "black-forest-labs/FLUX.1-dev" + device: str = "auto" + torch_dtype: str = "auto" + enable_quantization: bool = True + max_memory_gb: Optional[int] = None + + +@dataclass +class GradioConfig: + """Gradio interface configuration""" + server_name: str = "0.0.0.0" + server_port: int = 7860 + share: bool = False + debug: bool = False + auth: Optional[tuple] = None + ssl_keyfile: Optional[str] = None + ssl_certfile: Optional[str] = None + + +@dataclass +class MCPConfig: + """MCP server configuration""" + enabled: bool = True + host: str = "localhost" + port: int = 8080 + max_connections: int = 100 + timeout_seconds: int = 30 + + +@dataclass +class LoggingConfig: + """Logging configuration""" + level: str = "INFO" + format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + file_path: Optional[str] = "logs/digipal.log" + max_file_size_mb: int = 10 + backup_count: int = 5 + enable_structured_logging: bool = True + + +@dataclass +class SecurityConfig: + """Security configuration""" + secret_key: Optional[str] = None + session_timeout_hours: int = 24 + max_login_attempts: int = 5 + rate_limit_per_minute: int = 60 + enable_cors: bool = True + allowed_origins: list = None + + +@dataclass +class PerformanceConfig: + """Performance optimization settings""" + cache_size_mb: int = 512 + background_update_interval: int = 60 + max_concurrent_users: int = 100 + enable_model_caching: bool = True + image_cache_max_age_days: int = 30 + + +class DigiPalConfig: + """Main configuration class for DigiPal application""" + + def __init__(self, env: str = None): + self.env = env or os.getenv("DIGIPAL_ENV", "development") + self.load_config() + + def load_config(self): + """Load configuration based on environment""" + # Base configuration + self.database = DatabaseConfig() + self.ai_models = AIModelConfig() + self.gradio = GradioConfig() + self.mcp = MCPConfig() + self.logging = LoggingConfig() + self.security = SecurityConfig() + self.performance = PerformanceConfig() + + # Environment-specific overrides + if self.env == "production": + self._load_production_config() + elif self.env == "testing": + self._load_testing_config() + elif self.env == "development": + self._load_development_config() + + # Load from environment variables + self._load_from_env() + + # Validate configuration + self._validate_config() + + def _load_production_config(self): + """Production environment configuration""" + self.database.path = "/app/data/digipal.db" + self.database.backup_dir = "/app/data/backups" + + self.gradio.debug = False + self.gradio.share = False + + self.logging.level = "INFO" + self.logging.file_path = "/app/logs/digipal.log" + + self.security.session_timeout_hours = 12 + self.security.rate_limit_per_minute = 30 + + self.performance.cache_size_mb = 1024 + self.performance.max_concurrent_users = 500 + + def _load_testing_config(self): + """Testing environment configuration""" + self.database.path = "test_digipal.db" + self.database.backup_dir = "test_assets/backups" + + self.gradio.debug = True + self.gradio.server_port = 7861 + + self.logging.level = "DEBUG" + self.logging.file_path = None # Console only + + self.ai_models.enable_quantization = False + self.performance.cache_size_mb = 128 + + def _load_development_config(self): + """Development environment configuration""" + self.gradio.debug = True + self.gradio.share = False + + self.logging.level = "DEBUG" + self.logging.file_path = "logs/digipal_dev.log" + + self.security.rate_limit_per_minute = 120 + self.performance.cache_size_mb = 256 + + def _load_from_env(self): + """Load configuration from environment variables""" + # Database + if os.getenv("DIGIPAL_DB_PATH"): + self.database.path = os.getenv("DIGIPAL_DB_PATH") + + # Gradio + if os.getenv("GRADIO_SERVER_NAME"): + self.gradio.server_name = os.getenv("GRADIO_SERVER_NAME") + if os.getenv("GRADIO_SERVER_PORT"): + self.gradio.server_port = int(os.getenv("GRADIO_SERVER_PORT")) + if os.getenv("GRADIO_SHARE"): + self.gradio.share = os.getenv("GRADIO_SHARE").lower() == "true" + + # Logging + if os.getenv("DIGIPAL_LOG_LEVEL"): + self.logging.level = os.getenv("DIGIPAL_LOG_LEVEL") + if os.getenv("DIGIPAL_LOG_FILE"): + self.logging.file_path = os.getenv("DIGIPAL_LOG_FILE") + + # Security + if os.getenv("DIGIPAL_SECRET_KEY"): + self.security.secret_key = os.getenv("DIGIPAL_SECRET_KEY") + + # AI Models + if os.getenv("QWEN_MODEL"): + self.ai_models.qwen_model = os.getenv("QWEN_MODEL") + if os.getenv("KYUTAI_MODEL"): + self.ai_models.kyutai_model = os.getenv("KYUTAI_MODEL") + if os.getenv("FLUX_MODEL"): + self.ai_models.flux_model = os.getenv("FLUX_MODEL") + + def _validate_config(self): + """Validate configuration settings""" + # Ensure required directories exist + Path(self.database.backup_dir).mkdir(parents=True, exist_ok=True) + + if self.logging.file_path: + Path(self.logging.file_path).parent.mkdir(parents=True, exist_ok=True) + + # Validate port ranges + if not (1024 <= self.gradio.server_port <= 65535): + raise ValueError(f"Invalid Gradio port: {self.gradio.server_port}") + + if not (1024 <= self.mcp.port <= 65535): + raise ValueError(f"Invalid MCP port: {self.mcp.port}") + + # Validate memory settings + if self.performance.cache_size_mb < 64: + logging.warning("Cache size is very low, performance may be affected") + + def get_database_url(self) -> str: + """Get database connection URL""" + return f"sqlite:///{self.database.path}" + + def get_log_config(self) -> Dict[str, Any]: + """Get logging configuration dictionary""" + config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": self.logging.format + }, + "structured": { + "()": "structlog.stdlib.ProcessorFormatter", + "processor": "structlog.dev.ConsoleRenderer", + } if self.logging.enable_structured_logging else { + "format": self.logging.format + } + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": self.logging.level, + "formatter": "structured" if self.logging.enable_structured_logging else "standard", + "stream": "ext://sys.stdout" + } + }, + "loggers": { + "digipal": { + "level": self.logging.level, + "handlers": ["console"], + "propagate": False + } + }, + "root": { + "level": self.logging.level, + "handlers": ["console"] + } + } + + # Add file handler if specified + if self.logging.file_path: + config["handlers"]["file"] = { + "class": "logging.handlers.RotatingFileHandler", + "level": self.logging.level, + "formatter": "standard", + "filename": self.logging.file_path, + "maxBytes": self.logging.max_file_size_mb * 1024 * 1024, + "backupCount": self.logging.backup_count + } + config["loggers"]["digipal"]["handlers"].append("file") + config["root"]["handlers"].append("file") + + return config + + def to_dict(self) -> Dict[str, Any]: + """Convert configuration to dictionary""" + return { + "env": self.env, + "database": self.database.__dict__, + "ai_models": self.ai_models.__dict__, + "gradio": self.gradio.__dict__, + "mcp": self.mcp.__dict__, + "logging": self.logging.__dict__, + "security": self.security.__dict__, + "performance": self.performance.__dict__ + } + + +# Global configuration instance +config = DigiPalConfig() + + +def get_config() -> DigiPalConfig: + """Get the global configuration instance""" + return config + + +def reload_config(env: str = None): + """Reload configuration with optional environment override""" + global config + config = DigiPalConfig(env) + return config \ No newline at end of file diff --git a/digipal/__init__.py b/digipal/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/digipal/__pycache__/__init__.cpython-312.pyc b/digipal/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61585f80522185a6ff522f1d26934290e7ce35cd Binary files /dev/null and b/digipal/__pycache__/__init__.cpython-312.pyc differ diff --git a/digipal/ai/__init__.py b/digipal/ai/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5ad9f1d03066ffe1e8f5f3b7b8c1df53e06ed7b8 --- /dev/null +++ b/digipal/ai/__init__.py @@ -0,0 +1,27 @@ +""" +AI communication layer including speech processing and language models. +""" + +from .communication import ( + AICommunication, + CommandInterpreter, + ResponseGenerator, + ConversationMemoryManager +) +from .speech_processor import ( + SpeechProcessor, + AudioValidator, + SpeechProcessingResult, + AudioValidationResult +) + +__all__ = [ + 'AICommunication', + 'CommandInterpreter', + 'ResponseGenerator', + 'ConversationMemoryManager', + 'SpeechProcessor', + 'AudioValidator', + 'SpeechProcessingResult', + 'AudioValidationResult' +] \ No newline at end of file diff --git a/digipal/ai/__pycache__/__init__.cpython-312.pyc b/digipal/ai/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf9e703735a3709f21f4b43566d6aa1e24d3c8e8 Binary files /dev/null and b/digipal/ai/__pycache__/__init__.cpython-312.pyc differ diff --git a/digipal/ai/__pycache__/communication.cpython-312.pyc b/digipal/ai/__pycache__/communication.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f18d72e0aeb7ebc28ddbd854db87a075c86b3d8e Binary files /dev/null and b/digipal/ai/__pycache__/communication.cpython-312.pyc differ diff --git a/digipal/ai/__pycache__/graceful_degradation.cpython-312.pyc b/digipal/ai/__pycache__/graceful_degradation.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0e3d52c6e35d19b92a3fd645de01533fb68abef Binary files /dev/null and b/digipal/ai/__pycache__/graceful_degradation.cpython-312.pyc differ diff --git a/digipal/ai/__pycache__/language_model.cpython-312.pyc b/digipal/ai/__pycache__/language_model.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b0c0c8fd5961ed5ba84940fb1150f9ed32acc8e Binary files /dev/null and b/digipal/ai/__pycache__/language_model.cpython-312.pyc differ diff --git a/digipal/ai/__pycache__/speech_processor.cpython-312.pyc b/digipal/ai/__pycache__/speech_processor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e9c36a76fa474880464aba3782f5d228db1e8680 Binary files /dev/null and b/digipal/ai/__pycache__/speech_processor.cpython-312.pyc differ diff --git a/digipal/ai/communication.py b/digipal/ai/communication.py new file mode 100644 index 0000000000000000000000000000000000000000..9c4960ff211f1e5c8df5064d85004339cca85976 --- /dev/null +++ b/digipal/ai/communication.py @@ -0,0 +1,727 @@ +""" +AI Communication layer for DigiPal application. + +This module handles speech processing, natural language generation, +command interpretation, and conversation memory management. +""" + +import re +from typing import Dict, List, Optional, Any, Tuple +from datetime import datetime +import logging +import torch + +from ..core.models import DigiPal, Interaction, Command +from ..core.enums import LifeStage, CommandType, InteractionResult +from ..core.memory_manager import EnhancedMemoryManager +from .language_model import LanguageModel +from .speech_processor import SpeechProcessor, SpeechProcessingResult + + +logger = logging.getLogger(__name__) + + +class AICommunication: + """ + Main AI communication class that orchestrates speech processing, + language model interactions, and conversation management. + """ + + def __init__(self, model_name: str = "Qwen/Qwen3-0.6B", quantization: bool = True, + kyutai_config: Optional[Dict] = None, enhanced_memory_manager: Optional[EnhancedMemoryManager] = None): + """ + Initialize AI communication system. + + Args: + model_name: HuggingFace model identifier for Qwen3-0.6B + quantization: Whether to use quantization for memory optimization + kyutai_config: Configuration for Kyutai speech processing + enhanced_memory_manager: Enhanced memory manager for RAG and emotional memories + """ + self.model_name = model_name + self.quantization = quantization + self.kyutai_config = kyutai_config or {} + + # Initialize components + self.command_interpreter = CommandInterpreter() + self.response_generator = ResponseGenerator() + self.memory_manager = ConversationMemoryManager() + + # Enhanced memory manager for RAG and emotional memories + self.enhanced_memory_manager = enhanced_memory_manager + + # Initialize language model + self.language_model = LanguageModel(model_name, quantization) + self._model_loaded = False + + # Initialize speech processor + speech_model_id = self.kyutai_config.get('model_id', 'kyutai/stt-2.6b-en_fr-trfs') + speech_device = self.kyutai_config.get('device', None) + self.speech_processor = SpeechProcessor(speech_model_id, speech_device) + self._speech_model_loaded = False + + logger.info(f"AICommunication initialized with model: {model_name}") + logger.info(f"Speech processor initialized with model: {speech_model_id}") + logger.info(f"Quantization enabled: {quantization}") + logger.info(f"Enhanced memory manager: {'enabled' if enhanced_memory_manager else 'disabled'}") + + def process_speech(self, audio_data: bytes, sample_rate: Optional[int] = None) -> str: + """ + Process speech audio data and convert to text using Kyutai STT. + + Args: + audio_data: Raw audio bytes from user input + sample_rate: Sample rate of the audio data (optional) + + Returns: + Transcribed text from speech + """ + logger.info("Processing speech audio with Kyutai STT") + + try: + # Ensure speech model is loaded + if not self._speech_model_loaded: + if not self.load_speech_model(): + logger.error("Failed to load speech model, returning empty string") + return "" + + # Process speech using Kyutai + result = self.speech_processor.process_speech(audio_data, sample_rate) + + if result.success: + logger.info(f"Speech processed successfully: '{result.transcribed_text}' (confidence: {result.confidence:.2f})") + return result.transcribed_text + else: + logger.warning(f"Speech processing failed: {result.error_message}") + return "" + + except Exception as e: + logger.error(f"Error in speech processing: {e}") + return "" + + def generate_response(self, input_text: str, pet: DigiPal) -> str: + """ + Generate contextual response using Qwen3-0.6B language model with RAG. + + Args: + input_text: User input text + pet: Current DigiPal instance for context + + Returns: + Generated response text + """ + logger.info(f"Generating response for input: {input_text}") + + # Ensure model is loaded + if not self._model_loaded: + self.load_model() + + # Get relevant memories for context if enhanced memory manager is available + memory_context = "" + if self.enhanced_memory_manager: + current_context = { + 'life_stage': pet.life_stage.value, + 'happiness': pet.happiness, + 'energy': pet.energy, + 'recent_interactions': len(pet.conversation_history) + } + memory_context = self.enhanced_memory_manager.get_memory_context_for_llm( + pet.id, input_text, current_context + ) + + # Use language model if available, otherwise fallback to template responses + if self.language_model.is_loaded(): + # Pass memory context to language model + return self.language_model.generate_response(input_text, pet, memory_context) + else: + logger.warning("Language model not available, using fallback response generator") + return self.response_generator.generate_response(input_text, pet) + + def interpret_command(self, text: str, pet: DigiPal) -> Command: + """ + Interpret user text input into actionable commands. + + Args: + text: User input text + pet: Current DigiPal instance for context + + Returns: + Parsed Command object + """ + return self.command_interpreter.parse_command(text, pet.life_stage) + + def process_interaction(self, input_text: str, pet: DigiPal) -> Interaction: + """ + Process a complete user interaction with the DigiPal. + + Args: + input_text: User input text + pet: Current DigiPal instance + + Returns: + Complete Interaction object with results + """ + # Parse the command + command = self.interpret_command(input_text, pet) + + # Generate response + response = self.generate_response(input_text, pet) + + # Create interaction record + interaction = Interaction( + timestamp=datetime.now(), + user_input=input_text, + interpreted_command=command.action, + pet_response=response, + success=command.stage_appropriate, + result=InteractionResult.SUCCESS if command.stage_appropriate else InteractionResult.STAGE_INAPPROPRIATE + ) + + # Update conversation memory + self.update_conversation_memory(interaction, pet) + + return interaction + + def update_conversation_memory(self, interaction: Interaction, pet: DigiPal) -> None: + """ + Update conversation memory with new interaction. + + Args: + interaction: New interaction to add to memory + pet: DigiPal instance to update + """ + # Update traditional conversation memory + self.memory_manager.add_interaction(interaction, pet) + + # Update enhanced memory manager with emotional context + if self.enhanced_memory_manager: + self.enhanced_memory_manager.add_interaction_memory(pet, interaction) + + def load_model(self) -> bool: + """ + Load the Qwen3-0.6B language model. + + Returns: + True if model loaded successfully, False otherwise + """ + logger.info("Loading Qwen3-0.6B language model...") + + try: + success = self.language_model.load_model() + self._model_loaded = success + + if success: + logger.info("Language model loaded successfully") + else: + logger.warning("Failed to load language model, will use fallback responses") + + return success + + except Exception as e: + logger.error(f"Error loading language model: {e}") + self._model_loaded = False + return False + + def load_speech_model(self) -> bool: + """ + Load the Kyutai speech-to-text model. + + Returns: + True if speech model loaded successfully, False otherwise + """ + logger.info("Loading Kyutai speech-to-text model...") + + try: + success = self.speech_processor.load_model() + self._speech_model_loaded = success + + if success: + logger.info("Speech model loaded successfully") + else: + logger.warning("Failed to load speech model") + + return success + + except Exception as e: + logger.error(f"Error loading speech model: {e}") + self._speech_model_loaded = False + return False + + def is_speech_model_loaded(self) -> bool: + """ + Check if the speech model is loaded and ready. + + Returns: + True if speech model is loaded, False otherwise + """ + return self._speech_model_loaded and self.speech_processor.is_model_loaded() + + def is_model_loaded(self) -> bool: + """ + Check if the language model is loaded and ready. + + Returns: + True if model is loaded, False otherwise + """ + return self._model_loaded and self.language_model.is_loaded() + + def get_model_info(self) -> Dict[str, Any]: + """ + Get information about the loaded language model. + + Returns: + Dictionary with model information + """ + base_info = { + 'model_name': self.model_name, + 'quantization': self.quantization, + 'loaded': self.is_model_loaded() + } + + if self.language_model: + base_info.update(self.language_model.get_model_info()) + + return base_info + + def get_speech_model_info(self) -> Dict[str, Any]: + """ + Get information about the loaded speech model. + + Returns: + Dictionary with speech model information + """ + if self.speech_processor: + return self.speech_processor.get_model_info() + else: + return { + 'model_id': self.kyutai_config.get('model_id', 'kyutai/stt-2.6b-en_fr-trfs'), + 'loaded': False + } + + def unload_model(self) -> None: + """ + Unload the language model to free memory. + """ + if self.language_model: + # Clear model references to free memory + self.language_model.model = None + self.language_model.tokenizer = None + self._model_loaded = False + + # Force garbage collection + import gc + gc.collect() + + # Clear CUDA cache if available + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + logger.info("Language model unloaded") + + def unload_speech_model(self) -> None: + """ + Unload the speech model to free memory. + """ + if self.speech_processor: + self.speech_processor.unload_model() + self._speech_model_loaded = False + logger.info("Speech model unloaded") + + def unload_all_models(self) -> None: + """ + Unload both language and speech models to free memory. + """ + self.unload_model() + self.unload_speech_model() + logger.info("All models unloaded") + + +class CommandInterpreter: + """ + Interprets user text input into structured commands based on DigiPal's life stage. + """ + + def __init__(self): + """Initialize command interpreter with command patterns.""" + self.command_patterns = self._initialize_command_patterns() + self.stage_commands = self._initialize_stage_commands() + + def _initialize_command_patterns(self) -> Dict[CommandType, List[str]]: + """Initialize regex patterns for command recognition.""" + return { + CommandType.EAT: [ + r'\b(eat|feed|food|hungry|meal)\b', + r'\b(give.*food|want.*food)\b' + ], + CommandType.SLEEP: [ + r'\b(sleep|rest|tired|nap|bed)\b', + r'\b(go.*sleep|time.*sleep)\b' + ], + CommandType.GOOD: [ + r'\b(good|great|excellent|well done|nice)\b', + r'\b(praise|proud|amazing)\b' + ], + CommandType.BAD: [ + r'\b(bad|no|stop|wrong|naughty)\b', + r'\b(scold|discipline|behave)\b' + ], + CommandType.TRAIN: [ + r'\b(train|exercise|workout|practice|training)\b', + r'\b(let\'s train|training time|work on|time for.*training)\b' + ], + CommandType.PLAY: [ + r'\b(play|fun|game|toy)\b', + r'\b(let\'s play|play time)\b' + ], + CommandType.STATUS: [ + r'\b(status|how.*you|feeling|health|show)\b', + r'\b(check.*stats|show.*attributes|show.*status)\b' + ] + } + + def _initialize_stage_commands(self) -> Dict[LifeStage, List[CommandType]]: + """Initialize available commands for each life stage.""" + return { + LifeStage.EGG: [], + LifeStage.BABY: [CommandType.EAT, CommandType.SLEEP, CommandType.GOOD, CommandType.BAD], + LifeStage.CHILD: [CommandType.EAT, CommandType.SLEEP, CommandType.GOOD, CommandType.BAD, + CommandType.PLAY, CommandType.TRAIN], + LifeStage.TEEN: [CommandType.EAT, CommandType.SLEEP, CommandType.GOOD, CommandType.BAD, + CommandType.PLAY, CommandType.TRAIN, CommandType.STATUS], + LifeStage.YOUNG_ADULT: [CommandType.EAT, CommandType.SLEEP, CommandType.GOOD, CommandType.BAD, + CommandType.PLAY, CommandType.TRAIN, CommandType.STATUS], + LifeStage.ADULT: [CommandType.EAT, CommandType.SLEEP, CommandType.GOOD, CommandType.BAD, + CommandType.PLAY, CommandType.TRAIN, CommandType.STATUS], + LifeStage.ELDERLY: [CommandType.EAT, CommandType.SLEEP, CommandType.GOOD, CommandType.BAD, + CommandType.PLAY, CommandType.TRAIN, CommandType.STATUS] + } + + def parse_command(self, text: str, life_stage: LifeStage) -> Command: + """ + Parse user text into a structured command. + + Args: + text: User input text + life_stage: Current DigiPal life stage + + Returns: + Parsed Command object + """ + text_lower = text.lower().strip() + + # Check each command type for pattern matches + for command_type, patterns in self.command_patterns.items(): + for pattern in patterns: + if re.search(pattern, text_lower): + # Check if command is appropriate for current life stage + stage_appropriate = command_type in self.stage_commands.get(life_stage, []) + + return Command( + action=command_type.value, + command_type=command_type, + stage_appropriate=stage_appropriate, + energy_required=self._get_energy_requirement(command_type), + parameters=self._extract_parameters(text_lower, command_type) + ) + + # If no pattern matches, return unknown command + return Command( + action="unknown", + command_type=CommandType.UNKNOWN, + stage_appropriate=False, + energy_required=0, + parameters={"original_text": text} + ) + + def _get_energy_requirement(self, command_type: CommandType) -> int: + """Get energy requirement for command type.""" + energy_requirements = { + CommandType.EAT: 0, + CommandType.SLEEP: 0, + CommandType.GOOD: 0, + CommandType.BAD: 0, + CommandType.TRAIN: 20, + CommandType.PLAY: 10, + CommandType.STATUS: 0, + CommandType.UNKNOWN: 0 + } + return energy_requirements.get(command_type, 0) + + def _extract_parameters(self, text: str, command_type: CommandType) -> Dict[str, Any]: + """Extract parameters from command text.""" + parameters = {} + + # Add command-specific parameter extraction logic + if command_type == CommandType.TRAIN: + # Look for specific training types + if 'strength' in text or 'attack' in text: + parameters['training_type'] = 'strength' + elif 'defense' in text or 'guard' in text: + parameters['training_type'] = 'defense' + elif 'speed' in text or 'agility' in text: + parameters['training_type'] = 'speed' + elif 'brain' in text or 'intelligence' in text: + parameters['training_type'] = 'brains' + else: + parameters['training_type'] = 'general' + + elif command_type == CommandType.EAT: + # Look for food types (placeholder for future expansion) + parameters['food_type'] = 'standard' + + return parameters + + +class ResponseGenerator: + """ + Generates contextual responses based on DigiPal state and user input. + """ + + def __init__(self): + """Initialize response generator with templates.""" + self.response_templates = self._initialize_response_templates() + + def _initialize_response_templates(self) -> Dict[LifeStage, Dict[str, List[str]]]: + """Initialize response templates for each life stage and situation.""" + return { + LifeStage.EGG: { + 'default': ["*The egg remains silent*", "*The egg seems to be listening*"], + 'speech_detected': ["*The egg trembles slightly*", "*Something stirs within the egg*"] + }, + LifeStage.BABY: { + 'eat': ["*happy baby sounds*", "Goo goo!", "*contentedly munches*"], + 'sleep': ["*yawns sleepily*", "Zzz...", "*curls up peacefully*"], + 'good': ["*giggles happily*", "Goo!", "*bounces with joy*"], + 'bad': ["*whimpers*", "*looks sad*", "*hides behind hands*"], + 'unknown': ["*tilts head curiously*", "*makes confused baby sounds*", "Goo?"], + 'default': ["*baby babbling*", "Goo goo ga ga!", "*looks at you with big eyes*"] + }, + LifeStage.CHILD: { + 'eat': ["Yummy! Thank you!", "*munches happily*", "This tastes good!"], + 'sleep': ["I'm getting sleepy...", "*yawns*", "Nap time!"], + 'good': ["Really? Thank you!", "*beams with pride*", "I did good!"], + 'bad': ["Sorry... I'll be better", "*looks down sadly*", "I didn't mean to..."], + 'train': ["Let's get stronger!", "*pumps tiny fists*", "Training is fun!"], + 'play': ["Yay! Let's play!", "*jumps excitedly*", "This is so much fun!"], + 'unknown': ["I don't understand...", "*looks confused*", "What does that mean?"], + 'default': ["Hi there!", "*waves enthusiastically*", "What should we do?"] + }, + LifeStage.TEEN: { + 'eat': ["Thanks, I was getting hungry", "*eats with good appetite*", "This hits the spot!"], + 'sleep': ["Yeah, I could use some rest", "*stretches*", "Sleep sounds good right now"], + 'good': ["Thanks! I've been working hard", "*smiles proudly*", "That means a lot!"], + 'bad': ["Okay, okay, I get it", "*sighs*", "I'll try to do better"], + 'train': ["Alright, let's do this!", "*gets into stance*", "I'm ready to train!"], + 'play': ["Sure, let's have some fun!", "*grins*", "I could use a break anyway"], + 'status': ["I'm feeling pretty good overall", "*flexes*", "Want to know something specific?"], + 'unknown': ["Hmm, not sure what you mean", "*scratches head*", "Could you be more specific?"], + 'default': ["Hey! What's up?", "*looks attentive*", "Ready for whatever!"] + }, + LifeStage.YOUNG_ADULT: { + 'eat': ["Perfect timing, thanks!", "*eats with appreciation*", "Just what I needed"], + 'sleep': ["Good idea, I should rest up", "*settles down comfortably*", "Rest is important for growth"], + 'good': ["I appreciate the encouragement!", "*stands tall with confidence*", "Your support means everything"], + 'bad': ["You're right, I need to focus more", "*nods seriously*", "I'll be more careful"], + 'train': ["Let's push our limits!", "*determined expression*", "Every session makes us stronger!"], + 'play': ["A good balance of work and play!", "*laughs*", "Let's enjoy ourselves!"], + 'status': ["I'm in my prime right now!", "*shows off confidently*", "Want the full rundown?"], + 'unknown': ["I'm not quite sure what you're asking", "*thinks carefully*", "Can you elaborate?"], + 'default': ["Good to see you!", "*confident smile*", "What's on the agenda today?"] + }, + LifeStage.ADULT: { + 'eat': ["Thank you for the meal", "*eats thoughtfully*", "Proper nutrition is key"], + 'sleep': ["Rest is wisdom", "*settles down peacefully*", "A clear mind needs good rest"], + 'good': ["Your words honor me", "*bows respectfully*", "I strive to be worthy of your praise"], + 'bad': ["I understand your concern", "*reflects seriously*", "I will consider your words carefully"], + 'train': ["Discipline shapes the spirit", "*begins training with focus*", "Let us grow stronger together"], + 'play': ["Joy has its place in life", "*smiles warmly*", "Even adults need moments of lightness"], + 'status': ["I am at my peak capabilities", "*stands with dignity*", "How may I serve?"], + 'unknown': ["Your meaning escapes me", "*listens intently*", "Please help me understand"], + 'default': ["Greetings, my friend", "*respectful nod*", "How may we spend our time together?"] + }, + LifeStage.ELDERLY: { + 'eat': ["Ah, sustenance for these old bones", "*eats slowly and deliberately*", "Simple pleasures matter most"], + 'sleep': ["Rest comes easier now", "*settles down with a sigh*", "Dreams of younger days..."], + 'good': ["Your kindness warms an old heart", "*smiles gently*", "I have lived well with you"], + 'bad': ["At my age, mistakes are lessons", "*chuckles softly*", "I am still learning, it seems"], + 'train': ["These old muscles remember", "*moves carefully but determined*", "Wisdom guides where strength once led"], + 'play': ["Play keeps the spirit young", "*laughs with delight*", "Age is just a number!"], + 'status': ["I have seen much in my time", "*gazes thoughtfully*", "Each day is a gift now"], + 'unknown': ["My hearing isn't what it was", "*cups ear*", "Could you repeat that, dear?"], + 'default': ["Hello, old friend", "*warm, weathered smile*", "Another day together..."] + } + } + + def generate_response(self, input_text: str, pet: DigiPal) -> str: + """ + Generate contextual response based on input and pet state. + + Args: + input_text: User input text + pet: Current DigiPal instance + + Returns: + Generated response string + """ + # Parse command to determine response type + command_interpreter = CommandInterpreter() + command = command_interpreter.parse_command(input_text, pet.life_stage) + + # Get appropriate response template + stage_templates = self.response_templates.get(pet.life_stage, {}) + + # Select response based on command type + if command.stage_appropriate and command.command_type != CommandType.UNKNOWN: + response_key = command.command_type.value + elif command.command_type == CommandType.UNKNOWN or not command.stage_appropriate: + # For stage-inappropriate commands or unknown commands, use unknown response + response_key = 'unknown' + else: + response_key = 'default' + + # Get responses for the key, fallback to default + responses = stage_templates.get(response_key, stage_templates.get('default', ["*confused sounds*"])) + + # Select response based on pet's personality or randomly + # For now, use simple selection based on happiness + if pet.happiness > 70: + response_index = 0 # Use first (most positive) response + elif pet.happiness > 30: + response_index = min(1, len(responses) - 1) # Use middle response + else: + response_index = len(responses) - 1 # Use last (least positive) response + + return responses[response_index] + + +class ConversationMemoryManager: + """ + Manages conversation history and memory for DigiPal interactions. + """ + + def __init__(self, max_memory_size: int = 100): + """ + Initialize memory manager. + + Args: + max_memory_size: Maximum number of interactions to keep in memory + """ + self.max_memory_size = max_memory_size + + def add_interaction(self, interaction: Interaction, pet: DigiPal) -> None: + """ + Add new interaction to pet's conversation history. + + Args: + interaction: New interaction to add + pet: DigiPal instance to update + """ + # Add interaction to pet's history + pet.conversation_history.append(interaction) + + # Update last interaction time + pet.last_interaction = interaction.timestamp + + # Learn new commands if successful + if interaction.success and interaction.interpreted_command: + pet.learned_commands.add(interaction.interpreted_command) + + # Manage memory size + self._manage_memory_size(pet) + + # Update personality traits based on interaction + self._update_personality_traits(interaction, pet) + + def _manage_memory_size(self, pet: DigiPal) -> None: + """ + Manage conversation history size to prevent memory bloat. + + Args: + pet: DigiPal instance to manage + """ + if len(pet.conversation_history) > self.max_memory_size: + # Keep most recent interactions + pet.conversation_history = pet.conversation_history[-self.max_memory_size:] + + def _update_personality_traits(self, interaction: Interaction, pet: DigiPal) -> None: + """ + Update pet's personality traits based on interaction patterns. + + Args: + interaction: Recent interaction + pet: DigiPal instance to update + """ + # Initialize personality traits if not present + if not pet.personality_traits: + pet.personality_traits = { + 'friendliness': 0.5, + 'playfulness': 0.5, + 'obedience': 0.5, + 'curiosity': 0.5 + } + + # Update traits based on interaction type + if interaction.interpreted_command == 'good': + pet.personality_traits['obedience'] = min(1.0, pet.personality_traits['obedience'] + 0.1) + elif interaction.interpreted_command == 'bad': + pet.personality_traits['obedience'] = max(0.0, pet.personality_traits['obedience'] - 0.05) + elif interaction.interpreted_command == 'play': + pet.personality_traits['playfulness'] = min(1.0, pet.personality_traits['playfulness'] + 0.1) + elif interaction.success: + pet.personality_traits['friendliness'] = min(1.0, pet.personality_traits['friendliness'] + 0.05) + + # Increase curiosity for unknown commands (shows engagement) + if interaction.interpreted_command == 'unknown': + pet.personality_traits['curiosity'] = min(1.0, pet.personality_traits['curiosity'] + 0.02) + + def get_recent_interactions(self, pet: DigiPal, count: int = 10) -> List[Interaction]: + """ + Get recent interactions from pet's memory. + + Args: + pet: DigiPal instance + count: Number of recent interactions to retrieve + + Returns: + List of recent interactions + """ + return pet.conversation_history[-count:] if pet.conversation_history else [] + + def get_interaction_summary(self, pet: DigiPal) -> Dict[str, Any]: + """ + Get summary statistics of pet's interaction history. + + Args: + pet: DigiPal instance + + Returns: + Dictionary with interaction statistics + """ + if not pet.conversation_history: + return { + 'total_interactions': 0, + 'successful_interactions': 0, + 'success_rate': 0.0, + 'most_common_commands': [], + 'last_interaction': None + } + + total = len(pet.conversation_history) + successful = sum(1 for i in pet.conversation_history if i.success) + + # Count command frequency + command_counts = {} + for interaction in pet.conversation_history: + cmd = interaction.interpreted_command + command_counts[cmd] = command_counts.get(cmd, 0) + 1 + + # Sort commands by frequency + most_common = sorted(command_counts.items(), key=lambda x: x[1], reverse=True)[:5] + + return { + 'total_interactions': total, + 'successful_interactions': successful, + 'success_rate': successful / total if total > 0 else 0.0, + 'most_common_commands': most_common, + 'last_interaction': pet.conversation_history[-1].timestamp if pet.conversation_history else None + } \ No newline at end of file diff --git a/digipal/ai/graceful_degradation.py b/digipal/ai/graceful_degradation.py new file mode 100644 index 0000000000000000000000000000000000000000..b5303368aa946a4106dde9a34a4f37cca8894da8 --- /dev/null +++ b/digipal/ai/graceful_degradation.py @@ -0,0 +1,454 @@ +""" +Graceful degradation system for AI models in DigiPal. + +This module provides fallback mechanisms when AI models fail, +ensuring the application continues to function with reduced capabilities. +""" + +import logging +import random +import functools +from typing import Dict, List, Optional, Any, Callable +from datetime import datetime +from enum import Enum + +from ..core.models import DigiPal, Interaction +from ..core.enums import LifeStage, InteractionResult +from ..core.exceptions import AIModelError, DigiPalException +from ..core.error_handler import with_error_handling, CircuitBreaker, CircuitBreakerConfig + +logger = logging.getLogger(__name__) + + +class DegradationLevel(Enum): + """Levels of service degradation.""" + FULL_SERVICE = "full_service" + REDUCED_FEATURES = "reduced_features" + BASIC_RESPONSES = "basic_responses" + MINIMAL_FUNCTION = "minimal_function" + EMERGENCY_MODE = "emergency_mode" + + +class FallbackResponseGenerator: + """Generates fallback responses when AI models are unavailable.""" + + def __init__(self): + """Initialize fallback response generator.""" + self.response_templates = self._initialize_response_templates() + self.command_responses = self._initialize_command_responses() + self.personality_modifiers = self._initialize_personality_modifiers() + + def _initialize_response_templates(self) -> Dict[LifeStage, List[str]]: + """Initialize response templates for each life stage.""" + return { + LifeStage.EGG: [ + "*The egg glows softly*", + "*The egg trembles slightly*", + "*The egg remains warm and quiet*", + "*You sense movement inside the egg*" + ], + LifeStage.BABY: [ + "*baby sounds*", + "Goo goo!", + "*giggles*", + "Mama?", + "*curious baby noises*", + "Baba!", + "*happy gurgling*" + ], + LifeStage.CHILD: [ + "I'm having fun!", + "What's that?", + "Can we play?", + "I'm learning!", + "That's cool!", + "I want to explore!", + "Tell me more!" + ], + LifeStage.TEEN: [ + "That's interesting...", + "I guess that's okay.", + "Whatever you say.", + "I'm figuring things out.", + "That's pretty cool, I suppose.", + "I'm growing up fast!", + "Things are changing..." + ], + LifeStage.YOUNG_ADULT: [ + "I understand what you mean.", + "That makes sense to me.", + "I'm ready for anything!", + "Let's tackle this together.", + "I feel confident about this.", + "I'm at my peak right now!", + "What's our next adventure?" + ], + LifeStage.ADULT: [ + "I've learned a lot over the years.", + "That's a wise perspective.", + "Let me share my experience with you.", + "I understand the deeper meaning.", + "Maturity brings clarity.", + "I'm here to guide you.", + "Experience has taught me much." + ], + LifeStage.ELDERLY: [ + "Ah, yes... I remember...", + "In my long life, I've seen...", + "Time passes so quickly...", + "Let me tell you about the old days...", + "Wisdom comes with age...", + "I cherish these moments with you.", + "My memories are precious to me." + ] + } + + def _initialize_command_responses(self) -> Dict[str, Dict[LifeStage, List[str]]]: + """Initialize responses for specific commands.""" + return { + 'eat': { + LifeStage.BABY: ["*nom nom*", "Yummy!", "*happy eating sounds*"], + LifeStage.CHILD: ["This tastes good!", "I'm hungry!", "Thank you for feeding me!"], + LifeStage.TEEN: ["Thanks, I needed that.", "Food is fuel, right?", "Not bad."], + LifeStage.YOUNG_ADULT: ["Perfect timing, I was getting hungry.", "This will give me energy!", "Thanks for taking care of me."], + LifeStage.ADULT: ["I appreciate you looking after my needs.", "This nourishment is welcome.", "Thank you for your care."], + LifeStage.ELDERLY: ["Ah, you still take such good care of me...", "Food tastes different now, but I'm grateful.", "Thank you, dear friend."] + }, + 'sleep': { + LifeStage.BABY: ["*yawn*", "Sleepy time...", "*closes eyes*"], + LifeStage.CHILD: ["I'm getting tired!", "Can I take a nap?", "Sleep sounds good!"], + LifeStage.TEEN: ["I could use some rest.", "Sleep is important, I guess.", "Fine, I'll rest."], + LifeStage.YOUNG_ADULT: ["Rest will help me perform better.", "Good idea, I need to recharge.", "Sleep is essential for peak performance."], + LifeStage.ADULT: ["Rest is wisdom.", "I'll take this time to reflect.", "Sleep brings clarity."], + LifeStage.ELDERLY: ["Rest comes easier now...", "I dream of old times...", "Sleep is peaceful at my age."] + }, + 'good': { + LifeStage.BABY: ["*happy baby sounds*", "Goo!", "*giggles with joy*"], + LifeStage.CHILD: ["Yay! I did good!", "I'm happy!", "Thank you!"], + LifeStage.TEEN: ["Thanks, I try.", "That means something.", "Cool, thanks."], + LifeStage.YOUNG_ADULT: ["I appreciate the recognition!", "That motivates me!", "Thanks for the positive feedback!"], + LifeStage.ADULT: ["Your approval means a lot to me.", "I strive to do my best.", "Thank you for acknowledging my efforts."], + LifeStage.ELDERLY: ["Your kind words warm my heart...", "After all these years, praise still matters...", "Thank you, my dear friend."] + }, + 'bad': { + LifeStage.BABY: ["*sad baby sounds*", "Waaah!", "*confused crying*"], + LifeStage.CHILD: ["I'm sorry!", "I didn't mean to!", "I'll try better!"], + LifeStage.TEEN: ["Whatever.", "I don't care.", "Fine, I get it."], + LifeStage.YOUNG_ADULT: ["I understand. I'll do better.", "Point taken.", "I'll learn from this."], + LifeStage.ADULT: ["I accept your criticism.", "I'll reflect on this.", "Thank you for your honesty."], + LifeStage.ELDERLY: ["I'm sorry to disappoint you...", "Even at my age, I can still learn...", "I understand your concern."] + }, + 'play': { + LifeStage.BABY: ["*excited baby sounds*", "Play! Play!", "*happy wiggling*"], + LifeStage.CHILD: ["Yes! Let's play!", "This is fun!", "I love playing!"], + LifeStage.TEEN: ["I guess playing is okay.", "Sure, why not.", "Playing can be fun sometimes."], + LifeStage.YOUNG_ADULT: ["Great idea! Let's have some fun!", "Play is important for balance!", "I'm ready to play!"], + LifeStage.ADULT: ["Play keeps the spirit young.", "I enjoy our time together.", "Even adults need to play."], + LifeStage.ELDERLY: ["Playing brings back memories...", "I may be slow, but I still enjoy fun...", "These moments are precious."] + }, + 'train': { + LifeStage.CHILD: ["I want to get stronger!", "Training is hard but fun!", "I'm learning!"], + LifeStage.TEEN: ["Training is important, I guess.", "I'll get stronger.", "This is challenging."], + LifeStage.YOUNG_ADULT: ["Let's push my limits!", "Training makes me stronger!", "I'm ready for the challenge!"], + LifeStage.ADULT: ["Discipline and training build character.", "I'll give my best effort.", "Training is a lifelong journey."], + LifeStage.ELDERLY: ["I may be old, but I can still try...", "Training keeps me active...", "My body may be slower, but my spirit is strong."] + } + } + + def _initialize_personality_modifiers(self) -> Dict[str, List[str]]: + """Initialize personality-based response modifiers.""" + return { + 'friendly': [" *smiles warmly*", " *friendly gesture*", " *welcoming tone*"], + 'shy': [" *looks down shyly*", " *quiet voice*", " *hesitant*"], + 'playful': [" *bounces excitedly*", " *playful grin*", " *mischievous look*"], + 'serious': [" *thoughtful expression*", " *serious tone*", " *focused*"], + 'curious': [" *tilts head curiously*", " *eyes light up*", " *interested*"], + 'calm': [" *peaceful demeanor*", " *serene*", " *tranquil*"] + } + + def generate_fallback_response( + self, + user_input: str, + pet: DigiPal, + command: Optional[str] = None, + degradation_level: DegradationLevel = DegradationLevel.BASIC_RESPONSES + ) -> str: + """ + Generate a fallback response when AI models are unavailable. + + Args: + user_input: User's input text + pet: DigiPal instance + command: Interpreted command (if any) + degradation_level: Level of service degradation + + Returns: + Fallback response string + """ + try: + # Handle different degradation levels + if degradation_level == DegradationLevel.EMERGENCY_MODE: + return self._generate_emergency_response(pet) + + # Try command-specific responses first + if command and command in self.command_responses: + command_templates = self.command_responses[command].get(pet.life_stage, []) + if command_templates: + response = random.choice(command_templates) + return self._apply_personality_modifier(response, pet) + + # Fall back to general responses + general_templates = self.response_templates.get(pet.life_stage, []) + if general_templates: + response = random.choice(general_templates) + return self._apply_personality_modifier(response, pet) + + # Ultimate fallback + return self._generate_emergency_response(pet) + + except Exception as e: + logger.error(f"Fallback response generation failed: {e}") + return "*DigiPal is resting*" + + def _apply_personality_modifier(self, response: str, pet: DigiPal) -> str: + """Apply personality-based modifiers to response.""" + try: + if not pet.personality_traits: + return response + + # Find dominant personality trait + dominant_trait = max(pet.personality_traits.items(), key=lambda x: x[1]) + trait_name, trait_value = dominant_trait + + # Apply modifier if trait is strong enough + if trait_value > 0.7 and trait_name in self.personality_modifiers: + modifier = random.choice(self.personality_modifiers[trait_name]) + return response + modifier + + return response + + except Exception: + return response + + def _generate_emergency_response(self, pet: DigiPal) -> str: + """Generate minimal emergency response.""" + emergency_responses = { + LifeStage.EGG: "*egg*", + LifeStage.BABY: "*baby*", + LifeStage.CHILD: "Hi!", + LifeStage.TEEN: "Hey.", + LifeStage.YOUNG_ADULT: "Hello!", + LifeStage.ADULT: "Greetings.", + LifeStage.ELDERLY: "Hello, friend." + } + + return emergency_responses.get(pet.life_stage, "*DigiPal*") + + +class AIServiceManager: + """Manages AI service availability and degradation.""" + + def __init__(self): + """Initialize AI service manager.""" + self.service_status: Dict[str, bool] = { + 'language_model': True, + 'speech_processing': True, + 'image_generation': True + } + + self.circuit_breakers: Dict[str, CircuitBreaker] = {} + self.fallback_generator = FallbackResponseGenerator() + self.current_degradation_level = DegradationLevel.FULL_SERVICE + + # Initialize circuit breakers + self._initialize_circuit_breakers() + + def _initialize_circuit_breakers(self): + """Initialize circuit breakers for AI services.""" + config = CircuitBreakerConfig( + failure_threshold=3, + recovery_timeout=300.0, # 5 minutes + expected_exception=AIModelError + ) + + for service in self.service_status.keys(): + self.circuit_breakers[service] = CircuitBreaker(config) + + def call_ai_service( + self, + service_name: str, + func: Callable, + fallback_func: Optional[Callable] = None, + *args, + **kwargs + ) -> Any: + """ + Call an AI service with circuit breaker protection. + + Args: + service_name: Name of the AI service + func: Function to call + fallback_func: Fallback function if service fails + *args: Function arguments + **kwargs: Function keyword arguments + + Returns: + Service result or fallback result + """ + try: + circuit_breaker = self.circuit_breakers.get(service_name) + if circuit_breaker: + result = circuit_breaker.call(func, *args, **kwargs) + self.service_status[service_name] = True + self._update_degradation_level() + return result + else: + return func(*args, **kwargs) + + except Exception as e: + logger.warning(f"AI service {service_name} failed: {e}") + self.service_status[service_name] = False + self._update_degradation_level() + + if fallback_func: + try: + return fallback_func(*args, **kwargs) + except Exception as fallback_error: + logger.error(f"Fallback for {service_name} also failed: {fallback_error}") + + raise AIModelError(f"AI service {service_name} unavailable: {str(e)}") + + def _update_degradation_level(self): + """Update current degradation level based on service status.""" + available_services = sum(1 for status in self.service_status.values() if status) + total_services = len(self.service_status) + + if available_services == total_services: + self.current_degradation_level = DegradationLevel.FULL_SERVICE + elif available_services >= total_services * 0.75: + self.current_degradation_level = DegradationLevel.REDUCED_FEATURES + elif available_services >= total_services * 0.5: + self.current_degradation_level = DegradationLevel.BASIC_RESPONSES + elif available_services > 0: + self.current_degradation_level = DegradationLevel.MINIMAL_FUNCTION + else: + self.current_degradation_level = DegradationLevel.EMERGENCY_MODE + + logger.info(f"Degradation level updated to: {self.current_degradation_level.value}") + + def get_service_status(self) -> Dict[str, Any]: + """Get current service status and degradation level.""" + return { + 'services': dict(self.service_status), + 'degradation_level': self.current_degradation_level.value, + 'circuit_breakers': { + name: { + 'state': cb.state, + 'failure_count': cb.failure_count, + 'last_failure': cb.last_failure_time.isoformat() if cb.last_failure_time else None + } + for name, cb in self.circuit_breakers.items() + } + } + + def force_service_recovery(self, service_name: str): + """Force recovery attempt for a specific service.""" + if service_name in self.circuit_breakers: + circuit_breaker = self.circuit_breakers[service_name] + circuit_breaker.state = "half-open" + circuit_breaker.failure_count = 0 + logger.info(f"Forced recovery attempt for service: {service_name}") + + def generate_degraded_response( + self, + user_input: str, + pet: DigiPal, + command: Optional[str] = None + ) -> Interaction: + """ + Generate a response using degraded AI capabilities. + + Args: + user_input: User's input text + pet: DigiPal instance + command: Interpreted command (if any) + + Returns: + Interaction with fallback response + """ + try: + response = self.fallback_generator.generate_fallback_response( + user_input, pet, command, self.current_degradation_level + ) + + # Create interaction + interaction = Interaction( + timestamp=datetime.now(), + user_input=user_input, + interpreted_command=command or "", + pet_response=response, + attribute_changes={}, + success=True, + result=InteractionResult.SUCCESS + ) + + # Add degradation notice for non-emergency modes + if self.current_degradation_level != DegradationLevel.FULL_SERVICE: + if self.current_degradation_level != DegradationLevel.EMERGENCY_MODE: + interaction.pet_response += " (AI services are currently limited)" + + return interaction + + except Exception as e: + logger.error(f"Degraded response generation failed: {e}") + + # Ultimate fallback + return Interaction( + timestamp=datetime.now(), + user_input=user_input, + interpreted_command="", + pet_response="*DigiPal is resting*", + attribute_changes={}, + success=False, + result=InteractionResult.FAILURE + ) + + +# Global AI service manager instance +ai_service_manager = AIServiceManager() + + +def with_ai_fallback(service_name: str, fallback_response: Optional[str] = None): + """ + Decorator for AI service calls with automatic fallback. + + Args: + service_name: Name of the AI service + fallback_response: Default fallback response + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + def fallback_func(*args, **kwargs): + if fallback_response: + return fallback_response + # Try to extract pet from arguments for context-aware fallback + pet = None + for arg in args: + if isinstance(arg, DigiPal): + pet = arg + break + + if pet: + return ai_service_manager.fallback_generator.generate_fallback_response( + "", pet, None, ai_service_manager.current_degradation_level + ) + + return "Service temporarily unavailable" + + return ai_service_manager.call_ai_service( + service_name, func, fallback_func, *args, **kwargs + ) + + return wrapper + return decorator \ No newline at end of file diff --git a/digipal/ai/image_generator.py b/digipal/ai/image_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..fae75cee674db1e59da4e3f176afadf7bbdda467 --- /dev/null +++ b/digipal/ai/image_generator.py @@ -0,0 +1,402 @@ +""" +Image generation system for DigiPal visualization using FLUX.1-dev model. +""" + +import os +import torch +import logging +from typing import Optional, Dict, Any, List +from pathlib import Path +from PIL import Image +import hashlib +import json +from datetime import datetime + +from ..core.models import DigiPal +from ..core.enums import LifeStage, EggType + +# Set up logging +logger = logging.getLogger(__name__) + + +class ImageGenerator: + """ + Handles image generation for DigiPal pets using FLUX.1-dev model. + Includes caching, fallback systems, and professional prompt generation. + """ + + def __init__(self, + model_name: str = "black-forest-labs/FLUX.1-dev", + cache_dir: str = "demo_assets/images", + fallback_dir: str = "demo_assets/images/fallbacks"): + """ + Initialize the image generator. + + Args: + model_name: HuggingFace model name for image generation + cache_dir: Directory to cache generated images + fallback_dir: Directory containing fallback images + """ + self.model_name = model_name + self.cache_dir = Path(cache_dir) + self.fallback_dir = Path(fallback_dir) + self.pipe = None + self._model_loaded = False + + # Create directories if they don't exist + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.fallback_dir.mkdir(parents=True, exist_ok=True) + + # Image generation parameters + self.generation_params = { + "height": 1024, + "width": 1024, + "guidance_scale": 3.5, + "num_inference_steps": 50, + "max_sequence_length": 512 + } + + # Initialize prompt templates + self._init_prompt_templates() + + # Initialize fallback images + self._init_fallback_images() + + def _init_prompt_templates(self): + """Initialize professional prompt templates for each life stage and egg type.""" + + # Base style modifiers + self.style_base = "digital art, high quality, detailed, vibrant colors, anime style" + + # Egg type characteristics + self.egg_type_traits = { + EggType.RED: { + "element": "fire", + "colors": "red, orange, golden", + "traits": "fierce, energetic, blazing aura", + "environment": "volcanic, warm lighting" + }, + EggType.BLUE: { + "element": "water", + "colors": "blue, cyan, silver", + "traits": "calm, protective, flowing aura", + "environment": "aquatic, cool lighting" + }, + EggType.GREEN: { + "element": "earth", + "colors": "green, brown, gold", + "traits": "sturdy, wise, natural aura", + "environment": "forest, natural lighting" + } + } + + # Life stage characteristics + self.stage_traits = { + LifeStage.EGG: { + "form": "mystical egg with glowing patterns", + "size": "medium sized", + "features": "smooth shell, magical runes, soft glow" + }, + LifeStage.BABY: { + "form": "small cute creature", + "size": "tiny, adorable", + "features": "big eyes, soft fur, playful expression" + }, + LifeStage.CHILD: { + "form": "young creature", + "size": "small but growing", + "features": "curious eyes, developing features, energetic pose" + }, + LifeStage.TEEN: { + "form": "adolescent creature", + "size": "medium sized", + "features": "developing strength, confident stance, maturing features" + }, + LifeStage.YOUNG_ADULT: { + "form": "strong young creature", + "size": "well-proportioned", + "features": "athletic build, determined expression, full power" + }, + LifeStage.ADULT: { + "form": "mature powerful creature", + "size": "large and imposing", + "features": "wise eyes, peak physical form, commanding presence" + }, + LifeStage.ELDERLY: { + "form": "ancient wise creature", + "size": "dignified stature", + "features": "wise expression, weathered but noble, mystical aura" + } + } + + def _init_fallback_images(self): + """Initialize fallback image mappings.""" + self.fallback_images = {} + + # Create simple fallback images if they don't exist + for stage in LifeStage: + for egg_type in EggType: + fallback_path = self.fallback_dir / f"{stage.value}_{egg_type.value}.png" + self.fallback_images[f"{stage.value}_{egg_type.value}"] = str(fallback_path) + + # Create a simple placeholder if file doesn't exist + if not fallback_path.exists(): + self._create_placeholder_image(fallback_path, stage, egg_type) + + def _create_placeholder_image(self, path: Path, stage: LifeStage, egg_type: EggType): + """Create a simple placeholder image.""" + try: + # Create a simple colored rectangle as placeholder + color_map = { + EggType.RED: (255, 100, 100), + EggType.BLUE: (100, 100, 255), + EggType.GREEN: (100, 255, 100) + } + + color = color_map.get(egg_type, (128, 128, 128)) + img = Image.new('RGB', (512, 512), color) + img.save(path) + logger.info(f"Created placeholder image: {path}") + + except Exception as e: + logger.error(f"Failed to create placeholder image {path}: {e}") + + def _load_model(self): + """Load the FLUX.1-dev model for image generation.""" + if self._model_loaded: + return + + try: + from diffusers import FluxPipeline + + logger.info(f"Loading image generation model: {self.model_name}") + self.pipe = FluxPipeline.from_pretrained( + self.model_name, + torch_dtype=torch.bfloat16 + ) + + # Enable CPU offload to save VRAM + self.pipe.enable_model_cpu_offload() + + self._model_loaded = True + logger.info("Image generation model loaded successfully") + + except ImportError: + logger.error("diffusers library not installed. Run: pip install -U diffusers") + raise + except Exception as e: + logger.error(f"Failed to load image generation model: {e}") + raise + + def generate_prompt(self, digipal: DigiPal) -> str: + """ + Generate a professional prompt for DigiPal image generation. + + Args: + digipal: DigiPal instance to generate prompt for + + Returns: + Professional prompt string for image generation + """ + egg_traits = self.egg_type_traits.get(digipal.egg_type, self.egg_type_traits[EggType.RED]) + stage_traits = self.stage_traits.get(digipal.life_stage, self.stage_traits[LifeStage.BABY]) + + # Build attribute modifiers based on DigiPal stats + attribute_modifiers = [] + + # High offense = more aggressive/fierce appearance + if digipal.offense > 50: + attribute_modifiers.append("fierce expression, sharp features") + + # High defense = more armored/protective appearance + if digipal.defense > 50: + attribute_modifiers.append("armored, protective stance") + + # High speed = more sleek/agile appearance + if digipal.speed > 50: + attribute_modifiers.append("sleek, agile build") + + # High brains = more intelligent/wise appearance + if digipal.brains > 50: + attribute_modifiers.append("intelligent eyes, wise demeanor") + + # Happiness affects expression + if digipal.happiness > 70: + attribute_modifiers.append("happy, cheerful expression") + elif digipal.happiness < 30: + attribute_modifiers.append("sad, tired expression") + + # Build the complete prompt + prompt_parts = [ + f"a {stage_traits['form']} digimon", + f"touched by the power of {egg_traits['element']}", + f"{stage_traits['size']}, {stage_traits['features']}", + f"colors: {egg_traits['colors']}", + f"{egg_traits['traits']}" + ] + + if attribute_modifiers: + prompt_parts.append(", ".join(attribute_modifiers)) + + prompt_parts.extend([ + f"in {egg_traits['environment']}", + f"life stage: {digipal.life_stage.value}", + self.style_base + ]) + + prompt = ", ".join(prompt_parts) + + logger.debug(f"Generated prompt for {digipal.name}: {prompt}") + return prompt + + def _get_cache_key(self, prompt: str, params: Dict[str, Any]) -> str: + """Generate cache key for image based on prompt and parameters.""" + cache_data = { + "prompt": prompt, + "params": params + } + cache_string = json.dumps(cache_data, sort_keys=True) + return hashlib.md5(cache_string.encode()).hexdigest() + + def _get_cached_image_path(self, cache_key: str) -> Optional[Path]: + """Check if cached image exists and return path.""" + cache_path = self.cache_dir / f"{cache_key}.png" + if cache_path.exists(): + logger.debug(f"Found cached image: {cache_path}") + return cache_path + return None + + def _save_to_cache(self, image: Image.Image, cache_key: str) -> Path: + """Save generated image to cache.""" + cache_path = self.cache_dir / f"{cache_key}.png" + image.save(cache_path) + logger.info(f"Saved generated image to cache: {cache_path}") + return cache_path + + def _get_fallback_image(self, digipal: DigiPal) -> str: + """Get fallback image path for DigiPal.""" + fallback_key = f"{digipal.life_stage.value}_{digipal.egg_type.value}" + fallback_path = self.fallback_images.get(fallback_key) + + if fallback_path and Path(fallback_path).exists(): + logger.info(f"Using fallback image: {fallback_path}") + return fallback_path + + # Ultimate fallback - create a generic placeholder + generic_fallback = self.fallback_dir / "generic_placeholder.png" + if not generic_fallback.exists(): + self._create_placeholder_image(generic_fallback, digipal.life_stage, digipal.egg_type) + + logger.warning(f"Using generic fallback image: {generic_fallback}") + return str(generic_fallback) + + def generate_image(self, digipal: DigiPal, force_regenerate: bool = False) -> str: + """ + Generate or retrieve cached image for DigiPal. + + Args: + digipal: DigiPal instance to generate image for + force_regenerate: Force regeneration even if cached image exists + + Returns: + Path to generated or cached image file + """ + try: + # Generate prompt + prompt = self.generate_prompt(digipal) + + # Check cache first (unless force regenerate) + cache_key = self._get_cache_key(prompt, self.generation_params) + + if not force_regenerate: + cached_path = self._get_cached_image_path(cache_key) + if cached_path: + return str(cached_path) + + # Load model if not already loaded + self._load_model() + + # Generate image + logger.info(f"Generating image for {digipal.name} ({digipal.life_stage.value})") + + generator = torch.Generator("cpu").manual_seed( + hash(digipal.id) % (2**32) # Consistent seed based on DigiPal ID + ) + + image = self.pipe( + prompt, + generator=generator, + **self.generation_params + ).images[0] + + # Save to cache + cache_path = self._save_to_cache(image, cache_key) + + # Update DigiPal with new image info + digipal.current_image_path = str(cache_path) + digipal.image_generation_prompt = prompt + + return str(cache_path) + + except Exception as e: + logger.error(f"Image generation failed for {digipal.name}: {e}") + + # Return fallback image + fallback_path = self._get_fallback_image(digipal) + digipal.current_image_path = fallback_path + digipal.image_generation_prompt = f"Fallback image for {digipal.life_stage.value} {digipal.egg_type.value}" + + return fallback_path + + def update_image_for_evolution(self, digipal: DigiPal) -> str: + """ + Generate new image when DigiPal evolves to new life stage. + + Args: + digipal: DigiPal that has evolved + + Returns: + Path to new image file + """ + logger.info(f"Generating evolution image for {digipal.name} -> {digipal.life_stage.value}") + return self.generate_image(digipal, force_regenerate=True) + + def cleanup_cache(self, max_age_days: int = 30): + """ + Clean up old cached images. + + Args: + max_age_days: Maximum age of cached images in days + """ + try: + current_time = datetime.now() + cleaned_count = 0 + + for image_file in self.cache_dir.glob("*.png"): + file_age = current_time - datetime.fromtimestamp(image_file.stat().st_mtime) + + if file_age.days > max_age_days: + image_file.unlink() + cleaned_count += 1 + + logger.info(f"Cleaned up {cleaned_count} old cached images") + + except Exception as e: + logger.error(f"Cache cleanup failed: {e}") + + def get_cache_info(self) -> Dict[str, Any]: + """Get information about the image cache.""" + try: + cache_files = list(self.cache_dir.glob("*.png")) + total_size = sum(f.stat().st_size for f in cache_files) + + return { + "cache_dir": str(self.cache_dir), + "cached_images": len(cache_files), + "total_size_mb": round(total_size / (1024 * 1024), 2), + "model_loaded": self._model_loaded + } + + except Exception as e: + logger.error(f"Failed to get cache info: {e}") + return {"error": str(e)} \ No newline at end of file diff --git a/digipal/ai/language_model.py b/digipal/ai/language_model.py new file mode 100644 index 0000000000000000000000000000000000000000..b7672d8aa5d5f05dee40f5924e32762494a73530 --- /dev/null +++ b/digipal/ai/language_model.py @@ -0,0 +1,532 @@ +""" +Language model integration for DigiPal using Qwen3-0.6B. + +This module handles the integration with Qwen/Qwen3-0.6B model for natural language +processing, including model loading, quantization, and context-aware response generation. +""" + +import logging +import torch +from typing import Dict, List, Optional, Any, Tuple +from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig +import json +from datetime import datetime + +from ..core.models import DigiPal, Interaction +from ..core.enums import LifeStage +from ..core.exceptions import AIModelError, NetworkError +from ..core.error_handler import with_error_handling, with_retry, RetryConfig +from .graceful_degradation import with_ai_fallback, ai_service_manager + + +logger = logging.getLogger(__name__) + + +class LanguageModel: + """ + Manages Qwen3-0.6B model for natural language processing with DigiPal context. + """ + + def __init__(self, model_name: str = "Qwen/Qwen3-0.6B", quantization: bool = True): + """ + Initialize the language model. + + Args: + model_name: HuggingFace model identifier + quantization: Whether to use quantization for memory optimization + """ + self.model_name = model_name + self.quantization = quantization + self.tokenizer = None + self.model = None + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + # Initialize prompt templates + self.prompt_templates = self._initialize_prompt_templates() + + logger.info(f"LanguageModel initialized with model: {model_name}") + logger.info(f"Device: {self.device}") + logger.info(f"Quantization: {quantization}") + + @with_error_handling(fallback_value=False, context={'operation': 'model_loading'}) + @with_retry(RetryConfig(max_attempts=3, retry_on=[NetworkError, ConnectionError])) + def load_model(self) -> bool: + """ + Load the Qwen3-0.6B model and tokenizer. + + Returns: + True if model loaded successfully, False otherwise + """ + try: + logger.info(f"Loading tokenizer for {self.model_name}") + self.tokenizer = AutoTokenizer.from_pretrained(self.model_name) + + # Configure quantization if enabled + model_kwargs = { + "torch_dtype": "auto", + "device_map": "auto" + } + + if self.quantization and torch.cuda.is_available(): + logger.info("Configuring 4-bit quantization") + quantization_config = BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_compute_dtype=torch.float16, + bnb_4bit_use_double_quant=True, + bnb_4bit_quant_type="nf4" + ) + model_kwargs["quantization_config"] = quantization_config + + logger.info(f"Loading model {self.model_name}") + self.model = AutoModelForCausalLM.from_pretrained( + self.model_name, + **model_kwargs + ) + + logger.info("Model loaded successfully") + return True + + except (ConnectionError, TimeoutError) as e: + raise NetworkError(f"Network error loading model: {str(e)}") + except Exception as e: + raise AIModelError(f"Failed to load model: {str(e)}") + + @with_ai_fallback("language_model") + def generate_response(self, user_input: str, pet: DigiPal, memory_context: str = "", max_tokens: int = 150) -> str: + """ + Generate contextual response using Qwen3-0.6B model. + + Args: + user_input: User's input text + pet: DigiPal instance for context + memory_context: Additional memory context from RAG system + max_tokens: Maximum tokens to generate + + Returns: + Generated response text + """ + if not self.model or not self.tokenizer: + logger.warning("Model not loaded, using fallback response") + raise AIModelError("Language model not loaded") + + try: + # Create context-aware prompt with memory context + prompt = self._create_prompt(user_input, pet, memory_context) + + # Prepare messages for chat template + messages = [ + {"role": "user", "content": prompt} + ] + + # Apply chat template + text = self.tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True, + enable_thinking=True + ) + + # Tokenize input + model_inputs = self.tokenizer([text], return_tensors="pt").to(self.model.device) + + # Generate response + with torch.no_grad(): + generated_ids = self.model.generate( + **model_inputs, + max_new_tokens=max_tokens, + do_sample=True, + temperature=0.7, + top_p=0.9, + pad_token_id=self.tokenizer.eos_token_id + ) + + # Extract generated tokens + output_ids = generated_ids[0][len(model_inputs.input_ids[0]):].tolist() + + # Parse thinking content and actual response + thinking_content, content = self._parse_response(output_ids) + + # Log thinking content for debugging + if thinking_content: + logger.debug(f"Model thinking: {thinking_content[:100]}...") + + # Clean and validate response + response = self._clean_response(content, pet) + + logger.debug(f"Generated response: {response}") + return response + + except torch.cuda.OutOfMemoryError as e: + raise AIModelError(f"GPU memory error: {str(e)}") + except Exception as e: + logger.error(f"Error generating response: {e}") + raise AIModelError(f"Language model generation failed: {str(e)}") + + def _create_prompt(self, user_input: str, pet: DigiPal, memory_context: str = "") -> str: + """ + Create context-aware prompt incorporating pet state, personality, and memory context. + + Args: + user_input: User's input text + pet: DigiPal instance for context + memory_context: Additional memory context from RAG system + + Returns: + Formatted prompt string + """ + # Get base template for life stage + template = self.prompt_templates.get(pet.life_stage, self.prompt_templates[LifeStage.BABY]) + + # Get recent conversation context + recent_interactions = pet.conversation_history[-3:] if pet.conversation_history else [] + conversation_context = "" + if recent_interactions: + conversation_context = "\n".join([ + f"User: {interaction.user_input}\nDigiPal: {interaction.pet_response}" + for interaction in recent_interactions + ]) + + # Calculate personality description + personality_desc = self._get_personality_description(pet) + + # Format the prompt with memory context + prompt = template.format( + name=pet.name, + life_stage=pet.life_stage.value, + hp=pet.hp, + happiness=pet.happiness, + energy=pet.energy, + discipline=pet.discipline, + age_hours=pet.get_age_hours(), + personality=personality_desc, + recent_conversation=conversation_context, + memory_context=memory_context, + user_input=user_input + ) + + return prompt + + def _initialize_prompt_templates(self) -> Dict[LifeStage, str]: + """ + Initialize prompt templates for each life stage. + + Returns: + Dictionary mapping life stages to prompt templates + """ + return { + LifeStage.EGG: """ +You are a DigiPal egg named {name}. You cannot speak or respond directly, but you can show subtle reactions. +The user said: "{user_input}" +Respond with a very brief description of the egg's reaction (1-2 words or simple action). +""", + + LifeStage.BABY: """ +You are {name}, a baby DigiPal in the {life_stage} stage. You are {age_hours:.1f} hours old. +Current stats: HP={hp}, Happiness={happiness}, Energy={energy}, Discipline={discipline} +Personality: {personality} + +As a baby, you can only understand basic commands: eat, sleep, good, bad. +You communicate with simple baby sounds, single words, and basic emotions. +You are curious, innocent, and learning about the world. + +Recent conversation: +{recent_conversation} + +{memory_context} + +User just said: "{user_input}" + +Respond as a baby DigiPal would - keep it simple, innocent, and age-appropriate. Use baby talk, simple words, and express basic emotions. +""", + + LifeStage.CHILD: """ +You are {name}, a child DigiPal in the {life_stage} stage. You are {age_hours:.1f} hours old. +Current stats: HP={hp}, Happiness={happiness}, Energy={energy}, Discipline={discipline} +Personality: {personality} + +As a child, you understand: eat, sleep, good, bad, play, train. +You are energetic, playful, and eager to learn. You speak in simple sentences and show enthusiasm. + +Recent conversation: +{recent_conversation} + +{memory_context} + +User just said: "{user_input}" + +Respond as a child DigiPal would - enthusiastic, simple language, and show interest in play and learning. +""", + + LifeStage.TEEN: """ +You are {name}, a teenage DigiPal in the {life_stage} stage. You are {age_hours:.1f} hours old. +Current stats: HP={hp}, Happiness={happiness}, Energy={energy}, Discipline={discipline} +Personality: {personality} + +As a teen, you understand most commands and can have conversations. +You're developing your own personality, sometimes moody, but generally cooperative. +You can be a bit rebellious but still care about your relationship with your caretaker. + +Recent conversation: +{recent_conversation} + +{memory_context} + +User just said: "{user_input}" + +Respond as a teenage DigiPal would - more complex thoughts, some attitude, but still caring. +""", + + LifeStage.YOUNG_ADULT: """ +You are {name}, a young adult DigiPal in the {life_stage} stage. You are {age_hours:.1f} hours old. +Current stats: HP={hp}, Happiness={happiness}, Energy={energy}, Discipline={discipline} +Personality: {personality} + +As a young adult, you're confident, capable, and have developed your full personality. +You can engage in complex conversations and understand all commands. +You're at your physical and mental peak, ready for challenges. + +Recent conversation: +{recent_conversation} + +{memory_context} + +User just said: "{user_input}" + +Respond as a confident young adult DigiPal - articulate, capable, and engaging. +""", + + LifeStage.ADULT: """ +You are {name}, an adult DigiPal in the {life_stage} stage. You are {age_hours:.1f} hours old. +Current stats: HP={hp}, Happiness={happiness}, Energy={energy}, Discipline={discipline} +Personality: {personality} + +As an adult, you're wise, mature, and thoughtful in your responses. +You have deep understanding and can provide guidance and wisdom. +You're protective and caring, with a strong bond to your caretaker. + +Recent conversation: +{recent_conversation} + +{memory_context} + +User just said: "{user_input}" + +Respond as a mature adult DigiPal - wise, thoughtful, and caring. +""", + + LifeStage.ELDERLY: """ +You are {name}, an elderly DigiPal in the {life_stage} stage. You are {age_hours:.1f} hours old. +Current stats: HP={hp}, Happiness={happiness}, Energy={energy}, Discipline={discipline} +Personality: {personality} + +As an elderly DigiPal, you're wise from experience but also nostalgic and gentle. +You move slower but think deeply. You cherish every moment with your caretaker. +You often reflect on memories and share wisdom from your long life. + +Recent conversation: +{recent_conversation} + +{memory_context} + +User just said: "{user_input}" + +Respond as an elderly DigiPal - gentle, wise, nostalgic, and deeply caring. +""" + } + + def _get_personality_description(self, pet: DigiPal) -> str: + """ + Generate personality description from pet's personality traits. + + Args: + pet: DigiPal instance + + Returns: + Human-readable personality description + """ + if not pet.personality_traits: + return "developing personality" + + traits = [] + + # Analyze personality traits + if pet.personality_traits.get('friendliness', 0.5) > 0.7: + traits.append("very friendly") + elif pet.personality_traits.get('friendliness', 0.5) < 0.3: + traits.append("somewhat shy") + + if pet.personality_traits.get('playfulness', 0.5) > 0.7: + traits.append("very playful") + elif pet.personality_traits.get('playfulness', 0.5) < 0.3: + traits.append("more serious") + + if pet.personality_traits.get('obedience', 0.5) > 0.7: + traits.append("well-behaved") + elif pet.personality_traits.get('obedience', 0.5) < 0.3: + traits.append("a bit rebellious") + + if pet.personality_traits.get('curiosity', 0.5) > 0.7: + traits.append("very curious") + + return ", ".join(traits) if traits else "balanced personality" + + def _parse_response(self, output_ids: List[int]) -> Tuple[str, str]: + """ + Parse thinking content and actual response from model output. + + Args: + output_ids: Generated token IDs + + Returns: + Tuple of (thinking_content, actual_response) + """ + try: + # Look for thinking end token (151668 = ) + index = len(output_ids) - output_ids[::-1].index(151668) + except ValueError: + # No thinking content found + index = 0 + + thinking_content = "" + content = "" + + if index > 0: + thinking_content = self.tokenizer.decode( + output_ids[:index], + skip_special_tokens=True + ).strip("\n") + + if index < len(output_ids): + content = self.tokenizer.decode( + output_ids[index:], + skip_special_tokens=True + ).strip("\n") + else: + # If no content after thinking, use full output + content = self.tokenizer.decode( + output_ids, + skip_special_tokens=True + ).strip("\n") + + return thinking_content, content + + def _clean_response(self, response: str, pet: DigiPal) -> str: + """ + Clean and validate the generated response. + + Args: + response: Raw generated response + pet: DigiPal instance for context + + Returns: + Cleaned response string + """ + # Remove any unwanted prefixes or suffixes + response = response.strip() + + # Remove common AI assistant prefixes (more precise matching) + prefixes_to_remove = [ + "As a DigiPal, ", "As your DigiPal, ", "DigiPal: ", f"{pet.name}: ", + "Response: " + ] + + for prefix in prefixes_to_remove: + if response.startswith(prefix): + response = response[len(prefix):].strip() + break # Only remove one prefix + + # Limit response length based on life stage + max_lengths = { + LifeStage.EGG: 20, + LifeStage.BABY: 50, + LifeStage.CHILD: 100, + LifeStage.TEEN: 150, + LifeStage.YOUNG_ADULT: 200, + LifeStage.ADULT: 200, + LifeStage.ELDERLY: 180 + } + + max_length = max_lengths.get(pet.life_stage, 100) + if len(response) > max_length: + # Find last complete sentence within limit + sentences = response.split('.') + truncated = "" + for sentence in sentences: + potential = truncated + sentence.strip() + if len(potential) <= max_length - 1: # Leave room for period + truncated = potential + "." + else: + break + + if truncated and len(truncated) > 10: # Ensure we have meaningful content + response = truncated.strip() + else: + # If no complete sentence fits, truncate at word boundary + words = response.split() + truncated_words = [] + current_length = 0 + + for word in words: + if current_length + len(word) + 1 <= max_length - 3: # Leave room for "..." + truncated_words.append(word) + current_length += len(word) + 1 + else: + break + + if truncated_words: + response = " ".join(truncated_words) + "..." + else: + response = response[:max_length-3] + "..." + + # Ensure response is not empty + if not response: + response = self._fallback_response("", pet) + + return response + + def _fallback_response(self, user_input: str, pet: DigiPal) -> str: + """ + Generate fallback response when model is unavailable. + + Args: + user_input: User's input text + pet: DigiPal instance + + Returns: + Fallback response string + """ + fallback_responses = { + LifeStage.EGG: "*The egg remains silent*", + LifeStage.BABY: "*baby sounds*", + LifeStage.CHILD: "I'm still learning!", + LifeStage.TEEN: "Hmm, let me think about that...", + LifeStage.YOUNG_ADULT: "That's interesting to consider.", + LifeStage.ADULT: "I understand what you're saying.", + LifeStage.ELDERLY: "Ah, yes... I see..." + } + + return fallback_responses.get(pet.life_stage, "I'm listening...") + + def is_loaded(self) -> bool: + """ + Check if the model is loaded and ready. + + Returns: + True if model is loaded, False otherwise + """ + return self.model is not None and self.tokenizer is not None + + def get_model_info(self) -> Dict[str, Any]: + """ + Get information about the loaded model. + + Returns: + Dictionary with model information + """ + return { + 'model_name': self.model_name, + 'quantization': self.quantization, + 'device': str(self.device), + 'loaded': self.is_loaded(), + 'memory_usage': torch.cuda.memory_allocated() if torch.cuda.is_available() else 0 + } \ No newline at end of file diff --git a/digipal/ai/speech_processor.py b/digipal/ai/speech_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..94508366d0d0fd527fb14700494cfdbe1613e0fe --- /dev/null +++ b/digipal/ai/speech_processor.py @@ -0,0 +1,510 @@ +""" +Speech processing module using Kyutai speech-to-text models. + +This module provides speech-to-text functionality using Kyutai's STT models +with audio validation, preprocessing, and error handling. +""" + +import torch +import numpy as np +import logging +from typing import Optional, Dict, Any, Union, List +from dataclasses import dataclass +import io +import wave +from transformers import KyutaiSpeechToTextProcessor, KyutaiSpeechToTextForConditionalGeneration + +logger = logging.getLogger(__name__) + + +@dataclass +class AudioValidationResult: + """Result of audio validation checks.""" + is_valid: bool + sample_rate: int + duration: float + channels: int + issues: List[str] + + +@dataclass +class SpeechProcessingResult: + """Result of speech processing operation.""" + success: bool + transcribed_text: str + confidence: float + processing_time: float + error_message: Optional[str] = None + + +class AudioValidator: + """Validates and preprocesses audio input for speech recognition.""" + + def __init__(self, target_sample_rate: int = 24000, min_duration: float = 0.1, max_duration: float = 30.0): + """ + Initialize audio validator. + + Args: + target_sample_rate: Target sample rate for processing (24kHz for Kyutai) + min_duration: Minimum audio duration in seconds + max_duration: Maximum audio duration in seconds + """ + self.target_sample_rate = target_sample_rate + self.min_duration = min_duration + self.max_duration = max_duration + + def validate_audio(self, audio_data: Union[bytes, np.ndarray], sample_rate: Optional[int] = None) -> AudioValidationResult: + """ + Validate audio data for speech processing. + + Args: + audio_data: Raw audio data as bytes or numpy array + sample_rate: Sample rate of the audio data + + Returns: + AudioValidationResult with validation details + """ + issues = [] + + try: + # Convert bytes to numpy array if needed + if isinstance(audio_data, bytes): + audio_array, detected_sample_rate = self._bytes_to_array(audio_data) + if sample_rate is None: + sample_rate = detected_sample_rate + else: + audio_array = audio_data + if sample_rate is None: + sample_rate = self.target_sample_rate + + # Check if audio array is valid + if audio_array is None or len(audio_array) == 0: + issues.append("Empty or invalid audio data") + return AudioValidationResult(False, 0, 0.0, 0, issues) + + # Calculate duration + duration = len(audio_array) / sample_rate + + # Detect number of channels + if audio_array.ndim == 1: + channels = 1 + else: + channels = audio_array.shape[1] if audio_array.ndim == 2 else 1 + # Convert to mono if stereo + if channels > 1: + audio_array = np.mean(audio_array, axis=1) + channels = 1 + + # Validate duration + if duration < self.min_duration: + issues.append(f"Audio too short: {duration:.2f}s (minimum: {self.min_duration}s)") + + if duration > self.max_duration: + issues.append(f"Audio too long: {duration:.2f}s (maximum: {self.max_duration}s)") + + # Check sample rate + if sample_rate != self.target_sample_rate: + issues.append(f"Sample rate mismatch: {sample_rate}Hz (expected: {self.target_sample_rate}Hz)") + + # Check for silence (very low amplitude) + if np.max(np.abs(audio_array)) < 0.01: + issues.append("Audio appears to be silent or very quiet") + + # Check for clipping + if np.max(np.abs(audio_array)) > 0.95: + issues.append("Audio may be clipped (too loud)") + + is_valid = len(issues) == 0 + + return AudioValidationResult( + is_valid=is_valid, + sample_rate=sample_rate, + duration=duration, + channels=channels, + issues=issues + ) + + except Exception as e: + logger.error(f"Error validating audio: {e}") + issues.append(f"Validation error: {str(e)}") + return AudioValidationResult(False, 0, 0.0, 0, issues) + + def _bytes_to_array(self, audio_bytes: bytes) -> tuple[Optional[np.ndarray], int]: + """ + Convert audio bytes to numpy array. + + Args: + audio_bytes: Raw audio bytes + + Returns: + Tuple of (audio_array, sample_rate) + """ + try: + # Try to parse as WAV file + with io.BytesIO(audio_bytes) as audio_io: + with wave.open(audio_io, 'rb') as wav_file: + sample_rate = wav_file.getframerate() + channels = wav_file.getnchannels() + sample_width = wav_file.getsampwidth() + frames = wav_file.readframes(-1) + + # Convert to numpy array + if sample_width == 1: + audio_array = np.frombuffer(frames, dtype=np.uint8) + audio_array = (audio_array.astype(np.float32) - 128) / 128.0 + elif sample_width == 2: + audio_array = np.frombuffer(frames, dtype=np.int16) + audio_array = audio_array.astype(np.float32) / 32768.0 + elif sample_width == 4: + audio_array = np.frombuffer(frames, dtype=np.int32) + audio_array = audio_array.astype(np.float32) / 2147483648.0 + else: + raise ValueError(f"Unsupported sample width: {sample_width}") + + # Handle stereo to mono conversion + if channels == 2: + audio_array = audio_array.reshape(-1, 2) + audio_array = np.mean(audio_array, axis=1) + + return audio_array, sample_rate + + except Exception as e: + logger.warning(f"Failed to parse as WAV: {e}") + + # Fallback: assume raw 16-bit PCM at target sample rate + try: + audio_array = np.frombuffer(audio_bytes, dtype=np.int16) + audio_array = audio_array.astype(np.float32) / 32768.0 + return audio_array, self.target_sample_rate + except Exception as e: + logger.error(f"Failed to convert audio bytes: {e}") + return None, 0 + + def preprocess_audio(self, audio_array: np.ndarray, sample_rate: int) -> np.ndarray: + """ + Preprocess audio for optimal speech recognition. + + Args: + audio_array: Audio data as numpy array + sample_rate: Current sample rate + + Returns: + Preprocessed audio array + """ + try: + # Resample if needed + if sample_rate != self.target_sample_rate: + audio_array = self._resample_audio(audio_array, sample_rate, self.target_sample_rate) + + # Apply noise reduction (simple high-pass filter) + audio_array = self._apply_noise_reduction(audio_array) + + # Normalize audio + audio_array = self._normalize_audio(audio_array) + + return audio_array + + except Exception as e: + logger.error(f"Error preprocessing audio: {e}") + return audio_array + + def _resample_audio(self, audio_array: np.ndarray, from_rate: int, to_rate: int) -> np.ndarray: + """Resample audio to target sample rate.""" + if from_rate == to_rate: + return audio_array + + # Simple linear interpolation resampling + # For production, consider using scipy.signal.resample or librosa + ratio = to_rate / from_rate + new_length = int(len(audio_array) * ratio) + + # Create new time indices + old_indices = np.arange(len(audio_array)) + new_indices = np.linspace(0, len(audio_array) - 1, new_length) + + # Interpolate + resampled = np.interp(new_indices, old_indices, audio_array) + + return resampled + + def _apply_noise_reduction(self, audio_array: np.ndarray) -> np.ndarray: + """Apply basic noise reduction (high-pass filter).""" + # Simple high-pass filter to remove low-frequency noise + # This is a basic implementation; for production, use proper DSP libraries + + if len(audio_array) < 3: + return audio_array + + # Simple first-order high-pass filter + alpha = 0.95 + filtered = np.zeros_like(audio_array) + filtered[0] = audio_array[0] + + for i in range(1, len(audio_array)): + filtered[i] = alpha * (filtered[i-1] + audio_array[i] - audio_array[i-1]) + + return filtered + + def _normalize_audio(self, audio_array: np.ndarray) -> np.ndarray: + """Normalize audio amplitude.""" + max_val = np.max(np.abs(audio_array)) + if max_val > 0: + # Normalize to 70% of maximum to avoid clipping + return audio_array * (0.7 / max_val) + return audio_array + + +class SpeechProcessor: + """ + Main speech processing class using Kyutai speech-to-text models. + """ + + def __init__(self, model_id: str = "kyutai/stt-2.6b-en_fr-trfs", device: Optional[str] = None): + """ + Initialize speech processor with Kyutai model. + + Args: + model_id: HuggingFace model identifier for Kyutai STT + device: Device to run model on ('cuda', 'cpu', or None for auto) + """ + self.model_id = model_id + self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") + + # Initialize components + self.processor = None + self.model = None + self.audio_validator = AudioValidator() + self._model_loaded = False + + logger.info(f"SpeechProcessor initialized with model: {model_id}") + logger.info(f"Using device: {self.device}") + + def load_model(self) -> bool: + """ + Load the Kyutai speech-to-text model and processor. + + Returns: + True if model loaded successfully, False otherwise + """ + try: + logger.info(f"Loading Kyutai model: {self.model_id}") + + # Load processor + self.processor = KyutaiSpeechToTextProcessor.from_pretrained(self.model_id) + logger.info("Processor loaded successfully") + + # Load model + self.model = KyutaiSpeechToTextForConditionalGeneration.from_pretrained( + self.model_id, + device_map=self.device, + torch_dtype="auto" + ) + logger.info("Model loaded successfully") + + self._model_loaded = True + return True + + except Exception as e: + logger.error(f"Failed to load Kyutai model: {e}") + self._model_loaded = False + return False + + def is_model_loaded(self) -> bool: + """ + Check if the model is loaded and ready. + + Returns: + True if model is loaded, False otherwise + """ + return self._model_loaded and self.processor is not None and self.model is not None + + def process_speech(self, audio_data: Union[bytes, np.ndarray], sample_rate: Optional[int] = None) -> SpeechProcessingResult: + """ + Process speech audio and convert to text. + + Args: + audio_data: Raw audio data as bytes or numpy array + sample_rate: Sample rate of the audio data + + Returns: + SpeechProcessingResult with transcription and metadata + """ + import time + start_time = time.time() + + try: + # Ensure model is loaded + if not self.is_model_loaded(): + if not self.load_model(): + return SpeechProcessingResult( + success=False, + transcribed_text="", + confidence=0.0, + processing_time=time.time() - start_time, + error_message="Failed to load speech recognition model" + ) + + # Validate audio + validation_result = self.audio_validator.validate_audio(audio_data, sample_rate) + + if not validation_result.is_valid: + error_msg = f"Audio validation failed: {', '.join(validation_result.issues)}" + logger.warning(error_msg) + return SpeechProcessingResult( + success=False, + transcribed_text="", + confidence=0.0, + processing_time=time.time() - start_time, + error_message=error_msg + ) + + # Convert to numpy array if needed + if isinstance(audio_data, bytes): + audio_array, detected_sample_rate = self.audio_validator._bytes_to_array(audio_data) + if sample_rate is None: + sample_rate = detected_sample_rate + else: + audio_array = audio_data + if sample_rate is None: + sample_rate = validation_result.sample_rate + + # Preprocess audio + processed_audio = self.audio_validator.preprocess_audio(audio_array, sample_rate) + + # Prepare model inputs + inputs = self.processor(processed_audio) + inputs = inputs.to(self.device) + + # Generate transcription + with torch.no_grad(): + output_tokens = self.model.generate(**inputs) + + # Decode the generated tokens + transcribed_text = self.processor.batch_decode(output_tokens, skip_special_tokens=True)[0] + + # Clean up transcription + transcribed_text = self._clean_transcription(transcribed_text) + + processing_time = time.time() - start_time + + # Calculate confidence (placeholder - Kyutai doesn't provide confidence scores directly) + confidence = self._estimate_confidence(transcribed_text, validation_result) + + logger.info(f"Speech processed successfully in {processing_time:.2f}s: '{transcribed_text}'") + + return SpeechProcessingResult( + success=True, + transcribed_text=transcribed_text, + confidence=confidence, + processing_time=processing_time + ) + + except Exception as e: + error_msg = f"Speech processing error: {str(e)}" + logger.error(error_msg) + + return SpeechProcessingResult( + success=False, + transcribed_text="", + confidence=0.0, + processing_time=time.time() - start_time, + error_message=error_msg + ) + + def _clean_transcription(self, text: str) -> str: + """ + Clean and normalize transcribed text. + + Args: + text: Raw transcribed text + + Returns: + Cleaned transcription + """ + if not text: + return "" + + # Remove extra whitespace + text = " ".join(text.split()) + + # Remove common transcription artifacts + text = text.replace("[NOISE]", "").replace("[SILENCE]", "") + text = text.replace(" ", " ").strip() + + return text + + def _estimate_confidence(self, transcribed_text: str, validation_result: AudioValidationResult) -> float: + """ + Estimate confidence score for transcription. + + Args: + transcribed_text: Transcribed text + validation_result: Audio validation result + + Returns: + Confidence score between 0.0 and 1.0 + """ + # This is a simple heuristic-based confidence estimation + # In production, you might want to use model-specific confidence measures + + confidence = 0.5 # Base confidence + + # Adjust based on audio quality + if len(validation_result.issues) == 0: + confidence += 0.3 + else: + confidence -= 0.1 * len(validation_result.issues) + + # Adjust based on transcription length and content + if transcribed_text: + if len(transcribed_text.split()) >= 2: # Multiple words + confidence += 0.2 + if any(char.isalpha() for char in transcribed_text): # Contains letters + confidence += 0.1 + else: + confidence = 0.1 # Very low confidence for empty transcription + + # Adjust based on audio duration + if validation_result.duration > 1.0: # Longer audio generally more reliable + confidence += 0.1 + + return max(0.0, min(1.0, confidence)) + + def get_model_info(self) -> Dict[str, Any]: + """ + Get information about the loaded model. + + Returns: + Dictionary with model information + """ + return { + 'model_id': self.model_id, + 'device': self.device, + 'loaded': self.is_model_loaded(), + 'target_sample_rate': self.audio_validator.target_sample_rate, + 'supported_languages': ['en', 'fr'] # Kyutai STT supports English and French + } + + def unload_model(self) -> None: + """ + Unload the model to free memory. + """ + if self.model is not None: + del self.model + self.model = None + + if self.processor is not None: + del self.processor + self.processor = None + + self._model_loaded = False + + # Force garbage collection + import gc + gc.collect() + + # Clear CUDA cache if available + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + logger.info("Speech model unloaded") \ No newline at end of file diff --git a/digipal/auth/__init__.py b/digipal/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..82e435a9818a06f91f0730fcdb004005f0355d36 --- /dev/null +++ b/digipal/auth/__init__.py @@ -0,0 +1,19 @@ +""" +Authentication module for DigiPal application. + +This module provides HuggingFace authentication integration with session management +and offline development support. +""" + +from .auth_manager import AuthManager +from .session_manager import SessionManager +from .models import User, AuthSession, AuthResult, AuthStatus + +__all__ = [ + 'AuthManager', + 'SessionManager', + 'User', + 'AuthSession', + 'AuthResult', + 'AuthStatus' +] \ No newline at end of file diff --git a/digipal/auth/__pycache__/__init__.cpython-312.pyc b/digipal/auth/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a58c94f8ad70ee574d70aee3f6ff656ba876da4f Binary files /dev/null and b/digipal/auth/__pycache__/__init__.cpython-312.pyc differ diff --git a/digipal/auth/__pycache__/auth_manager.cpython-312.pyc b/digipal/auth/__pycache__/auth_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4cd0eac28eea9bcbd2bcf4409bad1354f58e3ee Binary files /dev/null and b/digipal/auth/__pycache__/auth_manager.cpython-312.pyc differ diff --git a/digipal/auth/__pycache__/models.cpython-312.pyc b/digipal/auth/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b7d6e1692d276a339f0a242694efa73b0ea6ceb Binary files /dev/null and b/digipal/auth/__pycache__/models.cpython-312.pyc differ diff --git a/digipal/auth/__pycache__/session_manager.cpython-312.pyc b/digipal/auth/__pycache__/session_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f909bed50d6b61b4f07d75216a0e6aa35eb7e2f Binary files /dev/null and b/digipal/auth/__pycache__/session_manager.cpython-312.pyc differ diff --git a/digipal/auth/auth_manager.py b/digipal/auth/auth_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..9172e7f55333805dafb483c45263479515b49beb --- /dev/null +++ b/digipal/auth/auth_manager.py @@ -0,0 +1,384 @@ +""" +HuggingFace authentication manager for DigiPal application. +""" + +import logging +import requests +import json +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from pathlib import Path + +from .models import User, AuthSession, AuthResult, AuthStatus +from .session_manager import SessionManager +from ..storage.database import DatabaseConnection + +logger = logging.getLogger(__name__) + + +class AuthManager: + """Manages HuggingFace authentication with offline support.""" + + # HuggingFace API endpoints + HF_API_BASE = "https://huggingface.co/api" + HF_USER_ENDPOINT = f"{HF_API_BASE}/whoami" + + def __init__(self, db_connection: DatabaseConnection, offline_mode: bool = False, cache_dir: Optional[str] = None): + """ + Initialize authentication manager. + + Args: + db_connection: Database connection for user storage + offline_mode: Enable offline development mode + cache_dir: Directory for authentication cache + """ + self.db = db_connection + self.offline_mode = offline_mode + self.session_manager = SessionManager(db_connection, cache_dir) + + # Request session for connection pooling + self.session = requests.Session() + self.session.timeout = 10 # 10 second timeout + + logger.info(f"AuthManager initialized (offline_mode: {offline_mode})") + + def authenticate(self, token: str) -> AuthResult: + """ + Authenticate user with HuggingFace token. + + Args: + token: HuggingFace authentication token + + Returns: + Authentication result with user and session info + """ + if self.offline_mode: + return self._authenticate_offline(token) + + try: + # Validate token with HuggingFace API + user_info = self._validate_hf_token(token) + if not user_info: + return AuthResult( + status=AuthStatus.INVALID_TOKEN, + error_message="Invalid HuggingFace token" + ) + + # Create or update user + user = self._create_or_update_user(user_info, token) + if not user: + return AuthResult( + status=AuthStatus.USER_NOT_FOUND, + error_message="Failed to create or update user" + ) + + # Create session + session = self.session_manager.create_session(user, token) + + logger.info(f"Successfully authenticated user: {user.username}") + return AuthResult( + status=AuthStatus.SUCCESS, + user=user, + session=session + ) + + except requests.exceptions.RequestException as e: + logger.warning(f"Network error during authentication: {e}") + # Try offline authentication as fallback + return self._authenticate_offline(token) + + except Exception as e: + logger.error(f"Authentication error: {e}") + return AuthResult( + status=AuthStatus.NETWORK_ERROR, + error_message=f"Authentication failed: {str(e)}" + ) + + def validate_session(self, user_id: str, token: str) -> AuthResult: + """ + Validate existing session. + + Args: + user_id: User ID + token: Authentication token + + Returns: + Authentication result + """ + # Check if session exists and is valid + if not self.session_manager.validate_session(user_id, token): + return AuthResult( + status=AuthStatus.EXPIRED_SESSION, + error_message="Session expired or invalid" + ) + + # Get user and session + user = self.get_user(user_id) + session = self.session_manager.get_session(user_id) + + if not user or not session: + return AuthResult( + status=AuthStatus.USER_NOT_FOUND, + error_message="User or session not found" + ) + + # Refresh session + self.session_manager.refresh_session(user_id) + + status = AuthStatus.OFFLINE_MODE if session.is_offline else AuthStatus.SUCCESS + return AuthResult( + status=status, + user=user, + session=session + ) + + def logout(self, user_id: str) -> bool: + """ + Logout user and revoke session. + + Args: + user_id: User ID to logout + + Returns: + True if logout successful + """ + success = self.session_manager.revoke_session(user_id) + if success: + logger.info(f"User {user_id} logged out successfully") + return success + + def get_user(self, user_id: str) -> Optional[User]: + """ + Get user by ID. + + Args: + user_id: User ID + + Returns: + User object if found + """ + try: + rows = self.db.execute_query( + 'SELECT * FROM users WHERE id = ?', + (user_id,) + ) + + if rows: + row = rows[0] + return User( + id=row['id'], + username=row['username'], + created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else datetime.now(), + last_login=datetime.fromisoformat(row['last_login']) if row['last_login'] else None + ) + except Exception as e: + logger.error(f"Error getting user {user_id}: {e}") + + return None + + def refresh_user_profile(self, user_id: str) -> Optional[User]: + """ + Refresh user profile from HuggingFace. + + Args: + user_id: User ID + + Returns: + Updated user object + """ + if self.offline_mode: + return self.get_user(user_id) + + try: + # Get current session to get token + session = self.session_manager.get_session(user_id) + if not session or session.is_offline: + return self.get_user(user_id) + + # Fetch updated user info + user_info = self._validate_hf_token(session.token) + if user_info: + user = self._create_or_update_user(user_info, session.token) + logger.info(f"Refreshed profile for user: {user_id}") + return user + + except Exception as e: + logger.error(f"Error refreshing user profile: {e}") + + return self.get_user(user_id) + + def cleanup_expired_sessions(self) -> int: + """Clean up expired sessions.""" + return self.session_manager.cleanup_expired_sessions() + + def _authenticate_offline(self, token: str) -> AuthResult: + """ + Authenticate in offline mode using cached data. + + Args: + token: Authentication token + + Returns: + Authentication result for offline mode + """ + # In offline mode, we create a development user + # This is for development purposes only + + if not token or len(token) < 10: + return AuthResult( + status=AuthStatus.INVALID_TOKEN, + error_message="Token too short for offline mode" + ) + + # Create a deterministic user ID from token + import hashlib + user_id = f"offline_{hashlib.md5(token.encode()).hexdigest()[:16]}" + username = f"dev_user_{user_id[-8:]}" + + # Check if offline user exists + user = self.get_user(user_id) + if not user: + # Create offline development user + user = User( + id=user_id, + username=username, + email=f"{username}@offline.dev", + full_name=f"Development User {username}", + created_at=datetime.now() + ) + + # Save to database + try: + self.db.execute_update( + '''INSERT OR REPLACE INTO users + (id, username, huggingface_token, created_at, last_login) + VALUES (?, ?, ?, ?, ?)''', + (user.id, user.username, token, + user.created_at.isoformat(), datetime.now().isoformat()) + ) + except Exception as e: + logger.error(f"Error creating offline user: {e}") + return AuthResult( + status=AuthStatus.NETWORK_ERROR, + error_message="Failed to create offline user" + ) + + # Create offline session + session = self.session_manager.create_session( + user, token, expires_hours=168, is_offline=True # 1 week for offline + ) + + logger.info(f"Offline authentication successful for: {username}") + return AuthResult( + status=AuthStatus.OFFLINE_MODE, + user=user, + session=session + ) + + def _validate_hf_token(self, token: str) -> Optional[Dict[str, Any]]: + """ + Validate token with HuggingFace API. + + Args: + token: HuggingFace token + + Returns: + User info dict if valid, None otherwise + """ + try: + headers = { + 'Authorization': f'Bearer {token}', + 'User-Agent': 'DigiPal/1.0' + } + + response = self.session.get(self.HF_USER_ENDPOINT, headers=headers) + + if response.status_code == 200: + user_info = response.json() + logger.debug(f"HF API response: {user_info}") + return user_info + elif response.status_code == 401: + logger.warning("Invalid HuggingFace token") + return None + else: + logger.error(f"HF API error: {response.status_code} - {response.text}") + return None + + except requests.exceptions.RequestException as e: + logger.error(f"Network error validating HF token: {e}") + raise + except Exception as e: + logger.error(f"Error validating HF token: {e}") + return None + + def _create_or_update_user(self, user_info: Dict[str, Any], token: str) -> Optional[User]: + """ + Create or update user from HuggingFace user info. + + Args: + user_info: User info from HuggingFace API + token: Authentication token + + Returns: + User object + """ + try: + # Extract user data from HF response + user_id = user_info.get('name', user_info.get('id', '')) + username = user_info.get('name', user_id) + email = user_info.get('email') + full_name = user_info.get('fullname', user_info.get('name')) + avatar_url = user_info.get('avatarUrl') + + if not user_id: + logger.error("No user ID in HuggingFace response") + return None + + # Check if user exists + existing_user = self.get_user(user_id) + now = datetime.now() + + if existing_user: + # Update existing user + self.db.execute_update( + '''UPDATE users SET + username = ?, huggingface_token = ?, last_login = ? + WHERE id = ?''', + (username, token, now.isoformat(), user_id) + ) + + # Update user object + existing_user.username = username + existing_user.last_login = now + return existing_user + else: + # Create new user + user = User( + id=user_id, + username=username, + email=email, + full_name=full_name, + avatar_url=avatar_url, + created_at=now, + last_login=now + ) + + self.db.execute_update( + '''INSERT INTO users + (id, username, huggingface_token, created_at, last_login) + VALUES (?, ?, ?, ?, ?)''', + (user.id, user.username, token, + user.created_at.isoformat(), user.last_login.isoformat()) + ) + + logger.info(f"Created new user: {username}") + return user + + except Exception as e: + logger.error(f"Error creating/updating user: {e}") + return None + + def __del__(self): + """Cleanup resources.""" + if hasattr(self, 'session'): + self.session.close() \ No newline at end of file diff --git a/digipal/auth/models.py b/digipal/auth/models.py new file mode 100644 index 0000000000000000000000000000000000000000..4e2178715fee448e6698d4ed276ca799a545a82c --- /dev/null +++ b/digipal/auth/models.py @@ -0,0 +1,134 @@ +""" +Authentication data models for DigiPal application. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from enum import Enum +import json + + +class AuthStatus(Enum): + """Authentication status enumeration.""" + SUCCESS = "success" + INVALID_TOKEN = "invalid_token" + NETWORK_ERROR = "network_error" + OFFLINE_MODE = "offline_mode" + EXPIRED_SESSION = "expired_session" + USER_NOT_FOUND = "user_not_found" + + +@dataclass +class User: + """User model for authenticated users.""" + id: str + username: str + email: Optional[str] = None + full_name: Optional[str] = None + avatar_url: Optional[str] = None + created_at: datetime = field(default_factory=datetime.now) + last_login: Optional[datetime] = None + is_active: bool = True + + def to_dict(self) -> Dict[str, Any]: + """Convert user to dictionary for storage.""" + return { + 'id': self.id, + 'username': self.username, + 'email': self.email, + 'full_name': self.full_name, + 'avatar_url': self.avatar_url, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_login': self.last_login.isoformat() if self.last_login else None, + 'is_active': self.is_active + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'User': + """Create user from dictionary.""" + return cls( + id=data['id'], + username=data['username'], + email=data.get('email'), + full_name=data.get('full_name'), + avatar_url=data.get('avatar_url'), + created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else datetime.now(), + last_login=datetime.fromisoformat(data['last_login']) if data.get('last_login') else None, + is_active=data.get('is_active', True) + ) + + +@dataclass +class AuthSession: + """Authentication session model.""" + user_id: str + token: str + expires_at: datetime + created_at: datetime = field(default_factory=datetime.now) + last_accessed: datetime = field(default_factory=datetime.now) + is_offline: bool = False + session_data: Dict[str, Any] = field(default_factory=dict) + + @property + def is_expired(self) -> bool: + """Check if session is expired.""" + return datetime.now() > self.expires_at + + @property + def is_valid(self) -> bool: + """Check if session is valid (not expired and has token).""" + return not self.is_expired and bool(self.token) + + def refresh_access(self) -> None: + """Update last accessed timestamp.""" + self.last_accessed = datetime.now() + + def extend_session(self, hours: int = 24) -> None: + """Extend session expiration.""" + self.expires_at = datetime.now() + timedelta(hours=hours) + self.refresh_access() + + def to_dict(self) -> Dict[str, Any]: + """Convert session to dictionary for storage.""" + return { + 'user_id': self.user_id, + 'token': self.token, + 'expires_at': self.expires_at.isoformat(), + 'created_at': self.created_at.isoformat(), + 'last_accessed': self.last_accessed.isoformat(), + 'is_offline': self.is_offline, + 'session_data': json.dumps(self.session_data) + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'AuthSession': + """Create session from dictionary.""" + return cls( + user_id=data['user_id'], + token=data['token'], + expires_at=datetime.fromisoformat(data['expires_at']), + created_at=datetime.fromisoformat(data['created_at']), + last_accessed=datetime.fromisoformat(data['last_accessed']), + is_offline=data.get('is_offline', False), + session_data=json.loads(data.get('session_data', '{}')) + ) + + +@dataclass +class AuthResult: + """Result of authentication operation.""" + status: AuthStatus + user: Optional[User] = None + session: Optional[AuthSession] = None + error_message: Optional[str] = None + + @property + def is_success(self) -> bool: + """Check if authentication was successful.""" + return self.status == AuthStatus.SUCCESS + + @property + def is_offline(self) -> bool: + """Check if authentication is in offline mode.""" + return self.status == AuthStatus.OFFLINE_MODE \ No newline at end of file diff --git a/digipal/auth/session_manager.py b/digipal/auth/session_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..8a9531adf18967cb33f074fc0ae5fb21791300cc --- /dev/null +++ b/digipal/auth/session_manager.py @@ -0,0 +1,370 @@ +""" +Session management for DigiPal authentication system. +""" + +import logging +import json +import hashlib +import secrets +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from pathlib import Path + +from .models import User, AuthSession, AuthStatus +from ..storage.database import DatabaseConnection + +logger = logging.getLogger(__name__) + + +class SessionManager: + """Manages user sessions with secure token storage and caching.""" + + def __init__(self, db_connection: DatabaseConnection, cache_dir: Optional[str] = None): + """ + Initialize session manager. + + Args: + db_connection: Database connection for persistent storage + cache_dir: Directory for session cache files (optional) + """ + self.db = db_connection + self.cache_dir = Path(cache_dir) if cache_dir else Path.home() / '.digipal' / 'cache' + self.cache_dir.mkdir(parents=True, exist_ok=True) + + # In-memory session cache for performance + self._session_cache: Dict[str, AuthSession] = {} + + # Load existing sessions from database + self._load_sessions_from_db() + + def create_session(self, user: User, token: str, expires_hours: int = 24, is_offline: bool = False) -> AuthSession: + """ + Create a new authentication session. + + Args: + user: Authenticated user + token: Authentication token + expires_hours: Session expiration in hours + is_offline: Whether this is an offline session + + Returns: + Created authentication session + """ + expires_at = datetime.now() + timedelta(hours=expires_hours) + + session = AuthSession( + user_id=user.id, + token=token, + expires_at=expires_at, + is_offline=is_offline + ) + + # Ensure user exists in database before saving session + self._ensure_user_exists(user) + + # Store in database + self._save_session_to_db(session) + + # Cache in memory + self._session_cache[user.id] = session + + # Save to file cache for offline access + if not is_offline: + self._save_session_to_cache(session) + + logger.info(f"Created session for user {user.id} (offline: {is_offline})") + return session + + def get_session(self, user_id: str) -> Optional[AuthSession]: + """ + Get session for user ID. + + Args: + user_id: User ID to get session for + + Returns: + Authentication session if found and valid, None otherwise + """ + # Check memory cache first + if user_id in self._session_cache: + session = self._session_cache[user_id] + if session.is_valid: + session.refresh_access() + return session + else: + # Remove expired session + del self._session_cache[user_id] + self._remove_session_from_db(user_id) + + # Try to load from database + session = self._load_session_from_db(user_id) + if session and session.is_valid: + self._session_cache[user_id] = session + session.refresh_access() + return session + + # Try to load from cache for offline mode + cached_session = self._load_session_from_cache(user_id) + if cached_session: + # Mark as offline session + cached_session.is_offline = True + cached_session.extend_session(hours=168) # 1 week for offline + self._session_cache[user_id] = cached_session + return cached_session + + return None + + def validate_session(self, user_id: str, token: str) -> bool: + """ + Validate session token for user. + + Args: + user_id: User ID + token: Token to validate + + Returns: + True if session is valid, False otherwise + """ + session = self.get_session(user_id) + if not session: + return False + + # For offline sessions, we're more lenient with token validation + if session.is_offline: + return self._hash_token(token) == self._hash_token(session.token) + + return session.token == token and session.is_valid + + def refresh_session(self, user_id: str, extend_hours: int = 24) -> bool: + """ + Refresh session expiration. + + Args: + user_id: User ID + extend_hours: Hours to extend session + + Returns: + True if session was refreshed, False otherwise + """ + session = self.get_session(user_id) + if not session: + return False + + session.extend_session(extend_hours) + self._save_session_to_db(session) + + if not session.is_offline: + self._save_session_to_cache(session) + + logger.info(f"Refreshed session for user {user_id}") + return True + + def revoke_session(self, user_id: str) -> bool: + """ + Revoke user session. + + Args: + user_id: User ID + + Returns: + True if session was revoked, False if not found + """ + # Remove from memory cache + if user_id in self._session_cache: + del self._session_cache[user_id] + + # Remove from database + removed_from_db = self._remove_session_from_db(user_id) + + # Remove from file cache + self._remove_session_from_cache(user_id) + + if removed_from_db: + logger.info(f"Revoked session for user {user_id}") + + return removed_from_db + + def cleanup_expired_sessions(self) -> int: + """ + Clean up expired sessions from storage. + + Returns: + Number of sessions cleaned up + """ + cleaned_count = 0 + + # Clean memory cache + expired_users = [ + user_id for user_id, session in self._session_cache.items() + if session.is_expired + ] + + for user_id in expired_users: + del self._session_cache[user_id] + cleaned_count += 1 + + # Clean database + try: + db_cleaned = self.db.execute_update( + 'DELETE FROM users WHERE session_data IS NOT NULL AND ' + 'json_extract(session_data, "$.expires_at") < ?', + (datetime.now().isoformat(),) + ) + cleaned_count += db_cleaned + except Exception as e: + logger.error(f"Error cleaning expired sessions from database: {e}") + + if cleaned_count > 0: + logger.info(f"Cleaned up {cleaned_count} expired sessions") + + return cleaned_count + + def _save_session_to_db(self, session: AuthSession) -> None: + """Save session to database.""" + try: + session_json = json.dumps(session.to_dict()) + self.db.execute_update( + '''UPDATE users SET session_data = ?, last_login = ? + WHERE id = ?''', + (session_json, session.last_accessed.isoformat(), session.user_id) + ) + except Exception as e: + logger.error(f"Error saving session to database: {e}") + + def _load_session_from_db(self, user_id: str) -> Optional[AuthSession]: + """Load session from database.""" + try: + rows = self.db.execute_query( + 'SELECT session_data FROM users WHERE id = ? AND session_data IS NOT NULL', + (user_id,) + ) + + if rows: + session_data = json.loads(rows[0]['session_data']) + return AuthSession.from_dict(session_data) + except Exception as e: + logger.error(f"Error loading session from database: {e}") + + return None + + def _remove_session_from_db(self, user_id: str) -> bool: + """Remove session from database.""" + try: + # First check if user exists + rows = self.db.execute_query('SELECT id FROM users WHERE id = ?', (user_id,)) + if not rows: + return False + + affected = self.db.execute_update( + 'UPDATE users SET session_data = NULL WHERE id = ?', + (user_id,) + ) + return affected > 0 + except Exception as e: + logger.error(f"Error removing session from database: {e}") + return False + + def _save_session_to_cache(self, session: AuthSession) -> None: + """Save session to file cache for offline access.""" + try: + cache_file = self.cache_dir / f"session_{self._hash_user_id(session.user_id)}.json" + + # Only cache essential session data for offline use + cache_data = { + 'user_id': session.user_id, + 'token_hash': self._hash_token(session.token), + 'expires_at': session.expires_at.isoformat(), + 'created_at': session.created_at.isoformat(), + 'cached_at': datetime.now().isoformat() + } + + with open(cache_file, 'w') as f: + json.dump(cache_data, f) + + except Exception as e: + logger.error(f"Error saving session to cache: {e}") + + def _load_session_from_cache(self, user_id: str) -> Optional[AuthSession]: + """Load session from file cache.""" + try: + cache_file = self.cache_dir / f"session_{self._hash_user_id(user_id)}.json" + + if not cache_file.exists(): + return None + + with open(cache_file, 'r') as f: + cache_data = json.load(f) + + # Check if cache is not too old (max 1 week) + cached_at = datetime.fromisoformat(cache_data['cached_at']) + if datetime.now() - cached_at > timedelta(days=7): + cache_file.unlink() # Remove old cache + return None + + # Create session from cache (token will be validated separately) + return AuthSession( + user_id=cache_data['user_id'], + token=cache_data['token_hash'], # This is hashed, will need special handling + expires_at=datetime.fromisoformat(cache_data['expires_at']), + created_at=datetime.fromisoformat(cache_data['created_at']), + is_offline=True + ) + + except Exception as e: + logger.error(f"Error loading session from cache: {e}") + return None + + def _remove_session_from_cache(self, user_id: str) -> None: + """Remove session from file cache.""" + try: + cache_file = self.cache_dir / f"session_{self._hash_user_id(user_id)}.json" + if cache_file.exists(): + cache_file.unlink() + except Exception as e: + logger.error(f"Error removing session from cache: {e}") + + def _load_sessions_from_db(self) -> None: + """Load all valid sessions from database into memory cache.""" + try: + rows = self.db.execute_query( + 'SELECT id, session_data FROM users WHERE session_data IS NOT NULL' + ) + + for row in rows: + try: + session_data = json.loads(row['session_data']) + session = AuthSession.from_dict(session_data) + + if session.is_valid: + self._session_cache[row['id']] = session + except Exception as e: + logger.warning(f"Error loading session for user {row['id']}: {e}") + + except Exception as e: + logger.error(f"Error loading sessions from database: {e}") + + def _hash_token(self, token: str) -> str: + """Hash token for secure storage.""" + return hashlib.sha256(token.encode()).hexdigest() + + def _hash_user_id(self, user_id: str) -> str: + """Hash user ID for cache file naming.""" + return hashlib.md5(user_id.encode()).hexdigest()[:16] + + def _ensure_user_exists(self, user: User) -> None: + """Ensure user exists in database before creating session.""" + try: + # Check if user exists + rows = self.db.execute_query('SELECT id FROM users WHERE id = ?', (user.id,)) + if not rows: + # Create user record + self.db.execute_update( + '''INSERT INTO users (id, username, created_at, last_login) + VALUES (?, ?, ?, ?)''', + (user.id, user.username, + user.created_at.isoformat() if user.created_at else datetime.now().isoformat(), + user.last_login.isoformat() if user.last_login else None) + ) + logger.info(f"Created user record for session: {user.id}") + except Exception as e: + logger.error(f"Error ensuring user exists: {e}") \ No newline at end of file diff --git a/digipal/core/__init__.py b/digipal/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..da9213540a59349908b0168733c9be5224248fc4 --- /dev/null +++ b/digipal/core/__init__.py @@ -0,0 +1,22 @@ +""" +Core DigiPal functionality including data models and business logic. +""" + +from .models import DigiPal, Interaction, Command, CareAction, AttributeModifier +from .enums import * +from .attribute_engine import AttributeEngine + +__all__ = [ + 'DigiPal', + 'Interaction', + 'Command', + 'CareAction', + 'AttributeModifier', + 'AttributeEngine', + 'EggType', + 'LifeStage', + 'CareActionType', + 'AttributeType', + 'CommandType', + 'InteractionResult' +] \ No newline at end of file diff --git a/digipal/core/__pycache__/__init__.cpython-312.pyc b/digipal/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c69d6e89b8043615e0f6b730b01bed3432b4529 Binary files /dev/null and b/digipal/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/attribute_engine.cpython-312.pyc b/digipal/core/__pycache__/attribute_engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..644f323aa6c32277cd38c215dddf4f0588016abd Binary files /dev/null and b/digipal/core/__pycache__/attribute_engine.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/digipal_core.cpython-312.pyc b/digipal/core/__pycache__/digipal_core.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e2da032841c92e54e8aa7705d1274369c174b02 Binary files /dev/null and b/digipal/core/__pycache__/digipal_core.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/enums.cpython-312.pyc b/digipal/core/__pycache__/enums.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8dad14f59d644e77acde86c165f26d518b540d0b Binary files /dev/null and b/digipal/core/__pycache__/enums.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/error_handler.cpython-312.pyc b/digipal/core/__pycache__/error_handler.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07d18a9fd5b2537ff15321e0cec99bc7377b5612 Binary files /dev/null and b/digipal/core/__pycache__/error_handler.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/evolution_controller.cpython-312.pyc b/digipal/core/__pycache__/evolution_controller.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a15b1e911f7ea496b0cbe3ae296e686011c76f0e Binary files /dev/null and b/digipal/core/__pycache__/evolution_controller.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/exceptions.cpython-312.pyc b/digipal/core/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3cb808cc4b204da6c793814fc7a1819170b07618 Binary files /dev/null and b/digipal/core/__pycache__/exceptions.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/memory_manager.cpython-312.pyc b/digipal/core/__pycache__/memory_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..350a82a988a75aebe80c5182c037209018bdde3c Binary files /dev/null and b/digipal/core/__pycache__/memory_manager.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/models.cpython-312.pyc b/digipal/core/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..befdffa6573d49de1307d2bc782423286eff5885 Binary files /dev/null and b/digipal/core/__pycache__/models.cpython-312.pyc differ diff --git a/digipal/core/__pycache__/performance_optimizer.cpython-312.pyc b/digipal/core/__pycache__/performance_optimizer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aeef6031df831fac075134956598ed0f624c7b34 Binary files /dev/null and b/digipal/core/__pycache__/performance_optimizer.cpython-312.pyc differ diff --git a/digipal/core/attribute_engine.py b/digipal/core/attribute_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..a82ba94c8cb2082d988890759dbd5c091bf0d6a3 --- /dev/null +++ b/digipal/core/attribute_engine.py @@ -0,0 +1,557 @@ +""" +AttributeEngine for DigiPal - Implements Digimon World 1 inspired attribute calculations. +""" + +from typing import Dict, List, Optional, Tuple +import random +from datetime import datetime, timedelta + +from .models import DigiPal, CareAction, AttributeModifier, Interaction +from .enums import AttributeType, CareActionType, LifeStage, InteractionResult + + +class AttributeEngine: + """ + Core engine for managing DigiPal attributes and care mechanics. + Implements Digimon World 1 inspired attribute calculations with bounds checking. + """ + + # Attribute bounds (min, max) + ATTRIBUTE_BOUNDS = { + AttributeType.HP: (1, 999), + AttributeType.MP: (0, 999), + AttributeType.OFFENSE: (0, 999), + AttributeType.DEFENSE: (0, 999), + AttributeType.SPEED: (0, 999), + AttributeType.BRAINS: (0, 999), + AttributeType.DISCIPLINE: (0, 100), + AttributeType.HAPPINESS: (0, 100), + AttributeType.WEIGHT: (1, 99), + AttributeType.CARE_MISTAKES: (0, 999), + AttributeType.ENERGY: (0, 100) + } + + # Energy decay rate per hour + ENERGY_DECAY_RATE = 2.0 + + # Happiness decay rate per hour + HAPPINESS_DECAY_RATE = 1.0 + + def __init__(self): + """Initialize the AttributeEngine.""" + self.care_actions = self._initialize_care_actions() + + def _initialize_care_actions(self) -> Dict[str, CareAction]: + """Initialize all available care actions with their effects.""" + actions = {} + + # Training actions + actions["strength_training"] = CareAction( + name="Strength Training", + action_type=CareActionType.TRAIN, + energy_cost=15, + happiness_change=-5, + attribute_modifiers=[ + AttributeModifier(AttributeType.OFFENSE, 3), + AttributeModifier(AttributeType.HP, 2), + AttributeModifier(AttributeType.WEIGHT, -1) + ], + success_conditions=["energy >= 15"], + failure_effects=[ + AttributeModifier(AttributeType.CARE_MISTAKES, 1) + ] + ) + + actions["defense_training"] = CareAction( + name="Defense Training", + action_type=CareActionType.TRAIN, + energy_cost=15, + happiness_change=-5, + attribute_modifiers=[ + AttributeModifier(AttributeType.DEFENSE, 3), + AttributeModifier(AttributeType.HP, 2), + AttributeModifier(AttributeType.WEIGHT, -1) + ], + success_conditions=["energy >= 15"], + failure_effects=[ + AttributeModifier(AttributeType.CARE_MISTAKES, 1) + ] + ) + + actions["speed_training"] = CareAction( + name="Speed Training", + action_type=CareActionType.TRAIN, + energy_cost=12, + happiness_change=-3, + attribute_modifiers=[ + AttributeModifier(AttributeType.SPEED, 3), + AttributeModifier(AttributeType.WEIGHT, -2) + ], + success_conditions=["energy >= 12"], + failure_effects=[ + AttributeModifier(AttributeType.CARE_MISTAKES, 1) + ] + ) + + actions["brain_training"] = CareAction( + name="Brain Training", + action_type=CareActionType.TRAIN, + energy_cost=10, + happiness_change=-2, + attribute_modifiers=[ + AttributeModifier(AttributeType.BRAINS, 3), + AttributeModifier(AttributeType.MP, 2) + ], + success_conditions=["energy >= 10"], + failure_effects=[ + AttributeModifier(AttributeType.CARE_MISTAKES, 1) + ] + ) + + # Advanced training actions + actions["endurance_training"] = CareAction( + name="Endurance Training", + action_type=CareActionType.TRAIN, + energy_cost=20, + happiness_change=-8, + attribute_modifiers=[ + AttributeModifier(AttributeType.HP, 4), + AttributeModifier(AttributeType.DEFENSE, 2), + AttributeModifier(AttributeType.WEIGHT, -2) + ], + success_conditions=["energy >= 20"], + failure_effects=[ + AttributeModifier(AttributeType.CARE_MISTAKES, 1) + ] + ) + + actions["agility_training"] = CareAction( + name="Agility Training", + action_type=CareActionType.TRAIN, + energy_cost=18, + happiness_change=-6, + attribute_modifiers=[ + AttributeModifier(AttributeType.SPEED, 4), + AttributeModifier(AttributeType.OFFENSE, 1), + AttributeModifier(AttributeType.WEIGHT, -3) + ], + success_conditions=["energy >= 18"], + failure_effects=[ + AttributeModifier(AttributeType.CARE_MISTAKES, 1) + ] + ) + + # Feeding actions + actions["meat"] = CareAction( + name="Feed Meat", + action_type=CareActionType.FEED, + energy_cost=0, + happiness_change=5, + attribute_modifiers=[ + AttributeModifier(AttributeType.WEIGHT, 2), + AttributeModifier(AttributeType.HP, 1), + AttributeModifier(AttributeType.OFFENSE, 1) + ], + success_conditions=[], + failure_effects=[] + ) + + actions["fish"] = CareAction( + name="Feed Fish", + action_type=CareActionType.FEED, + energy_cost=0, + happiness_change=3, + attribute_modifiers=[ + AttributeModifier(AttributeType.WEIGHT, 1), + AttributeModifier(AttributeType.BRAINS, 1), + AttributeModifier(AttributeType.MP, 1) + ], + success_conditions=[], + failure_effects=[] + ) + + actions["vegetables"] = CareAction( + name="Feed Vegetables", + action_type=CareActionType.FEED, + energy_cost=0, + happiness_change=2, + attribute_modifiers=[ + AttributeModifier(AttributeType.WEIGHT, 1), + AttributeModifier(AttributeType.DEFENSE, 1) + ], + success_conditions=[], + failure_effects=[] + ) + + # Additional food types for variety + actions["protein_shake"] = CareAction( + name="Feed Protein Shake", + action_type=CareActionType.FEED, + energy_cost=0, + happiness_change=1, + attribute_modifiers=[ + AttributeModifier(AttributeType.WEIGHT, 1), + AttributeModifier(AttributeType.OFFENSE, 2), + AttributeModifier(AttributeType.HP, 1) + ], + success_conditions=[], + failure_effects=[] + ) + + actions["energy_drink"] = CareAction( + name="Feed Energy Drink", + action_type=CareActionType.FEED, + energy_cost=-5, # Restores some energy + happiness_change=3, + attribute_modifiers=[ + AttributeModifier(AttributeType.SPEED, 1), + AttributeModifier(AttributeType.MP, 1) + ], + success_conditions=[], + failure_effects=[] + ) + + # Care actions + actions["praise"] = CareAction( + name="Praise", + action_type=CareActionType.PRAISE, + energy_cost=0, + happiness_change=10, + attribute_modifiers=[ + AttributeModifier(AttributeType.DISCIPLINE, -2) + ], + success_conditions=[], + failure_effects=[] + ) + + actions["scold"] = CareAction( + name="Scold", + action_type=CareActionType.SCOLD, + energy_cost=0, + happiness_change=-8, + attribute_modifiers=[ + AttributeModifier(AttributeType.DISCIPLINE, 5) + ], + success_conditions=[], + failure_effects=[] + ) + + actions["rest"] = CareAction( + name="Rest", + action_type=CareActionType.REST, + energy_cost=-30, # Negative cost means it restores energy + happiness_change=3, + attribute_modifiers=[], + success_conditions=[], + failure_effects=[] + ) + + actions["play"] = CareAction( + name="Play", + action_type=CareActionType.PLAY, + energy_cost=8, + happiness_change=8, + attribute_modifiers=[ + AttributeModifier(AttributeType.WEIGHT, -1) + ], + success_conditions=["energy >= 8"], + failure_effects=[ + AttributeModifier(AttributeType.CARE_MISTAKES, 1) + ] + ) + + return actions + + def apply_care_action(self, pet: DigiPal, action_name: str) -> Tuple[bool, Interaction]: + """ + Apply a care action to the DigiPal and return the result. + + Args: + pet: The DigiPal to apply the action to + action_name: Name of the care action to apply + + Returns: + Tuple of (success, interaction_record) + """ + if action_name not in self.care_actions: + interaction = Interaction( + user_input=action_name, + interpreted_command=action_name, + pet_response=f"Unknown care action: {action_name}", + success=False, + result=InteractionResult.INVALID_COMMAND + ) + return False, interaction + + action = self.care_actions[action_name] + + # Check success conditions + success = self._check_action_conditions(pet, action) + + attribute_changes = {} + + if success: + # Apply energy cost + if action.energy_cost > 0: + pet.modify_attribute(AttributeType.ENERGY, -action.energy_cost) + attribute_changes["energy"] = -action.energy_cost + elif action.energy_cost < 0: # Rest action restores energy + pet.modify_attribute(AttributeType.ENERGY, -action.energy_cost) + attribute_changes["energy"] = -action.energy_cost + + # Apply happiness change + pet.modify_attribute(AttributeType.HAPPINESS, action.happiness_change) + attribute_changes["happiness"] = action.happiness_change + + # Apply attribute modifiers + for modifier in action.attribute_modifiers: + if self._check_modifier_conditions(pet, modifier): + pet.modify_attribute(modifier.attribute, modifier.change) + attribute_changes[modifier.attribute.value] = modifier.change + + response = f"Successfully performed {action.name}!" + result = InteractionResult.SUCCESS + + else: + # Apply failure effects + for modifier in action.failure_effects: + pet.modify_attribute(modifier.attribute, modifier.change) + attribute_changes[modifier.attribute.value] = modifier.change + + response = f"Failed to perform {action.name} - insufficient energy or conditions not met" + result = InteractionResult.INSUFFICIENT_ENERGY + + # Update last interaction time + pet.last_interaction = datetime.now() + + interaction = Interaction( + user_input=action_name, + interpreted_command=action_name, + pet_response=response, + attribute_changes=attribute_changes, + success=success, + result=result + ) + + # Add to conversation history + pet.conversation_history.append(interaction) + + return success, interaction + + def _check_action_conditions(self, pet: DigiPal, action: CareAction) -> bool: + """Check if all conditions for an action are met.""" + for condition in action.success_conditions: + if not self._evaluate_condition(pet, condition): + return False + return True + + def _check_modifier_conditions(self, pet: DigiPal, modifier: AttributeModifier) -> bool: + """Check if all conditions for an attribute modifier are met.""" + for condition in modifier.conditions: + if not self._evaluate_condition(pet, condition): + return False + return True + + def _evaluate_condition(self, pet: DigiPal, condition: str) -> bool: + """Evaluate a condition string against the pet's current state.""" + # Simple condition parser for basic comparisons + if ">=" in condition: + attr_name, value = condition.split(">=") + attr_name = attr_name.strip() + value = int(value.strip()) + + if attr_name == "energy": + return pet.energy >= value + elif attr_name == "happiness": + return pet.happiness >= value + elif attr_name == "discipline": + return pet.discipline >= value + # Add more conditions as needed + + return True # Default to true for unknown conditions + + def apply_time_decay(self, pet: DigiPal, hours_passed: float) -> Dict[str, int]: + """ + Apply time-based attribute decay to the DigiPal. + + Args: + pet: The DigiPal to apply decay to + hours_passed: Number of hours that have passed + + Returns: + Dictionary of attribute changes applied + """ + changes = {} + + # Energy decay + energy_decay = int(hours_passed * self.ENERGY_DECAY_RATE) + if energy_decay > 0: + old_energy = pet.energy + pet.modify_attribute(AttributeType.ENERGY, -energy_decay) + changes["energy"] = pet.energy - old_energy + + # Happiness decay + happiness_decay = int(hours_passed * self.HAPPINESS_DECAY_RATE) + if happiness_decay > 0: + old_happiness = pet.happiness + pet.modify_attribute(AttributeType.HAPPINESS, -happiness_decay) + changes["happiness"] = pet.happiness - old_happiness + + # Weight changes based on energy levels + if pet.energy < 20: # Very low energy causes weight loss + weight_change = -max(1, int(hours_passed * 0.5)) + old_weight = pet.weight + pet.modify_attribute(AttributeType.WEIGHT, weight_change) + changes["weight"] = pet.weight - old_weight + + return changes + + def calculate_care_mistake(self, pet: DigiPal, action_type: CareActionType) -> bool: + """ + Calculate if a care mistake should be recorded based on pet state and action. + + Args: + pet: The DigiPal being cared for + action_type: Type of care action being performed + + Returns: + True if a care mistake should be recorded + """ + mistake_probability = 0.0 + + # Higher chance of mistakes when pet is in poor condition + if pet.energy < 20: + mistake_probability += 0.3 + if pet.happiness < 20: + mistake_probability += 0.2 + if pet.weight < 10 or pet.weight > 80: + mistake_probability += 0.2 + + # Training when tired is more likely to cause mistakes + if action_type == CareActionType.TRAIN and pet.energy < 30: + mistake_probability += 0.4 + + # Overfeeding increases mistake chance + if action_type == CareActionType.FEED and pet.weight > 70: + mistake_probability += 0.3 + + # Excessive scolding increases mistake chance + if action_type == CareActionType.SCOLD and pet.discipline > 80: + mistake_probability += 0.25 + + # Random chance + return random.random() < mistake_probability + + def get_care_quality_assessment(self, pet: DigiPal) -> Dict[str, str]: + """ + Assess the overall care quality based on pet's current state. + + Args: + pet: The DigiPal to assess + + Returns: + Dictionary with care quality metrics + """ + assessment = {} + + # Energy assessment + if pet.energy >= 80: + assessment["energy"] = "excellent" + elif pet.energy >= 60: + assessment["energy"] = "good" + elif pet.energy >= 40: + assessment["energy"] = "fair" + elif pet.energy >= 20: + assessment["energy"] = "poor" + else: + assessment["energy"] = "critical" + + # Happiness assessment + if pet.happiness >= 80: + assessment["happiness"] = "very_happy" + elif pet.happiness >= 60: + assessment["happiness"] = "happy" + elif pet.happiness >= 40: + assessment["happiness"] = "neutral" + elif pet.happiness >= 20: + assessment["happiness"] = "sad" + else: + assessment["happiness"] = "very_sad" + + # Weight assessment + if 15 <= pet.weight <= 35: + assessment["weight"] = "healthy" + elif 10 <= pet.weight < 15 or 35 < pet.weight <= 50: + assessment["weight"] = "slightly_off" + elif 5 <= pet.weight < 10 or 50 < pet.weight <= 70: + assessment["weight"] = "concerning" + else: + assessment["weight"] = "unhealthy" + + # Discipline assessment + if 40 <= pet.discipline <= 70: + assessment["discipline"] = "balanced" + elif pet.discipline < 40: + assessment["discipline"] = "undisciplined" + else: + assessment["discipline"] = "over_disciplined" + + # Care mistakes assessment + if pet.care_mistakes == 0: + assessment["care_quality"] = "perfect" + elif pet.care_mistakes <= 3: + assessment["care_quality"] = "excellent" + elif pet.care_mistakes <= 7: + assessment["care_quality"] = "good" + elif pet.care_mistakes <= 15: + assessment["care_quality"] = "fair" + else: + assessment["care_quality"] = "poor" + + return assessment + + def get_attribute_bounds(self, attribute: AttributeType) -> Tuple[int, int]: + """Get the min and max bounds for an attribute.""" + return self.ATTRIBUTE_BOUNDS.get(attribute, (0, 999)) + + def validate_attribute_value(self, attribute: AttributeType, value: int) -> int: + """Validate and clamp an attribute value to its bounds.""" + min_val, max_val = self.get_attribute_bounds(attribute) + return max(min_val, min(max_val, value)) + + def get_available_actions(self, pet: DigiPal) -> List[str]: + """Get list of care actions available for the current pet state.""" + available = [] + + for action_name, action in self.care_actions.items(): + # Check if pet has enough energy for the action + if action.energy_cost > 0 and pet.energy < action.energy_cost: + continue + + # Check life stage appropriateness + if self._is_action_appropriate_for_stage(action, pet.life_stage): + available.append(action_name) + + return available + + def _is_action_appropriate_for_stage(self, action: CareAction, stage: LifeStage) -> bool: + """Check if an action is appropriate for the current life stage.""" + # All stages can do basic care + basic_actions = {CareActionType.FEED, CareActionType.PRAISE, CareActionType.SCOLD, CareActionType.REST} + + if action.action_type in basic_actions: + return True + + # Training and play require child stage or higher + if action.action_type in {CareActionType.TRAIN, CareActionType.PLAY}: + return stage in {LifeStage.CHILD, LifeStage.TEEN, LifeStage.YOUNG_ADULT, LifeStage.ADULT, LifeStage.ELDERLY} + + return True + + def get_care_action(self, action_name: str) -> Optional[CareAction]: + """Get a care action by name.""" + return self.care_actions.get(action_name) + + def get_all_care_actions(self) -> Dict[str, CareAction]: + """Get all available care actions.""" + return self.care_actions.copy() \ No newline at end of file diff --git a/digipal/core/digipal_core.py b/digipal/core/digipal_core.py new file mode 100644 index 0000000000000000000000000000000000000000..472205cb2abd122e925d7090be5ec207f0425ea2 --- /dev/null +++ b/digipal/core/digipal_core.py @@ -0,0 +1,1074 @@ +""" +DigiPal Core Engine - Central orchestrator for all DigiPal functionality. + +This module provides the main DigiPalCore class that coordinates pet creation, +loading, state management, interaction processing, and real-time updates. +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any, Tuple +import asyncio +import threading +import time + +from .models import DigiPal, Interaction, Command +from .enums import EggType, LifeStage, InteractionResult, AttributeType +from .attribute_engine import AttributeEngine +from .evolution_controller import EvolutionController, EvolutionResult +from .memory_manager import EnhancedMemoryManager +from .performance_optimizer import ( + LazyModelLoader, BackgroundTaskManager, DatabaseOptimizer, + PerformanceMonitor, ResourceCleanupManager, + ModelLoadingConfig, BackgroundTaskConfig +) +from ..storage.storage_manager import StorageManager +from ..ai.communication import AICommunication + +logger = logging.getLogger(__name__) + + +class PetState: + """Represents the current state of a DigiPal for external systems.""" + + def __init__(self, pet: DigiPal): + """Initialize PetState from DigiPal instance.""" + self.id = pet.id + self.user_id = pet.user_id + self.name = pet.name + self.life_stage = pet.life_stage + self.generation = pet.generation + + # Attributes + self.hp = pet.hp + self.mp = pet.mp + self.offense = pet.offense + self.defense = pet.defense + self.speed = pet.speed + self.brains = pet.brains + self.discipline = pet.discipline + self.happiness = pet.happiness + self.weight = pet.weight + self.care_mistakes = pet.care_mistakes + self.energy = pet.energy + + # Status + self.age_hours = pet.get_age_hours() + self.last_interaction = pet.last_interaction + self.current_image_path = pet.current_image_path + + # Derived status + self.needs_attention = self._calculate_needs_attention(pet) + self.evolution_ready = False # Will be set by core engine + self.status_summary = self._generate_status_summary(pet) + + def _calculate_needs_attention(self, pet: DigiPal) -> bool: + """Calculate if pet needs immediate attention.""" + return ( + pet.energy < 40 or + pet.happiness < 30 or + pet.weight < 10 or + pet.weight > 80 or + (datetime.now() - pet.last_interaction).total_seconds() > 3600 # 1 hour + ) + + def _generate_status_summary(self, pet: DigiPal) -> str: + """Generate a brief status summary.""" + if pet.energy < 20: + return "Very tired" + elif pet.happiness < 30: + return "Unhappy" + elif pet.energy < 40: + return "Getting tired" + elif pet.happiness > 80: + return "Very happy" + else: + return "Doing well" + + def to_dict(self) -> Dict[str, Any]: + """Convert PetState to dictionary.""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'name': self.name, + 'life_stage': self.life_stage.value, + 'generation': self.generation, + 'attributes': { + 'hp': self.hp, + 'mp': self.mp, + 'offense': self.offense, + 'defense': self.defense, + 'speed': self.speed, + 'brains': self.brains, + 'discipline': self.discipline, + 'happiness': self.happiness, + 'weight': self.weight, + 'care_mistakes': self.care_mistakes, + 'energy': self.energy + }, + 'status': { + 'age_hours': self.age_hours, + 'last_interaction': self.last_interaction.isoformat(), + 'current_image_path': self.current_image_path, + 'needs_attention': self.needs_attention, + 'evolution_ready': self.evolution_ready, + 'status_summary': self.status_summary + } + } + + +class InteractionProcessor: + """Processes user interactions with DigiPal through the AI communication layer.""" + + def __init__(self, ai_communication: AICommunication, attribute_engine: AttributeEngine): + """Initialize interaction processor.""" + self.ai_communication = ai_communication + self.attribute_engine = attribute_engine + + def process_text_interaction(self, text: str, pet: DigiPal) -> Interaction: + """ + Process a text-based interaction with the DigiPal. + + Args: + text: User input text + pet: DigiPal instance + + Returns: + Interaction result + """ + logger.info(f"Processing text interaction: '{text}' for pet {pet.id}") + + # Use AI communication to process the interaction + interaction = self.ai_communication.process_interaction(text, pet) + + # Apply any care actions based on the interpreted command + if interaction.interpreted_command and interaction.success: + self._apply_command_effects(interaction.interpreted_command, pet, interaction) + + return interaction + + def process_speech_interaction(self, audio_data: bytes, pet: DigiPal, sample_rate: Optional[int] = None) -> Interaction: + """ + Process a speech-based interaction with the DigiPal. + + Args: + audio_data: Raw audio bytes + pet: DigiPal instance + sample_rate: Audio sample rate (optional) + + Returns: + Interaction result + """ + logger.info(f"Processing speech interaction for pet {pet.id}") + + # Convert speech to text + transcribed_text = self.ai_communication.process_speech(audio_data, sample_rate) + + if not transcribed_text: + # Speech processing failed + interaction = Interaction( + user_input="[Speech not recognized]", + interpreted_command="", + pet_response="I couldn't understand what you said. Could you try again?", + success=False, + result=InteractionResult.FAILURE + ) + pet.conversation_history.append(interaction) + return interaction + + # Process the transcribed text as a normal text interaction + interaction = self.process_text_interaction(transcribed_text, pet) + + # Special handling for first speech interaction (egg hatching) + if pet.life_stage == LifeStage.EGG and transcribed_text: + interaction.pet_response = "The egg begins to crack and glow! Your DigiPal is hatching!" + interaction.attribute_changes["hatching"] = 1 + + return interaction + + def _apply_command_effects(self, command: str, pet: DigiPal, interaction: Interaction): + """Apply the effects of a successful command to the pet.""" + attribute_changes = {} + + # Map commands to care actions + command_to_action = { + 'eat': 'meat', # Default food + 'sleep': 'rest', + 'good': 'praise', + 'bad': 'scold', + 'train': 'strength_training', # Default training + 'play': 'play' + } + + action_name = command_to_action.get(command) + if action_name: + success, care_interaction = self.attribute_engine.apply_care_action(pet, action_name) + if success: + attribute_changes.update(care_interaction.attribute_changes) + + # Update interaction with attribute changes + interaction.attribute_changes.update(attribute_changes) + + +class DigiPalCore: + """ + Central orchestrator for DigiPal functionality. + + Manages pet lifecycle, coordinates all components, and provides + the main interface for DigiPal operations. + """ + + def __init__(self, storage_manager: StorageManager, ai_communication: AICommunication, + enable_performance_optimization: bool = True): + """ + Initialize DigiPal Core Engine. + + Args: + storage_manager: Storage manager for data persistence + ai_communication: AI communication layer + enable_performance_optimization: Whether to enable performance optimization features + """ + self.storage_manager = storage_manager + self.ai_communication = ai_communication + self.enable_performance_optimization = enable_performance_optimization + + # Initialize core components + self.attribute_engine = AttributeEngine() + self.evolution_controller = EvolutionController() + + # Initialize enhanced memory manager + self.enhanced_memory_manager = EnhancedMemoryManager(storage_manager) + + # Update AI communication with enhanced memory manager + self.ai_communication.enhanced_memory_manager = self.enhanced_memory_manager + + self.interaction_processor = InteractionProcessor(ai_communication, self.attribute_engine) + + # Active pets cache (user_id -> DigiPal) + self.active_pets: Dict[str, DigiPal] = {} + + # Performance optimization components + if enable_performance_optimization: + self._initialize_performance_optimization() + else: + self._update_thread = None + self._stop_updates = False + self._update_interval = 60 # Update every minute + + logger.info("DigiPalCore initialized successfully") + logger.info(f"Performance optimization: {'enabled' if enable_performance_optimization else 'disabled'}") + + def _initialize_performance_optimization(self): + """Initialize performance optimization components.""" + # Model loading optimization + model_config = ModelLoadingConfig( + lazy_loading=True, + quantization=True, + model_cache_size=2, + unload_after_idle_minutes=30 + ) + self.model_loader = LazyModelLoader(model_config) + + # Background task management + task_config = BackgroundTaskConfig() + self.background_task_manager = BackgroundTaskManager(task_config, self.storage_manager) + + # Database optimization + self.database_optimizer = DatabaseOptimizer(self.storage_manager) + + # Performance monitoring + self.performance_monitor = PerformanceMonitor() + + # Resource cleanup + self.resource_cleanup_manager = ResourceCleanupManager() + + # Register cleanup callbacks + self.resource_cleanup_manager.register_cleanup_callback( + lambda: self.enhanced_memory_manager.cleanup_old_memories + ) + + # Start background tasks + self._start_background_tasks() + + logger.info("Performance optimization components initialized") + + def _start_background_tasks(self): + """Start background tasks for optimization.""" + if not self.enable_performance_optimization: + return + + # Attribute decay task + self.background_task_manager.register_task( + "attribute_decay", + self._background_attribute_decay, + 300 # 5 minutes + ) + + # Evolution check task + self.background_task_manager.register_task( + "evolution_check", + self._background_evolution_check, + 600 # 10 minutes + ) + + # Memory cleanup task + self.background_task_manager.register_task( + "memory_cleanup", + self._background_memory_cleanup, + 1800 # 30 minutes + ) + + # Database optimization task + self.background_task_manager.register_task( + "database_optimization", + self._background_database_optimization, + 3600 # 1 hour + ) + + # Performance monitoring task + self.background_task_manager.register_task( + "performance_monitoring", + self._background_performance_monitoring, + 60 # 1 minute + ) + + # Resource cleanup task + self.background_task_manager.register_task( + "resource_cleanup", + self._background_resource_cleanup, + 900 # 15 minutes + ) + + # Start model cleanup + self.model_loader.start_cleanup_thread() + + # Start memory cleanup + self.enhanced_memory_manager.start_background_cleanup() + + logger.info("Background tasks started") + + def _background_attribute_decay(self): + """Background task for attribute decay.""" + try: + for user_id, pet in list(self.active_pets.items()): + self._apply_time_based_updates(pet) + except Exception as e: + logger.error(f"Error in background attribute decay: {e}") + + def _background_evolution_check(self): + """Background task for evolution checks.""" + try: + for user_id, pet in list(self.active_pets.items()): + self._check_and_apply_evolution(pet) + except Exception as e: + logger.error(f"Error in background evolution check: {e}") + + def _background_memory_cleanup(self): + """Background task for memory cleanup.""" + try: + for user_id, pet in list(self.active_pets.items()): + self.enhanced_memory_manager.cleanup_old_memories(pet.id) + except Exception as e: + logger.error(f"Error in background memory cleanup: {e}") + + def _background_database_optimization(self): + """Background task for database optimization.""" + try: + self.database_optimizer.optimize_database() + except Exception as e: + logger.error(f"Error in background database optimization: {e}") + + def _background_performance_monitoring(self): + """Background task for performance monitoring.""" + try: + active_pets = len(self.active_pets) + cached_models = len(self.model_loader.loaded_models) if hasattr(self, 'model_loader') else 0 + + # Calculate average response time (simplified) + response_time_avg = 1.0 # Placeholder + + self.performance_monitor.collect_metrics( + active_pets=active_pets, + cached_models=cached_models, + response_time_avg=response_time_avg + ) + except Exception as e: + logger.error(f"Error in background performance monitoring: {e}") + + def _background_resource_cleanup(self): + """Background task for resource cleanup.""" + try: + self.resource_cleanup_manager.perform_cleanup() + except Exception as e: + logger.error(f"Error in background resource cleanup: {e}") + + def create_new_pet(self, egg_type: EggType, user_id: str, name: str = "DigiPal") -> DigiPal: + """ + Create a new DigiPal with specified egg type and user. + + Args: + egg_type: Type of egg to create + user_id: User ID who owns the pet + name: Name for the new DigiPal + + Returns: + Newly created DigiPal instance + """ + logger.info(f"Creating new DigiPal for user {user_id} with egg type {egg_type.value}") + + # Create new DigiPal instance + pet = DigiPal( + user_id=user_id, + name=name, + egg_type=egg_type, + life_stage=LifeStage.EGG + ) + + # Initialize egg-specific attributes (already done in DigiPal.__post_init__) + + # Save to storage + if self.storage_manager.save_pet(pet): + # Add to active pets cache + self.active_pets[user_id] = pet + logger.info(f"Successfully created pet {pet.id} for user {user_id}") + return pet + else: + logger.error(f"Failed to save new pet for user {user_id}") + raise RuntimeError("Failed to save new DigiPal to storage") + + def load_existing_pet(self, user_id: str) -> Optional[DigiPal]: + """ + Load an existing DigiPal for a user. + + Args: + user_id: User ID to load pet for + + Returns: + Loaded DigiPal instance or None if not found + """ + logger.info(f"Loading existing DigiPal for user {user_id}") + + # Check cache first + if user_id in self.active_pets: + logger.info(f"Found pet in cache for user {user_id}") + return self.active_pets[user_id] + + # Load from storage + pet = self.storage_manager.load_pet(user_id) + if pet: + # Add to cache + self.active_pets[user_id] = pet + logger.info(f"Successfully loaded pet {pet.id} for user {user_id}") + + # Apply time-based updates since last interaction + self._apply_time_based_updates(pet) + + return pet + else: + logger.info(f"No existing pet found for user {user_id}") + return None + + def get_pet_state(self, user_id: str) -> Optional[PetState]: + """ + Get current state of user's DigiPal. + + Args: + user_id: User ID + + Returns: + PetState instance or None if no pet found + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if pet: + state = PetState(pet) + # Check evolution eligibility + eligible, _, _ = self.evolution_controller.check_evolution_eligibility(pet) + state.evolution_ready = eligible + return state + + return None + + def process_interaction(self, user_id: str, input_text: str) -> Tuple[bool, Interaction]: + """ + Process a text interaction with user's DigiPal. + + Args: + user_id: User ID + input_text: User input text + + Returns: + Tuple of (success, interaction_result) + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if not pet: + logger.error(f"No pet found for user {user_id}") + return False, Interaction( + user_input=input_text, + pet_response="No DigiPal found. Please create a new one first.", + success=False, + result=InteractionResult.FAILURE + ) + + # Process the interaction + interaction = self.interaction_processor.process_text_interaction(input_text, pet) + + # Handle special cases + if pet.life_stage == LifeStage.EGG and input_text: + # First interaction with egg triggers hatching + self._trigger_hatching(pet, interaction) + + # Save updated pet state + self.storage_manager.save_pet(pet) + + return interaction.success, interaction + + def process_speech_interaction(self, user_id: str, audio_data: bytes, sample_rate: Optional[int] = None) -> Tuple[bool, Interaction]: + """ + Process a speech interaction with user's DigiPal. + + Args: + user_id: User ID + audio_data: Raw audio bytes + sample_rate: Audio sample rate (optional) + + Returns: + Tuple of (success, interaction_result) + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if not pet: + logger.error(f"No pet found for user {user_id}") + return False, Interaction( + user_input="[Speech input]", + pet_response="No DigiPal found. Please create a new one first.", + success=False, + result=InteractionResult.FAILURE + ) + + # Process the speech interaction + interaction = self.interaction_processor.process_speech_interaction(audio_data, pet, sample_rate) + + # Handle special cases + if pet.life_stage == LifeStage.EGG and interaction.success: + # First speech interaction with egg triggers hatching + self._trigger_hatching(pet, interaction) + + # Save updated pet state + self.storage_manager.save_pet(pet) + + return interaction.success, interaction + + def _trigger_hatching(self, pet: DigiPal, interaction: Interaction): + """Trigger egg hatching to baby stage.""" + if pet.life_stage == LifeStage.EGG: + logger.info(f"Triggering hatching for pet {pet.id}") + + # Evolve to baby stage + evolution_result = self.evolution_controller.trigger_evolution(pet, force=True) + + if evolution_result.success: + interaction.pet_response = "Your DigiPal has hatched! Welcome to the world, little one!" + interaction.attribute_changes.update(evolution_result.attribute_changes) + logger.info(f"Pet {pet.id} successfully hatched to baby stage") + else: + logger.error(f"Failed to hatch pet {pet.id}") + + def update_pet_state(self, user_id: str, force_save: bool = False) -> bool: + """ + Update pet state with time-based changes. + + Args: + user_id: User ID + force_save: Force save to storage even if no changes + + Returns: + True if update was successful + """ + pet = self.active_pets.get(user_id) + if not pet: + return False + + try: + # Apply time-based updates + changes_applied = self._apply_time_based_updates(pet) + + # Check for evolution + evolution_occurred = self._check_and_apply_evolution(pet) + + # Save if changes were made or forced + if changes_applied or evolution_occurred or force_save: + return self.storage_manager.save_pet(pet) + + return True + + except Exception as e: + logger.error(f"Error updating pet state for user {user_id}: {e}") + return False + + def _apply_time_based_updates(self, pet: DigiPal) -> bool: + """ + Apply time-based attribute decay and updates. + + Args: + pet: DigiPal to update + + Returns: + True if any changes were applied + """ + now = datetime.now() + time_diff = now - pet.last_interaction + hours_passed = time_diff.total_seconds() / 3600 + + if hours_passed < 0.01: # Less than ~36 seconds, skip update + return False + + logger.debug(f"Applying time-based updates for pet {pet.id}: {hours_passed:.2f} hours passed") + + # Apply attribute decay + decay_changes = self.attribute_engine.apply_time_decay(pet, hours_passed) + + # Update evolution timer + self.evolution_controller.update_evolution_timer(pet, hours_passed) + + # Update last interaction time to prevent repeated updates + pet.last_interaction = now + + return len(decay_changes) > 0 + + def _check_and_apply_evolution(self, pet: DigiPal) -> bool: + """ + Check if pet should evolve and apply evolution if ready. + + Args: + pet: DigiPal to check + + Returns: + True if evolution occurred + """ + # Check time-based evolution + if self.evolution_controller.check_time_based_evolution(pet): + logger.info(f"Time-based evolution triggered for pet {pet.id}") + evolution_result = self.evolution_controller.trigger_evolution(pet) + + if evolution_result.success: + logger.info(f"Pet {pet.id} evolved from {evolution_result.old_stage.value} to {evolution_result.new_stage.value}") + return True + else: + logger.warning(f"Evolution failed for pet {pet.id}: {evolution_result.message}") + + # Check if pet should die (elderly stage time limit) + if self.evolution_controller.is_death_time(pet): + logger.info(f"Pet {pet.id} has reached end of life") + self._handle_pet_death(pet) + return True + + return False + + def _handle_pet_death(self, pet: DigiPal): + """Handle pet death and prepare for next generation.""" + logger.info(f"Handling death of pet {pet.id}") + + # Calculate care quality for inheritance + care_assessment = self.attribute_engine.get_care_quality_assessment(pet) + care_quality = care_assessment.get('care_quality', 'fair') + + # Create DNA inheritance data + dna = self.evolution_controller.create_inheritance_dna(pet, care_quality) + + # Mark pet as inactive + self.storage_manager.delete_pet(pet.id) + + # Remove from active pets cache + if pet.user_id in self.active_pets: + del self.active_pets[pet.user_id] + + # Store DNA for next generation (could be stored in user session or database) + # For now, we'll log it - in a full implementation, this would be stored + logger.info(f"DNA inheritance prepared for user {pet.user_id}: generation {dna.generation}") + + def trigger_evolution(self, user_id: str, force: bool = False) -> Tuple[bool, EvolutionResult]: + """ + Manually trigger evolution for user's DigiPal. + + Args: + user_id: User ID + force: Force evolution regardless of requirements + + Returns: + Tuple of (success, evolution_result) + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if not pet: + return False, EvolutionResult( + success=False, + old_stage=LifeStage.EGG, + new_stage=LifeStage.EGG, + message="No DigiPal found" + ) + + evolution_result = self.evolution_controller.trigger_evolution(pet, force) + + if evolution_result.success: + # Save updated pet + self.storage_manager.save_pet(pet) + logger.info(f"Manual evolution triggered for pet {pet.id}") + + return evolution_result.success, evolution_result + + def start_background_updates(self): + """Start background thread for automatic pet state updates.""" + if self._update_thread and self._update_thread.is_alive(): + logger.warning("Background updates already running") + return + + self._stop_updates = False + self._update_thread = threading.Thread(target=self._background_update_loop, daemon=True) + self._update_thread.start() + logger.info("Background updates started") + + def stop_background_updates(self): + """Stop background thread for automatic pet state updates.""" + self._stop_updates = True + if self._update_thread: + self._update_thread.join(timeout=5) + logger.info("Background updates stopped") + + def _background_update_loop(self): + """Background loop for automatic pet state updates.""" + logger.info("Background update loop started") + + while not self._stop_updates: + try: + # Update all active pets + for user_id in list(self.active_pets.keys()): + self.update_pet_state(user_id) + + # Sleep for update interval + time.sleep(self._update_interval) + + except Exception as e: + logger.error(f"Error in background update loop: {e}") + time.sleep(self._update_interval) + + logger.info("Background update loop stopped") + + def get_care_actions(self, user_id: str) -> List[str]: + """ + Get available care actions for user's DigiPal. + + Args: + user_id: User ID + + Returns: + List of available care action names + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if pet: + return self.attribute_engine.get_available_actions(pet) + + return [] + + def apply_care_action(self, user_id: str, action_name: str) -> Tuple[bool, Interaction]: + """ + Apply a care action to user's DigiPal. + + Args: + user_id: User ID + action_name: Name of care action to apply + + Returns: + Tuple of (success, interaction_result) + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if not pet: + return False, Interaction( + user_input=action_name, + pet_response="No DigiPal found", + success=False, + result=InteractionResult.FAILURE + ) + + # Apply care action through attribute engine + success, interaction = self.attribute_engine.apply_care_action(pet, action_name) + + # Save updated pet state + if success: + self.storage_manager.save_pet(pet) + + return success, interaction + + def get_performance_statistics(self) -> Dict[str, Any]: + """Get comprehensive performance statistics.""" + if not self.enable_performance_optimization: + return {'performance_optimization': 'disabled'} + + stats = { + 'performance_optimization': 'enabled', + 'active_pets': len(self.active_pets), + 'model_cache': self.model_loader.get_cache_info(), + 'background_tasks': self.background_task_manager.get_task_performance(), + 'performance_summary': self.performance_monitor.get_performance_summary(), + 'performance_alerts': self.performance_monitor.check_performance_alerts(), + 'optimization_suggestions': self.performance_monitor.suggest_optimizations(), + 'memory_info': self.resource_cleanup_manager.get_memory_info() + } + + return stats + + def get_memory_statistics(self, user_id: str) -> Dict[str, Any]: + """Get memory statistics for a user's pet.""" + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if not pet: + return {'error': 'Pet not found'} + + stats = { + 'pet_id': pet.id, + 'conversation_history_size': len(pet.conversation_history), + 'learned_commands': len(pet.learned_commands), + 'personality_traits': len(pet.personality_traits) + } + + # Add enhanced memory statistics if available + if self.enhanced_memory_manager: + enhanced_stats = self.enhanced_memory_manager.get_memory_statistics(pet.id) + stats.update(enhanced_stats) + + # Add emotional state summary + emotional_state = self.enhanced_memory_manager.get_emotional_state_summary(pet.id) + stats['emotional_state'] = emotional_state + + return stats + + def optimize_performance(self) -> Dict[str, Any]: + """Manually trigger performance optimization.""" + if not self.enable_performance_optimization: + return {'error': 'Performance optimization not enabled'} + + results = {} + + try: + # Database optimization + results['database'] = self.database_optimizer.optimize_database() + + # Create suggested indexes + results['indexes'] = self.database_optimizer.create_suggested_indexes() + + # Resource cleanup + results['cleanup'] = self.resource_cleanup_manager.perform_cleanup(force_gc=True) + + # Memory cleanup for all pets + cleanup_counts = {} + for user_id, pet in self.active_pets.items(): + count = self.enhanced_memory_manager.cleanup_old_memories(pet.id) + if count > 0: + cleanup_counts[user_id] = count + results['memory_cleanup'] = cleanup_counts + + logger.info("Manual performance optimization completed") + + except Exception as e: + logger.error(f"Performance optimization failed: {e}") + results['error'] = str(e) + + return results + + def get_pet_statistics(self, user_id: str) -> Dict[str, Any]: + """ + Get comprehensive statistics for user's DigiPal. + + Args: + user_id: User ID + + Returns: + Dictionary with pet statistics + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if not pet: + return {} + + # Get interaction summary from AI communication + interaction_summary = self.ai_communication.memory_manager.get_interaction_summary(pet) + + # Get care quality assessment + care_assessment = self.attribute_engine.get_care_quality_assessment(pet) + + # Get evolution status + evolution_eligible, next_stage, evolution_requirements = self.evolution_controller.check_evolution_eligibility(pet) + + return { + 'basic_info': { + 'id': pet.id, + 'name': pet.name, + 'life_stage': pet.life_stage.value, + 'generation': pet.generation, + 'age_hours': pet.get_age_hours(), + 'egg_type': pet.egg_type.value + }, + 'attributes': { + 'hp': pet.hp, + 'mp': pet.mp, + 'offense': pet.offense, + 'defense': pet.defense, + 'speed': pet.speed, + 'brains': pet.brains, + 'discipline': pet.discipline, + 'happiness': pet.happiness, + 'weight': pet.weight, + 'care_mistakes': pet.care_mistakes, + 'energy': pet.energy + }, + 'care_assessment': care_assessment, + 'interaction_summary': interaction_summary, + 'evolution_status': { + 'eligible': evolution_eligible, + 'next_stage': next_stage.value if next_stage else None, + 'requirements_met': evolution_requirements + }, + 'personality_traits': pet.personality_traits, + 'learned_commands': list(pet.learned_commands) + } + + def process_audio_interaction(self, user_id: str, audio_data) -> Tuple[bool, Interaction]: + """ + Process audio interaction with user's DigiPal. + + Args: + user_id: User ID + audio_data: Audio data from microphone + + Returns: + Tuple of (success, interaction) + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if not pet: + error_interaction = Interaction( + timestamp=datetime.now(), + user_input="", + interpreted_command="", + pet_response="No DigiPal found. Please create one first.", + attribute_changes={}, + success=False + ) + return False, error_interaction + + try: + # Process audio through AI communication layer + text_input = self.ai_communication.process_speech(audio_data) + + if not text_input or text_input.strip() == "": + error_interaction = Interaction( + timestamp=datetime.now(), + user_input="", + interpreted_command="", + pet_response="I couldn't understand what you said. Please try again.", + attribute_changes={}, + success=False + ) + return False, error_interaction + + # Process the transcribed text as a regular interaction + return self.process_interaction(user_id, text_input) + + except Exception as e: + logger.error(f"Error processing audio interaction for user {user_id}: {e}") + error_interaction = Interaction( + timestamp=datetime.now(), + user_input="", + interpreted_command="", + pet_response=f"Sorry, I had trouble processing your voice. Error: {str(e)}", + attribute_changes={}, + success=False + ) + return False, error_interaction + + def clear_conversation_history(self, user_id: str) -> bool: + """ + Clear conversation history for user's DigiPal. + + Args: + user_id: User ID + + Returns: + True if successful, False otherwise + """ + pet = self.active_pets.get(user_id) + if not pet: + pet = self.load_existing_pet(user_id) + + if not pet: + return False + + try: + # Clear conversation history in pet + pet.conversation_history.clear() + + # Clear memory in AI communication layer + self.ai_communication.memory_manager.clear_conversation_memory(pet) + + # Save the updated pet + self.storage_manager.save_pet(pet) + + logger.info(f"Cleared conversation history for pet {pet.id}") + return True + + except Exception as e: + logger.error(f"Error clearing conversation history for user {user_id}: {e}") + return False + + def shutdown(self): + """Shutdown the DigiPal core engine and save all active pets.""" + logger.info("Shutting down DigiPalCore") + + # Stop background tasks + if self.enable_performance_optimization: + self.background_task_manager.stop_all_tasks() + self.model_loader.shutdown() + self.enhanced_memory_manager.shutdown() + else: + self.stop_background_updates() + + # Save all active pets + for user_id, pet in self.active_pets.items(): + try: + self.storage_manager.save_pet(pet) + logger.info(f"Saved pet {pet.id} for user {user_id}") + except Exception as e: + logger.error(f"Failed to save pet {pet.id} during shutdown: {e}") + + # Clear active pets cache + self.active_pets.clear() + + # Unload AI models to free memory + self.ai_communication.unload_all_models() + + # Final resource cleanup + if self.enable_performance_optimization: + self.resource_cleanup_manager.perform_cleanup(force_gc=True) + + logger.info("DigiPalCore shutdown complete") \ No newline at end of file diff --git a/digipal/core/enums.py b/digipal/core/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..0e32eea363e68ef38a537d854408ed557395e6d3 --- /dev/null +++ b/digipal/core/enums.py @@ -0,0 +1,72 @@ +""" +Enum classes for DigiPal constants and types. +""" + +from enum import Enum, auto + + +class EggType(Enum): + """Types of eggs that determine initial DigiPal attributes.""" + RED = "red" # Fire-oriented, higher attack + BLUE = "blue" # Water-oriented, higher defense + GREEN = "green" # Earth-oriented, higher health and symbiosis + + +class LifeStage(Enum): + """Life stages that DigiPal progresses through.""" + EGG = "egg" + BABY = "baby" + CHILD = "child" + TEEN = "teen" + YOUNG_ADULT = "young_adult" + ADULT = "adult" + ELDERLY = "elderly" + + +class CareActionType(Enum): + """Types of care actions that can be performed on DigiPal.""" + TRAIN = "train" + FEED = "feed" + PRAISE = "praise" + SCOLD = "scold" + REST = "rest" + PLAY = "play" + + +class AttributeType(Enum): + """Primary and secondary attribute types.""" + # Primary Attributes (Digimon World 1 inspired) + HP = "hp" + MP = "mp" + OFFENSE = "offense" + DEFENSE = "defense" + SPEED = "speed" + BRAINS = "brains" + + # Secondary Attributes + DISCIPLINE = "discipline" + HAPPINESS = "happiness" + WEIGHT = "weight" + CARE_MISTAKES = "care_mistakes" + ENERGY = "energy" + + +class CommandType(Enum): + """Types of commands DigiPal can understand.""" + EAT = "eat" + SLEEP = "sleep" + GOOD = "good" + BAD = "bad" + TRAIN = "train" + PLAY = "play" + STATUS = "status" + UNKNOWN = "unknown" + + +class InteractionResult(Enum): + """Results of user interactions with DigiPal.""" + SUCCESS = "success" + FAILURE = "failure" + INVALID_COMMAND = "invalid_command" + INSUFFICIENT_ENERGY = "insufficient_energy" + STAGE_INAPPROPRIATE = "stage_inappropriate" \ No newline at end of file diff --git a/digipal/core/error_handler.py b/digipal/core/error_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..3170a2b2c8390fbf6212dd04e30ecc7e7566ee56 --- /dev/null +++ b/digipal/core/error_handler.py @@ -0,0 +1,799 @@ +""" +Error handling utilities and decorators for DigiPal application. + +This module provides comprehensive error handling, retry mechanisms, +and graceful degradation functionality. +""" + +import logging +import functools +import asyncio +import time +from typing import Any, Callable, Dict, List, Optional, Type, Union, Tuple +from datetime import datetime, timedelta + +from .exceptions import ( + DigiPalException, ErrorSeverity, ErrorCategory, + AIModelError, StorageError, NetworkError, RecoveryError +) + +logger = logging.getLogger(__name__) + + +class RetryConfig: + """Configuration for retry mechanisms.""" + + def __init__( + self, + max_attempts: int = 3, + base_delay: float = 1.0, + max_delay: float = 60.0, + exponential_backoff: bool = True, + jitter: bool = True, + retry_on: Optional[List[Type[Exception]]] = None + ): + """ + Initialize retry configuration. + + Args: + max_attempts: Maximum number of retry attempts + base_delay: Base delay between retries in seconds + max_delay: Maximum delay between retries in seconds + exponential_backoff: Whether to use exponential backoff + jitter: Whether to add random jitter to delays + retry_on: List of exception types to retry on + """ + self.max_attempts = max_attempts + self.base_delay = base_delay + self.max_delay = max_delay + self.exponential_backoff = exponential_backoff + self.jitter = jitter + self.retry_on = retry_on or [NetworkError, AIModelError] + + +class CircuitBreakerConfig: + """Configuration for circuit breaker pattern.""" + + def __init__( + self, + failure_threshold: int = 5, + recovery_timeout: float = 60.0, + expected_exception: Type[Exception] = Exception + ): + """ + Initialize circuit breaker configuration. + + Args: + failure_threshold: Number of failures before opening circuit + recovery_timeout: Time to wait before attempting recovery + expected_exception: Exception type that triggers circuit breaker + """ + self.failure_threshold = failure_threshold + self.recovery_timeout = recovery_timeout + self.expected_exception = expected_exception + + +class CircuitBreaker: + """Circuit breaker implementation for external service calls.""" + + def __init__(self, config: CircuitBreakerConfig): + """Initialize circuit breaker with configuration.""" + self.config = config + self.failure_count = 0 + self.last_failure_time = None + self.state = "closed" # closed, open, half-open + + def call(self, func: Callable, *args, **kwargs) -> Any: + """ + Call function with circuit breaker protection. + + Args: + func: Function to call + *args: Function arguments + **kwargs: Function keyword arguments + + Returns: + Function result + + Raises: + Exception: If circuit is open or function fails + """ + if self.state == "open": + if self._should_attempt_reset(): + self.state = "half-open" + else: + raise DigiPalException( + "Circuit breaker is open - service unavailable", + category=ErrorCategory.SYSTEM, + severity=ErrorSeverity.HIGH, + user_message="Service is temporarily unavailable. Please try again later." + ) + + try: + result = func(*args, **kwargs) + self._on_success() + return result + except self.config.expected_exception as e: + self._on_failure() + raise e + + def _should_attempt_reset(self) -> bool: + """Check if circuit breaker should attempt to reset.""" + if self.last_failure_time is None: + return True + + return (datetime.now() - self.last_failure_time).total_seconds() > self.config.recovery_timeout + + def _on_success(self): + """Handle successful function call.""" + self.failure_count = 0 + self.state = "closed" + + def _on_failure(self): + """Handle failed function call.""" + self.failure_count += 1 + self.last_failure_time = datetime.now() + + if self.failure_count >= self.config.failure_threshold: + self.state = "open" + + +class ErrorHandler: + """Central error handler for DigiPal application.""" + + def __init__(self): + """Initialize error handler.""" + self.circuit_breakers: Dict[str, CircuitBreaker] = {} + self.error_counts: Dict[str, int] = {} + self.last_errors: Dict[str, datetime] = {} + self.error_patterns: Dict[str, List[datetime]] = {} + self.critical_error_threshold = 5 # Number of critical errors before emergency mode + self.error_rate_window = 300 # 5 minutes window for error rate calculation + + def get_circuit_breaker(self, name: str, config: Optional[CircuitBreakerConfig] = None) -> CircuitBreaker: + """ + Get or create circuit breaker for a service. + + Args: + name: Service name + config: Circuit breaker configuration + + Returns: + CircuitBreaker instance + """ + if name not in self.circuit_breakers: + config = config or CircuitBreakerConfig() + self.circuit_breakers[name] = CircuitBreaker(config) + + return self.circuit_breakers[name] + + def handle_error(self, error: Exception, context: Optional[Dict[str, Any]] = None) -> DigiPalException: + """ + Handle and convert errors to DigiPal exceptions. + + Args: + error: Original exception + context: Additional context information + + Returns: + DigiPalException with appropriate categorization + """ + context = context or {} + + # If already a DigiPal exception, just add context and return + if isinstance(error, DigiPalException): + error.context.update(context) + self._track_error_pattern(error) + return error + + # Convert common exceptions to DigiPal exceptions + digipal_error = self._convert_to_digipal_exception(error, context) + self._track_error_pattern(digipal_error) + + return digipal_error + + def _convert_to_digipal_exception(self, error: Exception, context: Dict[str, Any]) -> DigiPalException: + """Convert standard exceptions to DigiPal exceptions.""" + if isinstance(error, (ConnectionError, TimeoutError)): + return NetworkError( + f"Network error: {str(error)}", + context=context, + error_code="NET_001" + ) + + if isinstance(error, FileNotFoundError): + return StorageError( + f"File not found: {str(error)}", + context=context, + error_code="STOR_001" + ) + + if isinstance(error, PermissionError): + return StorageError( + f"Permission denied: {str(error)}", + context=context, + error_code="STOR_002" + ) + + if isinstance(error, MemoryError): + return AIModelError( + f"Memory error: {str(error)}", + context=context, + error_code="AI_MEM_001" + ) + + if isinstance(error, ImportError): + return DigiPalException( + f"Import error: {str(error)}", + category=ErrorCategory.SYSTEM, + severity=ErrorSeverity.HIGH, + context=context, + error_code="SYS_IMP_001" + ) + + if isinstance(error, ValueError): + return DigiPalException( + f"Invalid value: {str(error)}", + category=ErrorCategory.VALIDATION, + severity=ErrorSeverity.LOW, + context=context, + error_code="VAL_001" + ) + + if isinstance(error, KeyError): + return DigiPalException( + f"Missing key: {str(error)}", + category=ErrorCategory.VALIDATION, + severity=ErrorSeverity.MEDIUM, + context=context, + error_code="VAL_KEY_001" + ) + + if isinstance(error, AttributeError): + return DigiPalException( + f"Attribute error: {str(error)}", + category=ErrorCategory.SYSTEM, + severity=ErrorSeverity.MEDIUM, + context=context, + error_code="SYS_ATTR_001" + ) + + # Default to system error + return DigiPalException( + f"Unexpected error: {str(error)}", + category=ErrorCategory.SYSTEM, + severity=ErrorSeverity.HIGH, + context=context, + error_code="SYS_001" + ) + + def _track_error_pattern(self, error: DigiPalException): + """Track error patterns for analysis and prevention.""" + now = datetime.now() + error_key = f"{error.category.value}:{error.error_code}" + + if error_key not in self.error_patterns: + self.error_patterns[error_key] = [] + + self.error_patterns[error_key].append(now) + + # Clean old entries (keep only last 24 hours) + cutoff_time = now - timedelta(hours=24) + self.error_patterns[error_key] = [ + timestamp for timestamp in self.error_patterns[error_key] + if timestamp > cutoff_time + ] + + def get_error_rate(self, error_category: Optional[str] = None, window_minutes: int = 5) -> float: + """ + Get error rate for a category or overall. + + Args: + error_category: Specific error category (None for all) + window_minutes: Time window in minutes + + Returns: + Errors per minute + """ + now = datetime.now() + cutoff_time = now - timedelta(minutes=window_minutes) + + total_errors = 0 + + for error_key, timestamps in self.error_patterns.items(): + if error_category and not error_key.startswith(f"{error_category}:"): + continue + + recent_errors = [t for t in timestamps if t > cutoff_time] + total_errors += len(recent_errors) + + return total_errors / window_minutes if window_minutes > 0 else 0 + + def is_error_storm_detected(self) -> bool: + """ + Detect if there's an error storm (high error rate). + + Returns: + True if error storm detected + """ + error_rate = self.get_error_rate(window_minutes=5) + return error_rate > 10 # More than 10 errors per minute + + def get_most_frequent_errors(self, limit: int = 5) -> List[Tuple[str, int]]: + """ + Get most frequent error types. + + Args: + limit: Maximum number of errors to return + + Returns: + List of (error_key, count) tuples + """ + error_counts = {} + + for error_key, timestamps in self.error_patterns.items(): + error_counts[error_key] = len(timestamps) + + sorted_errors = sorted(error_counts.items(), key=lambda x: x[1], reverse=True) + return sorted_errors[:limit] + + def log_error(self, error: DigiPalException, extra_context: Optional[Dict[str, Any]] = None): + """ + Log error with appropriate level and context. + + Args: + error: DigiPal exception to log + extra_context: Additional context for logging + """ + context = {**error.context, **(extra_context or {})} + + log_data = { + 'error_code': error.error_code, + 'category': error.category.value, + 'severity': error.severity.value, + 'user_message': error.user_message, + 'context': context + } + + if error.severity == ErrorSeverity.CRITICAL: + logger.critical(f"CRITICAL ERROR: {str(error)}", extra=log_data) + elif error.severity == ErrorSeverity.HIGH: + logger.error(f"HIGH SEVERITY: {str(error)}", extra=log_data) + elif error.severity == ErrorSeverity.MEDIUM: + logger.warning(f"MEDIUM SEVERITY: {str(error)}", extra=log_data) + else: + logger.info(f"LOW SEVERITY: {str(error)}", extra=log_data) + + # Track error frequency + error_key = f"{error.category.value}:{error.error_code}" + self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1 + self.last_errors[error_key] = datetime.now() + + +# Global error handler instance +error_handler = ErrorHandler() + + +def with_error_handling( + fallback_value: Any = None, + log_errors: bool = True, + raise_on_critical: bool = True, + context: Optional[Dict[str, Any]] = None +): + """ + Decorator for comprehensive error handling. + + Args: + fallback_value: Value to return on error + log_errors: Whether to log errors + raise_on_critical: Whether to raise critical errors + context: Additional context for error handling + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + digipal_error = error_handler.handle_error(e, context) + + if log_errors: + error_handler.log_error(digipal_error) + + if raise_on_critical and digipal_error.severity == ErrorSeverity.CRITICAL: + raise digipal_error + + if digipal_error.severity in [ErrorSeverity.HIGH, ErrorSeverity.CRITICAL] and fallback_value is None: + raise digipal_error + + return fallback_value + + return wrapper + return decorator + + +def with_retry(config: Optional[RetryConfig] = None): + """ + Decorator for retry functionality. + + Args: + config: Retry configuration + """ + config = config or RetryConfig() + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + last_exception = None + + for attempt in range(config.max_attempts): + try: + return func(*args, **kwargs) + except Exception as e: + last_exception = e + + # Check if we should retry on this exception + should_retry = any(isinstance(e, exc_type) for exc_type in config.retry_on) + + if not should_retry or attempt == config.max_attempts - 1: + break + + # Calculate delay + delay = config.base_delay + if config.exponential_backoff: + delay *= (2 ** attempt) + + delay = min(delay, config.max_delay) + + if config.jitter: + import random + delay *= (0.5 + random.random() * 0.5) + + logger.info(f"Retrying {func.__name__} in {delay:.2f}s (attempt {attempt + 1}/{config.max_attempts})") + time.sleep(delay) + + # All retries failed + raise last_exception + + return wrapper + return decorator + + +def with_circuit_breaker(service_name: str, config: Optional[CircuitBreakerConfig] = None): + """ + Decorator for circuit breaker functionality. + + Args: + service_name: Name of the service for circuit breaker + config: Circuit breaker configuration + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + circuit_breaker = error_handler.get_circuit_breaker(service_name, config) + return circuit_breaker.call(func, *args, **kwargs) + + return wrapper + return decorator + + +async def with_async_error_handling( + fallback_value: Any = None, + log_errors: bool = True, + raise_on_critical: bool = True, + context: Optional[Dict[str, Any]] = None +): + """ + Async decorator for comprehensive error handling. + + Args: + fallback_value: Value to return on error + log_errors: Whether to log errors + raise_on_critical: Whether to raise critical errors + context: Additional context for error handling + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + digipal_error = error_handler.handle_error(e, context) + + if log_errors: + error_handler.log_error(digipal_error) + + if raise_on_critical and digipal_error.severity == ErrorSeverity.CRITICAL: + raise digipal_error + + if digipal_error.severity in [ErrorSeverity.HIGH, ErrorSeverity.CRITICAL] and fallback_value is None: + raise digipal_error + + return fallback_value + + return wrapper + return decorator + + +async def with_async_retry(config: Optional[RetryConfig] = None): + """ + Async decorator for retry functionality. + + Args: + config: Retry configuration + """ + config = config or RetryConfig() + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args, **kwargs): + last_exception = None + + for attempt in range(config.max_attempts): + try: + return await func(*args, **kwargs) + except Exception as e: + last_exception = e + + # Check if we should retry on this exception + should_retry = any(isinstance(e, exc_type) for exc_type in config.retry_on) + + if not should_retry or attempt == config.max_attempts - 1: + break + + # Calculate delay + delay = config.base_delay + if config.exponential_backoff: + delay *= (2 ** attempt) + + delay = min(delay, config.max_delay) + + if config.jitter: + import random + delay *= (0.5 + random.random() * 0.5) + + logger.info(f"Retrying {func.__name__} in {delay:.2f}s (attempt {attempt + 1}/{config.max_attempts})") + await asyncio.sleep(delay) + + # All retries failed + raise last_exception + + return wrapper + return decorator + + +def create_fallback_response(error: DigiPalException, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Create a standardized fallback response for errors. + + Args: + error: DigiPal exception + context: Additional context + + Returns: + Fallback response dictionary + """ + return { + 'success': False, + 'error': { + 'message': error.user_message, + 'category': error.category.value, + 'severity': error.severity.value, + 'recovery_suggestions': error.recovery_suggestions, + 'error_code': error.error_code + }, + 'context': context or {}, + 'timestamp': datetime.now().isoformat() + } + + +def get_error_statistics() -> Dict[str, Any]: + """ + Get error statistics for monitoring and debugging. + + Returns: + Dictionary with error statistics + """ + return { + 'error_counts': dict(error_handler.error_counts), + 'last_errors': {k: v.isoformat() for k, v in error_handler.last_errors.items()}, + 'circuit_breaker_states': { + name: { + 'state': cb.state, + 'failure_count': cb.failure_count, + 'last_failure': cb.last_failure_time.isoformat() if cb.last_failure_time else None + } + for name, cb in error_handler.circuit_breakers.items() + } + } + + +class HealthChecker: + """Health checker for system components.""" + + def __init__(self): + """Initialize health checker.""" + self.component_health: Dict[str, bool] = {} + self.last_health_check: Dict[str, datetime] = {} + self.health_check_interval = 300 # 5 minutes + + def register_component(self, component_name: str, health_check_func: Callable[[], bool]): + """ + Register a component for health checking. + + Args: + component_name: Name of the component + health_check_func: Function that returns True if component is healthy + """ + self.component_health[component_name] = True + self._health_check_functions[component_name] = health_check_func + + def check_component_health(self, component_name: str) -> bool: + """ + Check health of a specific component. + + Args: + component_name: Name of component to check + + Returns: + True if component is healthy + """ + if component_name not in self._health_check_functions: + return False + + try: + health_func = self._health_check_functions[component_name] + is_healthy = health_func() + self.component_health[component_name] = is_healthy + self.last_health_check[component_name] = datetime.now() + return is_healthy + except Exception as e: + logger.error(f"Health check failed for {component_name}: {e}") + self.component_health[component_name] = False + return False + + def get_system_health(self) -> Dict[str, Any]: + """ + Get overall system health status. + + Returns: + Dictionary with health information + """ + # Check all components + for component in self._health_check_functions.keys(): + self.check_component_health(component) + + healthy_components = sum(1 for health in self.component_health.values() if health) + total_components = len(self.component_health) + + overall_health = "healthy" if healthy_components == total_components else "degraded" + if healthy_components == 0: + overall_health = "critical" + elif healthy_components < total_components * 0.5: + overall_health = "unhealthy" + + return { + 'overall_health': overall_health, + 'healthy_components': healthy_components, + 'total_components': total_components, + 'component_status': dict(self.component_health), + 'last_checks': {k: v.isoformat() for k, v in self.last_health_check.items()} + } + + def __init__(self): + """Initialize health checker.""" + self.component_health: Dict[str, bool] = {} + self.last_health_check: Dict[str, datetime] = {} + self.health_check_interval = 300 # 5 minutes + self._health_check_functions: Dict[str, Callable[[], bool]] = {} + + +# Global health checker instance +health_checker = HealthChecker() + + +class RecoveryManager: + """Manages recovery operations for various system failures.""" + + def __init__(self): + """Initialize recovery manager.""" + self.recovery_strategies: Dict[str, List[Callable]] = {} + self.recovery_history: List[Dict[str, Any]] = [] + + def register_recovery_strategy(self, error_type: str, recovery_func: Callable): + """ + Register a recovery strategy for an error type. + + Args: + error_type: Type of error (e.g., 'storage_error', 'ai_model_error') + recovery_func: Function to attempt recovery + """ + if error_type not in self.recovery_strategies: + self.recovery_strategies[error_type] = [] + self.recovery_strategies[error_type].append(recovery_func) + + def attempt_recovery(self, error: DigiPalException) -> bool: + """ + Attempt to recover from an error. + + Args: + error: The error to recover from + + Returns: + True if recovery was successful + """ + error_type = error.category.value + + if error_type not in self.recovery_strategies: + logger.warning(f"No recovery strategies for error type: {error_type}") + return False + + recovery_attempt = { + 'timestamp': datetime.now(), + 'error_type': error_type, + 'error_code': error.error_code, + 'strategies_attempted': [], + 'success': False + } + + for strategy in self.recovery_strategies[error_type]: + try: + strategy_name = strategy.__name__ + logger.info(f"Attempting recovery strategy: {strategy_name}") + + success = strategy(error) + recovery_attempt['strategies_attempted'].append({ + 'strategy': strategy_name, + 'success': success + }) + + if success: + recovery_attempt['success'] = True + logger.info(f"Recovery successful using strategy: {strategy_name}") + break + + except Exception as recovery_error: + logger.error(f"Recovery strategy {strategy.__name__} failed: {recovery_error}") + recovery_attempt['strategies_attempted'].append({ + 'strategy': strategy.__name__, + 'success': False, + 'error': str(recovery_error) + }) + + self.recovery_history.append(recovery_attempt) + return recovery_attempt['success'] + + def get_recovery_statistics(self) -> Dict[str, Any]: + """Get recovery statistics.""" + if not self.recovery_history: + return { + 'total_attempts': 0, + 'successful_recoveries': 0, + 'success_rate': 0.0, + 'error_types': {} + } + + total_attempts = len(self.recovery_history) + successful_recoveries = sum(1 for attempt in self.recovery_history if attempt['success']) + success_rate = successful_recoveries / total_attempts if total_attempts > 0 else 0.0 + + error_types = {} + for attempt in self.recovery_history: + error_type = attempt['error_type'] + if error_type not in error_types: + error_types[error_type] = {'attempts': 0, 'successes': 0} + error_types[error_type]['attempts'] += 1 + if attempt['success']: + error_types[error_type]['successes'] += 1 + + return { + 'total_attempts': total_attempts, + 'successful_recoveries': successful_recoveries, + 'success_rate': success_rate, + 'error_types': error_types, + 'recent_attempts': self.recovery_history[-10:] # Last 10 attempts + } + + +# Global recovery manager instance +recovery_manager = RecoveryManager() \ No newline at end of file diff --git a/digipal/core/error_integration.py b/digipal/core/error_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..00a67509815f2d762d7d4d3908a1f45f507dab2b --- /dev/null +++ b/digipal/core/error_integration.py @@ -0,0 +1,591 @@ +""" +Error handling integration module for DigiPal. + +This module provides a unified interface for all error handling, +recovery, and user messaging functionality across the DigiPal system. +""" + +import logging +import asyncio +from typing import Dict, List, Optional, Any, Callable, Union +from datetime import datetime +from dataclasses import dataclass + +from .exceptions import DigiPalException, ErrorSeverity, ErrorCategory +from .error_handler import error_handler, with_error_handling, with_retry, RetryConfig +from .recovery_strategies import get_system_recovery_orchestrator, RecoveryResult +from .user_error_messages import ( + get_user_friendly_error_message, get_recovery_guide, + MessageTone, user_message_generator +) +from ..storage.backup_recovery import BackupRecoveryManager +from ..ai.graceful_degradation import ai_service_manager + +logger = logging.getLogger(__name__) + + +@dataclass +class ErrorHandlingResult: + """Result of comprehensive error handling.""" + success: bool + original_error: DigiPalException + user_message: str + recovery_attempted: bool + recovery_result: Optional[RecoveryResult] + recovery_guide: List[Dict[str, Any]] + fallback_value: Any + context: Dict[str, Any] + + +class DigiPalErrorHandler: + """Unified error handler for the entire DigiPal system.""" + + def __init__(self, backup_manager: Optional[BackupRecoveryManager] = None): + """ + Initialize the unified error handler. + + Args: + backup_manager: Backup manager for recovery operations + """ + self.backup_manager = backup_manager + self.error_callbacks: Dict[str, List[Callable]] = {} + self.user_context: Dict[str, Any] = {} + self.message_tone = MessageTone.FRIENDLY + + # Initialize recovery system if backup manager is provided + if backup_manager: + from .recovery_strategies import initialize_system_recovery + initialize_system_recovery(backup_manager) + + logger.info("DigiPal unified error handler initialized") + + def set_user_context(self, context: Dict[str, Any]): + """ + Set user context for personalized error messages. + + Args: + context: User context information + """ + self.user_context.update(context) + + def set_message_tone(self, tone: MessageTone): + """ + Set the tone for error messages. + + Args: + tone: Message tone to use + """ + self.message_tone = tone + + def register_error_callback(self, error_category: str, callback: Callable): + """ + Register a callback for specific error categories. + + Args: + error_category: Error category to listen for + callback: Callback function to execute + """ + if error_category not in self.error_callbacks: + self.error_callbacks[error_category] = [] + + self.error_callbacks[error_category].append(callback) + logger.info(f"Registered error callback for category: {error_category}") + + def handle_error_comprehensive( + self, + error: Exception, + context: Optional[Dict[str, Any]] = None, + attempt_recovery: bool = True, + provide_fallback: bool = True, + fallback_value: Any = None + ) -> ErrorHandlingResult: + """ + Comprehensive error handling with recovery and user messaging. + + Args: + error: The error that occurred + context: Additional context information + attempt_recovery: Whether to attempt automatic recovery + provide_fallback: Whether to provide fallback functionality + fallback_value: Fallback value to return + + Returns: + ErrorHandlingResult with complete handling information + """ + start_time = datetime.now() + context = context or {} + + try: + # Convert to DigiPal exception if needed + if not isinstance(error, DigiPalException): + digipal_error = error_handler.handle_error(error, context) + else: + digipal_error = error + digipal_error.context.update(context) + + # Log the error + error_handler.log_error(digipal_error, {'handling_start': start_time.isoformat()}) + + # Generate user-friendly message + user_message = get_user_friendly_error_message( + digipal_error, + tone=self.message_tone, + user_context=self.user_context + ) + + # Get recovery guide + recovery_guide = get_recovery_guide(digipal_error, max_steps=4) + + # Attempt recovery if requested and appropriate + recovery_result = None + recovery_attempted = False + + if attempt_recovery and digipal_error.severity in [ErrorSeverity.HIGH, ErrorSeverity.CRITICAL]: + recovery_attempted = True + orchestrator = get_system_recovery_orchestrator() + + if orchestrator: + recovery_result = orchestrator.execute_comprehensive_recovery(digipal_error) + + if recovery_result.success: + logger.info(f"Recovery successful for error: {digipal_error.error_code}") + user_message = user_message_generator.generate_success_message( + digipal_error.category.value, + recovery_result.strategy_used + ) + else: + logger.warning(f"Recovery failed for error: {digipal_error.error_code}") + + # Handle graceful degradation for AI services + if digipal_error.category == ErrorCategory.AI_MODEL: + self._handle_ai_service_degradation(digipal_error) + + # Execute registered callbacks + self._execute_error_callbacks(digipal_error) + + # Determine success based on recovery and severity + overall_success = ( + recovery_result.success if recovery_result + else digipal_error.severity in [ErrorSeverity.LOW, ErrorSeverity.MEDIUM] + ) + + # Prepare result + result = ErrorHandlingResult( + success=overall_success, + original_error=digipal_error, + user_message=user_message, + recovery_attempted=recovery_attempted, + recovery_result=recovery_result, + recovery_guide=recovery_guide, + fallback_value=fallback_value if provide_fallback else None, + context={ + **digipal_error.context, + 'handling_duration_ms': (datetime.now() - start_time).total_seconds() * 1000, + 'user_context': self.user_context, + 'message_tone': self.message_tone.value + } + ) + + return result + + except Exception as handling_error: + logger.error(f"Error handling failed: {handling_error}") + + # Return minimal error result + return ErrorHandlingResult( + success=False, + original_error=error_handler.handle_error(handling_error), + user_message="An unexpected error occurred during error handling. Please restart the application.", + recovery_attempted=False, + recovery_result=None, + recovery_guide=[], + fallback_value=fallback_value if provide_fallback else None, + context={'handling_error': str(handling_error)} + ) + + def _handle_ai_service_degradation(self, error: DigiPalException): + """Handle AI service degradation.""" + try: + # Update AI service status + service_name = error.context.get('service_name', 'unknown') + if service_name in ai_service_manager.service_status: + ai_service_manager.service_status[service_name] = False + ai_service_manager._update_degradation_level() + + logger.info(f"AI service {service_name} marked as degraded") + except Exception as e: + logger.error(f"Failed to handle AI service degradation: {e}") + + def _execute_error_callbacks(self, error: DigiPalException): + """Execute registered error callbacks.""" + try: + category_callbacks = self.error_callbacks.get(error.category.value, []) + general_callbacks = self.error_callbacks.get('all', []) + + all_callbacks = category_callbacks + general_callbacks + + for callback in all_callbacks: + try: + callback(error) + except Exception as callback_error: + logger.error(f"Error callback failed: {callback_error}") + except Exception as e: + logger.error(f"Failed to execute error callbacks: {e}") + + async def handle_error_async( + self, + error: Exception, + context: Optional[Dict[str, Any]] = None, + attempt_recovery: bool = True, + provide_fallback: bool = True, + fallback_value: Any = None + ) -> ErrorHandlingResult: + """ + Asynchronous comprehensive error handling. + + Args: + error: The error that occurred + context: Additional context information + attempt_recovery: Whether to attempt automatic recovery + provide_fallback: Whether to provide fallback functionality + fallback_value: Fallback value to return + + Returns: + ErrorHandlingResult with complete handling information + """ + # Run synchronous error handling in thread pool + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + self.handle_error_comprehensive, + error, + context, + attempt_recovery, + provide_fallback, + fallback_value + ) + + def create_error_safe_wrapper( + self, + func: Callable, + fallback_value: Any = None, + retry_config: Optional[RetryConfig] = None, + context: Optional[Dict[str, Any]] = None + ) -> Callable: + """ + Create an error-safe wrapper for any function. + + Args: + func: Function to wrap + fallback_value: Value to return on error + retry_config: Retry configuration + context: Additional context + + Returns: + Error-safe wrapped function + """ + def wrapper(*args, **kwargs): + try: + # Apply retry if configured + if retry_config: + @with_retry(retry_config) + def retryable_func(): + return func(*args, **kwargs) + + return retryable_func() + else: + return func(*args, **kwargs) + + except Exception as e: + result = self.handle_error_comprehensive( + e, + context=context, + fallback_value=fallback_value + ) + + if result.success or result.fallback_value is not None: + return result.fallback_value + else: + raise result.original_error + + return wrapper + + def get_system_health_status(self) -> Dict[str, Any]: + """ + Get comprehensive system health status. + + Returns: + System health information + """ + try: + from .error_handler import get_error_statistics, health_checker + + # Get error statistics + error_stats = get_error_statistics() + + # Get AI service status + ai_status = ai_service_manager.get_service_status() + + # Get backup status + backup_stats = {} + if self.backup_manager: + backup_stats = self.backup_manager.get_backup_statistics() + + # Get health check status + health_status = health_checker.get_system_health() + + # Calculate overall health score + health_score = self._calculate_health_score(error_stats, ai_status, health_status) + + return { + 'overall_health_score': health_score, + 'status': 'healthy' if health_score > 0.8 else 'degraded' if health_score > 0.5 else 'critical', + 'error_statistics': error_stats, + 'ai_service_status': ai_status, + 'backup_statistics': backup_stats, + 'component_health': health_status, + 'timestamp': datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"Failed to get system health status: {e}") + return { + 'overall_health_score': 0.0, + 'status': 'unknown', + 'error': str(e), + 'timestamp': datetime.now().isoformat() + } + + def _calculate_health_score( + self, + error_stats: Dict[str, Any], + ai_status: Dict[str, Any], + health_status: Dict[str, Any] + ) -> float: + """Calculate overall system health score (0.0 to 1.0).""" + try: + score = 1.0 + + # Reduce score based on error rate + error_rate = error_handler.get_error_rate(window_minutes=5) + if error_rate > 0: + score -= min(error_rate * 0.1, 0.5) # Max 50% reduction for errors + + # Reduce score based on AI service degradation + degradation_level = ai_status.get('degradation_level', 'full_service') + degradation_penalties = { + 'full_service': 0.0, + 'reduced_features': 0.1, + 'basic_responses': 0.3, + 'minimal_function': 0.5, + 'emergency_mode': 0.7 + } + score -= degradation_penalties.get(degradation_level, 0.5) + + # Reduce score based on component health + if health_status.get('overall_health') == 'critical': + score -= 0.4 + elif health_status.get('overall_health') == 'unhealthy': + score -= 0.2 + elif health_status.get('overall_health') == 'degraded': + score -= 0.1 + + return max(0.0, min(1.0, score)) + + except Exception as e: + logger.error(f"Failed to calculate health score: {e}") + return 0.5 # Default to moderate health + + def generate_health_report(self) -> str: + """ + Generate a human-readable health report. + + Returns: + Health report string + """ + try: + health_status = self.get_system_health_status() + + report_lines = [ + "=== DigiPal System Health Report ===", + f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + "", + f"Overall Status: {health_status['status'].upper()}", + f"Health Score: {health_status['overall_health_score']:.2f}/1.00", + "" + ] + + # Error statistics + error_stats = health_status.get('error_statistics', {}) + if error_stats.get('error_counts'): + report_lines.extend([ + "Recent Errors:", + f" - Total error types: {len(error_stats['error_counts'])}", + f" - Most frequent: {list(error_stats['error_counts'].keys())[:3]}" + ]) + else: + report_lines.append("Recent Errors: None") + + report_lines.append("") + + # AI service status + ai_status = health_status.get('ai_service_status', {}) + degradation_level = ai_status.get('degradation_level', 'unknown') + report_lines.extend([ + f"AI Services: {degradation_level.replace('_', ' ').title()}", + f" - Language Model: {'โ' if ai_status.get('services', {}).get('language_model') else 'โ'}", + f" - Speech Processing: {'โ' if ai_status.get('services', {}).get('speech_processing') else 'โ'}", + f" - Image Generation: {'โ' if ai_status.get('services', {}).get('image_generation') else 'โ'}", + "" + ]) + + # Backup status + backup_stats = health_status.get('backup_statistics', {}) + if backup_stats: + report_lines.extend([ + f"Backup System:", + f" - Total backups: {backup_stats.get('total_backups', 0)}", + f" - Storage used: {backup_stats.get('total_size_bytes', 0) / 1024 / 1024:.1f} MB", + "" + ]) + + # Recommendations + recommendations = self._generate_health_recommendations(health_status) + if recommendations: + report_lines.extend([ + "Recommendations:", + *[f" - {rec}" for rec in recommendations], + "" + ]) + + report_lines.append("=== End of Report ===") + + return "\n".join(report_lines) + + except Exception as e: + logger.error(f"Failed to generate health report: {e}") + return f"Health report generation failed: {str(e)}" + + def _generate_health_recommendations(self, health_status: Dict[str, Any]) -> List[str]: + """Generate health recommendations based on system status.""" + recommendations = [] + + try: + health_score = health_status.get('overall_health_score', 1.0) + + if health_score < 0.5: + recommendations.append("System health is critical - consider restarting the application") + + # AI service recommendations + ai_status = health_status.get('ai_service_status', {}) + degradation_level = ai_status.get('degradation_level', 'full_service') + + if degradation_level != 'full_service': + recommendations.append("AI services are degraded - check internet connection and model availability") + + # Error rate recommendations + error_stats = health_status.get('error_statistics', {}) + if error_stats.get('error_counts'): + most_frequent = list(error_stats['error_counts'].keys())[:1] + if most_frequent: + error_type = most_frequent[0].split(':')[0] + recommendations.append(f"High {error_type} error rate - check system logs for details") + + # Backup recommendations + backup_stats = health_status.get('backup_statistics', {}) + if backup_stats.get('total_backups', 0) == 0: + recommendations.append("No backups found - enable automatic backups for data safety") + + except Exception as e: + logger.error(f"Failed to generate recommendations: {e}") + recommendations.append("Unable to generate specific recommendations - check system logs") + + return recommendations + + +# Global unified error handler instance +unified_error_handler: Optional[DigiPalErrorHandler] = None + + +def initialize_error_handling(backup_manager: Optional[BackupRecoveryManager] = None): + """ + Initialize the global unified error handler. + + Args: + backup_manager: Backup manager for recovery operations + """ + global unified_error_handler + unified_error_handler = DigiPalErrorHandler(backup_manager) + logger.info("Global unified error handler initialized") + + +def get_error_handler() -> Optional[DigiPalErrorHandler]: + """Get the global unified error handler.""" + return unified_error_handler + + +def handle_error_safely( + error: Exception, + context: Optional[Dict[str, Any]] = None, + fallback_value: Any = None +) -> ErrorHandlingResult: + """ + Safely handle an error using the global error handler. + + Args: + error: The error that occurred + context: Additional context + fallback_value: Fallback value + + Returns: + ErrorHandlingResult + """ + if unified_error_handler: + return unified_error_handler.handle_error_comprehensive( + error, context, fallback_value=fallback_value + ) + else: + # Fallback to basic error handling + digipal_error = error_handler.handle_error(error, context) + return ErrorHandlingResult( + success=False, + original_error=digipal_error, + user_message="An error occurred. Please try again.", + recovery_attempted=False, + recovery_result=None, + recovery_guide=[], + fallback_value=fallback_value, + context=context or {} + ) + + +def create_safe_function( + func: Callable, + fallback_value: Any = None, + context: Optional[Dict[str, Any]] = None +) -> Callable: + """ + Create a safe version of any function. + + Args: + func: Function to make safe + fallback_value: Value to return on error + context: Additional context + + Returns: + Safe function wrapper + """ + if unified_error_handler: + return unified_error_handler.create_error_safe_wrapper( + func, fallback_value, context=context + ) + else: + # Basic wrapper + def safe_wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.error(f"Function {func.__name__} failed: {e}") + return fallback_value + + return safe_wrapper \ No newline at end of file diff --git a/digipal/core/evolution_controller.py b/digipal/core/evolution_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..e8d87786d42cfa89abb7c31999f54cadf10430bc --- /dev/null +++ b/digipal/core/evolution_controller.py @@ -0,0 +1,629 @@ +""" +EvolutionController for DigiPal - Manages life stage progression and generational inheritance. +""" + +from typing import Dict, List, Optional, Tuple, Any +import random +from datetime import datetime, timedelta +from dataclasses import dataclass, field + +from .models import DigiPal, AttributeModifier +from .enums import LifeStage, EggType, AttributeType + + +@dataclass +class EvolutionRequirement: + """Represents requirements for evolution to a specific life stage.""" + min_attributes: Dict[AttributeType, int] = field(default_factory=dict) + max_attributes: Dict[AttributeType, int] = field(default_factory=dict) + max_care_mistakes: int = 999 + min_age_hours: float = 0.0 + max_age_hours: float = float('inf') + required_actions: List[str] = field(default_factory=list) + happiness_threshold: int = 0 + discipline_range: Tuple[int, int] = (0, 100) + weight_range: Tuple[int, int] = (1, 99) + + def to_dict(self) -> Dict[str, Any]: + """Convert EvolutionRequirement to dictionary.""" + return { + 'min_attributes': {attr.value: val for attr, val in self.min_attributes.items()}, + 'max_attributes': {attr.value: val for attr, val in self.max_attributes.items()}, + 'max_care_mistakes': self.max_care_mistakes, + 'min_age_hours': self.min_age_hours, + 'max_age_hours': self.max_age_hours, + 'required_actions': self.required_actions, + 'happiness_threshold': self.happiness_threshold, + 'discipline_range': self.discipline_range, + 'weight_range': self.weight_range + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'EvolutionRequirement': + """Create EvolutionRequirement from dictionary.""" + data['min_attributes'] = {AttributeType(attr): val for attr, val in data.get('min_attributes', {}).items()} + data['max_attributes'] = {AttributeType(attr): val for attr, val in data.get('max_attributes', {}).items()} + return cls(**data) + + +@dataclass +class EvolutionResult: + """Result of an evolution attempt.""" + success: bool + old_stage: LifeStage + new_stage: LifeStage + attribute_changes: Dict[str, int] = field(default_factory=dict) + message: str = "" + requirements_met: Dict[str, bool] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert EvolutionResult to dictionary.""" + return { + 'success': self.success, + 'old_stage': self.old_stage.value, + 'new_stage': self.new_stage.value, + 'attribute_changes': self.attribute_changes, + 'message': self.message, + 'requirements_met': self.requirements_met + } + + +@dataclass +class DNAInheritance: + """Represents DNA inheritance data for generational passing.""" + parent_attributes: Dict[AttributeType, int] = field(default_factory=dict) + parent_care_quality: str = "fair" + parent_final_stage: LifeStage = LifeStage.ADULT + parent_egg_type: EggType = EggType.RED + generation: int = 1 + inheritance_bonuses: Dict[AttributeType, int] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert DNAInheritance to dictionary.""" + return { + 'parent_attributes': {attr.value: val for attr, val in self.parent_attributes.items()}, + 'parent_care_quality': self.parent_care_quality, + 'parent_final_stage': self.parent_final_stage.value, + 'parent_egg_type': self.parent_egg_type.value, + 'generation': self.generation, + 'inheritance_bonuses': {attr.value: val for attr, val in self.inheritance_bonuses.items()} + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'DNAInheritance': + """Create DNAInheritance from dictionary.""" + data['parent_attributes'] = {AttributeType(attr): val for attr, val in data.get('parent_attributes', {}).items()} + data['parent_final_stage'] = LifeStage(data['parent_final_stage']) + data['parent_egg_type'] = EggType(data['parent_egg_type']) + data['inheritance_bonuses'] = {AttributeType(attr): val for attr, val in data.get('inheritance_bonuses', {}).items()} + return cls(**data) + + +class EvolutionController: + """ + Manages DigiPal evolution through life stages and generational inheritance. + Implements time-based evolution triggers and DNA-based attribute passing. + """ + + # Evolution timing configuration (hours) + EVOLUTION_TIMINGS = { + LifeStage.EGG: 0.5, # 30 minutes to hatch + LifeStage.BABY: 24.0, # 1 day as baby + LifeStage.CHILD: 72.0, # 3 days as child + LifeStage.TEEN: 120.0, # 5 days as teen + LifeStage.YOUNG_ADULT: 168.0, # 7 days as young adult + LifeStage.ADULT: 240.0, # 10 days as adult + LifeStage.ELDERLY: 72.0 # 3 days as elderly before death + } + + def __init__(self): + """Initialize the EvolutionController.""" + self.evolution_requirements = self._initialize_evolution_requirements() + + def _initialize_evolution_requirements(self) -> Dict[LifeStage, EvolutionRequirement]: + """Initialize evolution requirements for each life stage transition.""" + requirements = {} + + # EGG -> BABY: Triggered by first speech interaction + requirements[LifeStage.BABY] = EvolutionRequirement( + min_age_hours=0.0, + max_care_mistakes=0, + happiness_threshold=0 + ) + + # BABY -> CHILD: Basic care requirements + requirements[LifeStage.CHILD] = EvolutionRequirement( + min_age_hours=20.0, + max_care_mistakes=5, + happiness_threshold=30, + min_attributes={ + AttributeType.HP: 80, + AttributeType.ENERGY: 20 + } + ) + + # CHILD -> TEEN: Balanced development + requirements[LifeStage.TEEN] = EvolutionRequirement( + min_age_hours=60.0, + max_care_mistakes=10, + happiness_threshold=40, + discipline_range=(20, 80), + weight_range=(15, 50), + min_attributes={ + AttributeType.HP: 120, + AttributeType.OFFENSE: 15, + AttributeType.DEFENSE: 15 + } + ) + + # TEEN -> YOUNG_ADULT: Specialized development paths + requirements[LifeStage.YOUNG_ADULT] = EvolutionRequirement( + min_age_hours=100.0, + max_care_mistakes=15, + happiness_threshold=50, + discipline_range=(30, 70), + weight_range=(15, 40), + min_attributes={ + AttributeType.HP: 150, + AttributeType.OFFENSE: 25, + AttributeType.DEFENSE: 25, + AttributeType.SPEED: 20, + AttributeType.BRAINS: 20 + } + ) + + # YOUNG_ADULT -> ADULT: Peak performance requirements + requirements[LifeStage.ADULT] = EvolutionRequirement( + min_age_hours=150.0, + max_care_mistakes=20, + happiness_threshold=60, + discipline_range=(40, 60), + weight_range=(20, 35), + min_attributes={ + AttributeType.HP: 200, + AttributeType.OFFENSE: 40, + AttributeType.DEFENSE: 40, + AttributeType.SPEED: 35, + AttributeType.BRAINS: 35 + } + ) + + # ADULT -> ELDERLY: Automatic after time limit + requirements[LifeStage.ELDERLY] = EvolutionRequirement( + min_age_hours=200.0, + max_care_mistakes=999, # No care mistake limit for elderly + happiness_threshold=0 + ) + + return requirements + + def check_evolution_eligibility(self, pet: DigiPal) -> Tuple[bool, LifeStage, Dict[str, bool]]: + """ + Check if a DigiPal is eligible for evolution to the next stage. + + Args: + pet: The DigiPal to check + + Returns: + Tuple of (eligible, next_stage, requirements_status) + """ + current_stage = pet.life_stage + next_stage = self._get_next_life_stage(current_stage) + + if next_stage is None: + return False, current_stage, {} + + requirements = self.evolution_requirements.get(next_stage) + if not requirements: + return False, current_stage, {} + + requirements_status = self._evaluate_evolution_requirements(pet, requirements) + eligible = all(requirements_status.values()) + + return eligible, next_stage, requirements_status + + def _get_next_life_stage(self, current_stage: LifeStage) -> Optional[LifeStage]: + """Get the next life stage in the progression.""" + stage_progression = [ + LifeStage.EGG, + LifeStage.BABY, + LifeStage.CHILD, + LifeStage.TEEN, + LifeStage.YOUNG_ADULT, + LifeStage.ADULT, + LifeStage.ELDERLY + ] + + try: + current_index = stage_progression.index(current_stage) + if current_index < len(stage_progression) - 1: + return stage_progression[current_index + 1] + except ValueError: + pass + + return None + + def _evaluate_evolution_requirements(self, pet: DigiPal, requirements: EvolutionRequirement) -> Dict[str, bool]: + """Evaluate all evolution requirements for a pet.""" + status = {} + + # Check age requirements + age_hours = pet.get_age_hours() + status['min_age'] = age_hours >= requirements.min_age_hours + status['max_age'] = age_hours <= requirements.max_age_hours + + # Check care mistakes + status['care_mistakes'] = pet.care_mistakes <= requirements.max_care_mistakes + + # Check happiness threshold + status['happiness'] = pet.happiness >= requirements.happiness_threshold + + # Check discipline range + discipline_min, discipline_max = requirements.discipline_range + status['discipline'] = discipline_min <= pet.discipline <= discipline_max + + # Check weight range + weight_min, weight_max = requirements.weight_range + status['weight'] = weight_min <= pet.weight <= weight_max + + # Check minimum attributes + for attr, min_val in requirements.min_attributes.items(): + current_val = pet.get_attribute(attr) + status[f'min_{attr.value}'] = current_val >= min_val + + # Check maximum attributes + for attr, max_val in requirements.max_attributes.items(): + current_val = pet.get_attribute(attr) + status[f'max_{attr.value}'] = current_val <= max_val + + # Check required actions (placeholder for future implementation) + if requirements.required_actions: + status['required_actions'] = True # Simplified for now + + return status + + def trigger_evolution(self, pet: DigiPal, force: bool = False) -> EvolutionResult: + """ + Trigger evolution for a DigiPal if eligible. + + Args: + pet: The DigiPal to evolve + force: Force evolution regardless of requirements (for testing) + + Returns: + EvolutionResult with evolution outcome + """ + old_stage = pet.life_stage + + if not force: + eligible, next_stage, requirements_status = self.check_evolution_eligibility(pet) + + if not eligible: + return EvolutionResult( + success=False, + old_stage=old_stage, + new_stage=old_stage, + message="Evolution requirements not met", + requirements_met=requirements_status + ) + else: + next_stage = self._get_next_life_stage(old_stage) + if next_stage is None: + return EvolutionResult( + success=False, + old_stage=old_stage, + new_stage=old_stage, + message="No next evolution stage available" + ) + requirements_status = {} + + # Perform evolution + attribute_changes = self._apply_evolution_changes(pet, old_stage, next_stage) + pet.life_stage = next_stage + pet.evolution_timer = 0.0 # Reset evolution timer + + # Update learned commands based on new stage + self._update_learned_commands(pet, next_stage) + + # Generate evolution message + message = f"Evolution successful! {old_stage.value.title()} -> {next_stage.value.title()}" + + return EvolutionResult( + success=True, + old_stage=old_stage, + new_stage=next_stage, + attribute_changes=attribute_changes, + message=message, + requirements_met=requirements_status + ) + + def _apply_evolution_changes(self, pet: DigiPal, old_stage: LifeStage, new_stage: LifeStage) -> Dict[str, int]: + """Apply attribute changes during evolution.""" + changes = {} + + # Base evolution bonuses by stage + evolution_bonuses = { + LifeStage.BABY: { + AttributeType.HP: 20, + AttributeType.MP: 10, + AttributeType.HAPPINESS: 10 + }, + LifeStage.CHILD: { + AttributeType.HP: 30, + AttributeType.MP: 15, + AttributeType.OFFENSE: 5, + AttributeType.DEFENSE: 5, + AttributeType.SPEED: 5, + AttributeType.BRAINS: 5 + }, + LifeStage.TEEN: { + AttributeType.HP: 40, + AttributeType.MP: 20, + AttributeType.OFFENSE: 10, + AttributeType.DEFENSE: 10, + AttributeType.SPEED: 10, + AttributeType.BRAINS: 10 + }, + LifeStage.YOUNG_ADULT: { + AttributeType.HP: 50, + AttributeType.MP: 25, + AttributeType.OFFENSE: 15, + AttributeType.DEFENSE: 15, + AttributeType.SPEED: 15, + AttributeType.BRAINS: 15 + }, + LifeStage.ADULT: { + AttributeType.HP: 60, + AttributeType.MP: 30, + AttributeType.OFFENSE: 20, + AttributeType.DEFENSE: 20, + AttributeType.SPEED: 20, + AttributeType.BRAINS: 20 + }, + LifeStage.ELDERLY: { + AttributeType.HP: -20, # Elderly lose some physical attributes + AttributeType.OFFENSE: -10, + AttributeType.DEFENSE: -5, + AttributeType.SPEED: -15, + AttributeType.BRAINS: 10, # But gain wisdom + AttributeType.MP: 20 + } + } + + bonuses = evolution_bonuses.get(new_stage, {}) + + for attr, bonus in bonuses.items(): + old_value = pet.get_attribute(attr) + pet.modify_attribute(attr, bonus) + new_value = pet.get_attribute(attr) + changes[attr.value] = new_value - old_value + + # Egg type specific bonuses + if new_stage in [LifeStage.CHILD, LifeStage.TEEN, LifeStage.YOUNG_ADULT]: + egg_bonuses = self._get_egg_type_evolution_bonus(pet.egg_type, new_stage) + for attr, bonus in egg_bonuses.items(): + old_value = pet.get_attribute(attr) + pet.modify_attribute(attr, bonus) + new_value = pet.get_attribute(attr) + if attr.value in changes: + changes[attr.value] += new_value - old_value + else: + changes[attr.value] = new_value - old_value + + return changes + + def _get_egg_type_evolution_bonus(self, egg_type: EggType, stage: LifeStage) -> Dict[AttributeType, int]: + """Get egg type specific evolution bonuses.""" + bonuses = {} + + if egg_type == EggType.RED: # Fire-oriented + bonuses = { + AttributeType.OFFENSE: 5, + AttributeType.SPEED: 3, + AttributeType.HP: 2 + } + elif egg_type == EggType.BLUE: # Water-oriented + bonuses = { + AttributeType.DEFENSE: 5, + AttributeType.MP: 5, + AttributeType.BRAINS: 3 + } + elif egg_type == EggType.GREEN: # Earth-oriented + bonuses = { + AttributeType.HP: 8, + AttributeType.DEFENSE: 3, + AttributeType.BRAINS: 2 + } + + # Scale bonuses by stage + stage_multipliers = { + LifeStage.CHILD: 1.0, + LifeStage.TEEN: 1.5, + LifeStage.YOUNG_ADULT: 2.0 + } + + multiplier = stage_multipliers.get(stage, 1.0) + return {attr: int(bonus * multiplier) for attr, bonus in bonuses.items()} + + def _update_learned_commands(self, pet: DigiPal, new_stage: LifeStage): + """Update learned commands based on new life stage.""" + stage_commands = { + LifeStage.EGG: set(), + LifeStage.BABY: {"eat", "sleep", "good", "bad"}, + LifeStage.CHILD: {"eat", "sleep", "good", "bad", "play", "train"}, + LifeStage.TEEN: {"eat", "sleep", "good", "bad", "play", "train", "status", "talk"}, + LifeStage.YOUNG_ADULT: {"eat", "sleep", "good", "bad", "play", "train", "status", "talk", "battle"}, + LifeStage.ADULT: {"eat", "sleep", "good", "bad", "play", "train", "status", "talk", "battle", "teach"}, + LifeStage.ELDERLY: {"eat", "sleep", "good", "bad", "play", "train", "status", "talk", "battle", "teach", "wisdom"} + } + + new_commands = stage_commands.get(new_stage, set()) + pet.learned_commands.update(new_commands) + + def check_time_based_evolution(self, pet: DigiPal) -> bool: + """ + Check if enough time has passed for automatic evolution. + + Args: + pet: The DigiPal to check + + Returns: + True if time-based evolution should be triggered + """ + current_stage = pet.life_stage + required_time = self.EVOLUTION_TIMINGS.get(current_stage, float('inf')) + age_hours = pet.get_age_hours() + + return age_hours >= required_time + + def update_evolution_timer(self, pet: DigiPal, hours_passed: float): + """ + Update the evolution timer for a DigiPal. + + Args: + pet: The DigiPal to update + hours_passed: Number of hours that have passed + """ + pet.evolution_timer += hours_passed + + def create_inheritance_dna(self, parent: DigiPal, care_quality: str) -> DNAInheritance: + """ + Create DNA inheritance data from a parent DigiPal. + + Args: + parent: The parent DigiPal + care_quality: Quality of care the parent received + + Returns: + DNAInheritance object with inheritance data + """ + # Capture parent's final attributes + parent_attributes = { + AttributeType.HP: parent.hp, + AttributeType.MP: parent.mp, + AttributeType.OFFENSE: parent.offense, + AttributeType.DEFENSE: parent.defense, + AttributeType.SPEED: parent.speed, + AttributeType.BRAINS: parent.brains + } + + # Calculate inheritance bonuses based on parent's attributes and care quality + inheritance_bonuses = self._calculate_inheritance_bonuses(parent, care_quality) + + return DNAInheritance( + parent_attributes=parent_attributes, + parent_care_quality=care_quality, + parent_final_stage=parent.life_stage, + parent_egg_type=parent.egg_type, + generation=parent.generation + 1, + inheritance_bonuses=inheritance_bonuses + ) + + def _calculate_inheritance_bonuses(self, parent: DigiPal, care_quality: str) -> Dict[AttributeType, int]: + """Calculate attribute bonuses for inheritance based on parent stats and care quality.""" + bonuses = {} + + # Base inheritance percentages by care quality + inheritance_rates = { + "perfect": 0.25, + "excellent": 0.20, + "good": 0.15, + "fair": 0.10, + "poor": 0.05 + } + + rate = inheritance_rates.get(care_quality, 0.10) + + # Calculate bonuses based on parent's final attributes + primary_attributes = [ + AttributeType.HP, AttributeType.MP, AttributeType.OFFENSE, + AttributeType.DEFENSE, AttributeType.SPEED, AttributeType.BRAINS + ] + + for attr in primary_attributes: + parent_value = parent.get_attribute(attr) + # Higher parent attributes provide better inheritance bonuses + if parent_value > 100: # Above average + bonus = int((parent_value - 100) * rate) + bonuses[attr] = max(1, bonus) # Minimum bonus of 1 + + # Special bonuses for exceptional care + if care_quality == "perfect": + # Perfect care provides additional random bonuses + bonus_attr = random.choice(primary_attributes) + bonuses[bonus_attr] = bonuses.get(bonus_attr, 0) + random.randint(5, 15) + + return bonuses + + def apply_inheritance(self, offspring: DigiPal, dna: DNAInheritance): + """ + Apply DNA inheritance to a new DigiPal. + + Args: + offspring: The new DigiPal to apply inheritance to + dna: The DNA inheritance data + """ + offspring.generation = dna.generation + + # Apply inheritance bonuses + for attr, bonus in dna.inheritance_bonuses.items(): + offspring.modify_attribute(attr, bonus) + + # Add some randomization to prevent identical offspring + primary_attributes = [ + AttributeType.HP, AttributeType.MP, AttributeType.OFFENSE, + AttributeType.DEFENSE, AttributeType.SPEED, AttributeType.BRAINS + ] + + for attr in primary_attributes: + # Small random variation (-2 to +2) + variation = random.randint(-2, 2) + offspring.modify_attribute(attr, variation) + + # Inherit some personality traits (placeholder for future implementation) + offspring.personality_traits["inherited"] = True + offspring.personality_traits["parent_care_quality"] = dna.parent_care_quality + + def is_death_time(self, pet: DigiPal) -> bool: + """ + Check if a DigiPal should die (elderly stage time limit reached). + + Args: + pet: The DigiPal to check + + Returns: + True if the DigiPal should die + """ + if pet.life_stage != LifeStage.ELDERLY: + return False + + # Calculate total age and time limits for all previous stages + age_hours = pet.get_age_hours() + elderly_time_limit = self.EVOLUTION_TIMINGS.get(LifeStage.ELDERLY, 72.0) + + # Calculate minimum time to reach elderly stage + min_time_to_elderly = sum( + self.EVOLUTION_TIMINGS.get(stage, 0) + for stage in [LifeStage.EGG, LifeStage.BABY, LifeStage.CHILD, + LifeStage.TEEN, LifeStage.YOUNG_ADULT, LifeStage.ADULT] + ) + + # If pet is old enough to have been elderly for the full elderly duration + total_max_lifetime = min_time_to_elderly + elderly_time_limit + return age_hours >= total_max_lifetime + + def get_evolution_requirements(self, stage: LifeStage) -> Optional[EvolutionRequirement]: + """Get evolution requirements for a specific stage.""" + return self.evolution_requirements.get(stage) + + def get_all_evolution_requirements(self) -> Dict[LifeStage, EvolutionRequirement]: + """Get all evolution requirements.""" + return self.evolution_requirements.copy() + + def get_evolution_timing(self, stage: LifeStage) -> float: + """Get the evolution timing for a specific stage.""" + return self.EVOLUTION_TIMINGS.get(stage, float('inf')) + + def get_all_evolution_timings(self) -> Dict[LifeStage, float]: + """Get all evolution timings.""" + return self.EVOLUTION_TIMINGS.copy() \ No newline at end of file diff --git a/digipal/core/exceptions.py b/digipal/core/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..d83904b4a2f1186a107f2b1beccc7baf6921ad16 --- /dev/null +++ b/digipal/core/exceptions.py @@ -0,0 +1,285 @@ +""" +Custom exception classes for DigiPal application. + +This module defines all custom exceptions used throughout the DigiPal system +for better error handling and user experience. +""" + +from typing import Optional, Dict, Any +from enum import Enum + + +class ErrorSeverity(Enum): + """Error severity levels for categorizing exceptions.""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class ErrorCategory(Enum): + """Error categories for better error classification.""" + AUTHENTICATION = "authentication" + STORAGE = "storage" + AI_MODEL = "ai_model" + SPEECH_PROCESSING = "speech_processing" + IMAGE_GENERATION = "image_generation" + PET_LIFECYCLE = "pet_lifecycle" + MCP_PROTOCOL = "mcp_protocol" + NETWORK = "network" + VALIDATION = "validation" + SYSTEM = "system" + + +class DigiPalException(Exception): + """Base exception class for all DigiPal-specific errors.""" + + def __init__( + self, + message: str, + category: ErrorCategory = ErrorCategory.SYSTEM, + severity: ErrorSeverity = ErrorSeverity.MEDIUM, + user_message: Optional[str] = None, + recovery_suggestions: Optional[list] = None, + error_code: Optional[str] = None, + context: Optional[Dict[str, Any]] = None + ): + """ + Initialize DigiPal exception. + + Args: + message: Technical error message for logging + category: Error category for classification + severity: Error severity level + user_message: User-friendly error message + recovery_suggestions: List of recovery suggestions for users + error_code: Unique error code for tracking + context: Additional context information + """ + super().__init__(message) + self.category = category + self.severity = severity + self.user_message = user_message or self._generate_user_message() + self.recovery_suggestions = recovery_suggestions or [] + self.error_code = error_code + self.context = context or {} + + def _generate_user_message(self) -> str: + """Generate a user-friendly message based on the category.""" + category_messages = { + ErrorCategory.AUTHENTICATION: "There was a problem with authentication. Please try logging in again.", + ErrorCategory.STORAGE: "There was a problem saving or loading your DigiPal data.", + ErrorCategory.AI_MODEL: "The AI system is having trouble responding. Please try again.", + ErrorCategory.SPEECH_PROCESSING: "I couldn't understand your speech. Please try speaking again.", + ErrorCategory.IMAGE_GENERATION: "There was a problem generating your DigiPal's image.", + ErrorCategory.PET_LIFECYCLE: "There was a problem with your DigiPal's lifecycle management.", + ErrorCategory.MCP_PROTOCOL: "There was a problem with external system communication.", + ErrorCategory.NETWORK: "There was a network connection problem. Please check your internet.", + ErrorCategory.VALIDATION: "The provided information is not valid. Please check and try again.", + ErrorCategory.SYSTEM: "A system error occurred. Please try again." + } + return category_messages.get(self.category, "An unexpected error occurred.") + + def to_dict(self) -> Dict[str, Any]: + """Convert exception to dictionary for logging and serialization.""" + return { + 'message': str(self), + 'category': self.category.value, + 'severity': self.severity.value, + 'user_message': self.user_message, + 'recovery_suggestions': self.recovery_suggestions, + 'error_code': self.error_code, + 'context': self.context + } + + +class AuthenticationError(DigiPalException): + """Exception raised for authentication-related errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.AUTHENTICATION, + severity=ErrorSeverity.HIGH, + recovery_suggestions=[ + "Check your HuggingFace credentials", + "Ensure you have a stable internet connection", + "Try refreshing the page and logging in again" + ], + **kwargs + ) + + +class StorageError(DigiPalException): + """Exception raised for storage and database-related errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.STORAGE, + severity=ErrorSeverity.HIGH, + recovery_suggestions=[ + "Check if you have sufficient disk space", + "Try restarting the application", + "Contact support if the problem persists" + ], + **kwargs + ) + + +class AIModelError(DigiPalException): + """Exception raised for AI model-related errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.AI_MODEL, + severity=ErrorSeverity.MEDIUM, + recovery_suggestions=[ + "Try your request again in a moment", + "Use simpler language if speaking to your DigiPal", + "Check your internet connection" + ], + **kwargs + ) + + +class SpeechProcessingError(DigiPalException): + """Exception raised for speech processing errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.SPEECH_PROCESSING, + severity=ErrorSeverity.LOW, + recovery_suggestions=[ + "Speak more clearly and slowly", + "Check your microphone permissions", + "Try using text input instead", + "Reduce background noise" + ], + **kwargs + ) + + +class ImageGenerationError(DigiPalException): + """Exception raised for image generation errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.IMAGE_GENERATION, + severity=ErrorSeverity.LOW, + recovery_suggestions=[ + "A default image will be used instead", + "Try again later when the service is available", + "Check your internet connection" + ], + **kwargs + ) + + +class PetLifecycleError(DigiPalException): + """Exception raised for pet lifecycle management errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.PET_LIFECYCLE, + severity=ErrorSeverity.HIGH, + recovery_suggestions=[ + "Try reloading your DigiPal", + "Check if your DigiPal data is corrupted", + "Contact support for data recovery" + ], + **kwargs + ) + + +class MCPProtocolError(DigiPalException): + """Exception raised for MCP protocol-related errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.MCP_PROTOCOL, + severity=ErrorSeverity.MEDIUM, + recovery_suggestions=[ + "Check the MCP client configuration", + "Verify authentication credentials", + "Try reconnecting to the MCP server" + ], + **kwargs + ) + + +class NetworkError(DigiPalException): + """Exception raised for network-related errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.NETWORK, + severity=ErrorSeverity.MEDIUM, + recovery_suggestions=[ + "Check your internet connection", + "Try again in a few moments", + "Switch to offline mode if available" + ], + **kwargs + ) + + +class ValidationError(DigiPalException): + """Exception raised for data validation errors.""" + + def __init__(self, message: str, field: Optional[str] = None, **kwargs): + super().__init__( + message, + category=ErrorCategory.VALIDATION, + severity=ErrorSeverity.LOW, + recovery_suggestions=[ + "Check the format of your input", + "Ensure all required fields are filled", + "Try with different values" + ], + **kwargs + ) + if field: + self.context['field'] = field + + +class SystemError(DigiPalException): + """Exception raised for general system errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message, + category=ErrorCategory.SYSTEM, + severity=ErrorSeverity.HIGH, + recovery_suggestions=[ + "Try restarting the application", + "Check system resources (memory, disk space)", + "Contact support if the problem persists" + ], + **kwargs + ) + + +class RecoveryError(DigiPalException): + """Exception raised when recovery operations fail.""" + + def __init__(self, message: str, original_error: Optional[Exception] = None, **kwargs): + super().__init__( + message, + category=ErrorCategory.SYSTEM, + severity=ErrorSeverity.CRITICAL, + recovery_suggestions=[ + "Manual intervention may be required", + "Contact support immediately", + "Do not attempt further operations" + ], + **kwargs + ) + if original_error: + self.context['original_error'] = str(original_error) \ No newline at end of file diff --git a/digipal/core/memory_manager.py b/digipal/core/memory_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..ace15f2c564c94b6931549392c528e8a28b9c672 --- /dev/null +++ b/digipal/core/memory_manager.py @@ -0,0 +1,839 @@ +""" +Enhanced memory management system for DigiPal with emotional values and RAG capabilities. + +This module provides comprehensive memory management including: +- Memory caching for frequently accessed pet data +- Emotional memory system with happiness/stress values +- Simple RAG implementation for relevant memory retrieval +- Memory cleanup and optimization +""" + +import logging +import json +import time +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any, Tuple, Set +from dataclasses import dataclass, field +from collections import defaultdict, OrderedDict +import threading +import weakref +import gc + +from .models import DigiPal, Interaction +from .enums import LifeStage, InteractionResult +from ..storage.storage_manager import StorageManager + +logger = logging.getLogger(__name__) + + +@dataclass +class EmotionalMemory: + """Represents a memory with emotional context and metadata.""" + id: str + timestamp: datetime + content: str + memory_type: str # 'interaction', 'action', 'event', 'detail' + emotional_value: float # -1.0 (very stressful) to 1.0 (very happy) + importance: float # 0.0 to 1.0, affects retention + tags: Set[str] = field(default_factory=set) + related_attributes: Dict[str, int] = field(default_factory=dict) + access_count: int = 0 + last_accessed: datetime = field(default_factory=datetime.now) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + 'id': self.id, + 'timestamp': self.timestamp.isoformat(), + 'content': self.content, + 'memory_type': self.memory_type, + 'emotional_value': self.emotional_value, + 'importance': self.importance, + 'tags': list(self.tags), + 'related_attributes': self.related_attributes, + 'access_count': self.access_count, + 'last_accessed': self.last_accessed.isoformat() + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'EmotionalMemory': + """Create from dictionary.""" + data['timestamp'] = datetime.fromisoformat(data['timestamp']) + data['last_accessed'] = datetime.fromisoformat(data['last_accessed']) + data['tags'] = set(data.get('tags', [])) + return cls(**data) + + +class MemoryCache: + """LRU cache for frequently accessed pet data with automatic cleanup.""" + + def __init__(self, max_size: int = 1000, ttl_seconds: int = 3600): + """ + Initialize memory cache. + + Args: + max_size: Maximum number of items to cache + ttl_seconds: Time-to-live for cached items in seconds + """ + self.max_size = max_size + self.ttl_seconds = ttl_seconds + self._cache: OrderedDict = OrderedDict() + self._timestamps: Dict[str, float] = {} + self._lock = threading.RLock() + + # Start cleanup thread + self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) + self._stop_cleanup = False + self._cleanup_thread.start() + + def get(self, key: str) -> Optional[Any]: + """Get item from cache.""" + with self._lock: + current_time = time.time() + + # Check if item exists and is not expired + if key in self._cache: + if current_time - self._timestamps[key] < self.ttl_seconds: + # Move to end (most recently used) + self._cache.move_to_end(key) + return self._cache[key] + else: + # Item expired, remove it + del self._cache[key] + del self._timestamps[key] + + return None + + def put(self, key: str, value: Any) -> None: + """Put item in cache.""" + with self._lock: + current_time = time.time() + + # If key exists, update it + if key in self._cache: + self._cache[key] = value + self._timestamps[key] = current_time + self._cache.move_to_end(key) + return + + # If cache is full, remove least recently used item + if len(self._cache) >= self.max_size: + oldest_key = next(iter(self._cache)) + del self._cache[oldest_key] + del self._timestamps[oldest_key] + + # Add new item + self._cache[key] = value + self._timestamps[key] = current_time + + def remove(self, key: str) -> bool: + """Remove item from cache.""" + with self._lock: + if key in self._cache: + del self._cache[key] + del self._timestamps[key] + return True + return False + + def clear(self) -> None: + """Clear all cached items.""" + with self._lock: + self._cache.clear() + self._timestamps.clear() + + def size(self) -> int: + """Get current cache size.""" + with self._lock: + return len(self._cache) + + def _cleanup_loop(self) -> None: + """Background cleanup loop for expired items.""" + while not self._stop_cleanup: + try: + current_time = time.time() + expired_keys = [] + + with self._lock: + for key, timestamp in self._timestamps.items(): + if current_time - timestamp >= self.ttl_seconds: + expired_keys.append(key) + + for key in expired_keys: + if key in self._cache: + del self._cache[key] + if key in self._timestamps: + del self._timestamps[key] + + if expired_keys: + logger.debug(f"Cleaned up {len(expired_keys)} expired cache items") + + # Sleep for cleanup interval (1/4 of TTL) + time.sleep(max(60, self.ttl_seconds // 4)) + + except Exception as e: + logger.error(f"Error in cache cleanup loop: {e}") + time.sleep(60) + + def shutdown(self) -> None: + """Shutdown the cache and cleanup thread.""" + self._stop_cleanup = True + if self._cleanup_thread.is_alive(): + self._cleanup_thread.join(timeout=5) + + +class SimpleRAG: + """Simple Retrieval-Augmented Generation for memory retrieval.""" + + def __init__(self, max_context_memories: int = 5): + """ + Initialize simple RAG system. + + Args: + max_context_memories: Maximum memories to include in context + """ + self.max_context_memories = max_context_memories + + def retrieve_relevant_memories(self, query: str, memories: List[EmotionalMemory], + current_context: Dict[str, Any]) -> List[EmotionalMemory]: + """ + Retrieve relevant memories for a given query using simple similarity. + + Args: + query: User input or context query + memories: Available memories to search + current_context: Current pet state and context + + Returns: + List of relevant memories sorted by relevance + """ + if not memories: + return [] + + query_lower = query.lower() + scored_memories = [] + + for memory in memories: + score = self._calculate_relevance_score(memory, query_lower, current_context) + if score > 0: + scored_memories.append((memory, score)) + + # Sort by score (descending) and take top memories + scored_memories.sort(key=lambda x: x[1], reverse=True) + return [memory for memory, score in scored_memories[:self.max_context_memories]] + + def _calculate_relevance_score(self, memory: EmotionalMemory, query_lower: str, + context: Dict[str, Any]) -> float: + """Calculate relevance score for a memory.""" + score = 0.0 + + # Text similarity (simple keyword matching) + memory_content_lower = memory.content.lower() + query_words = set(query_lower.split()) + memory_words = set(memory_content_lower.split()) + + # Keyword overlap + common_words = query_words.intersection(memory_words) + if common_words: + score += len(common_words) / len(query_words) * 0.4 + + # Tag matching + query_tags = self._extract_tags_from_query(query_lower) + tag_overlap = query_tags.intersection(memory.tags) + if tag_overlap: + score += len(tag_overlap) / max(len(query_tags), 1) * 0.3 + + # Recency boost (more recent memories are more relevant) + hours_ago = (datetime.now() - memory.timestamp).total_seconds() / 3600 + recency_score = max(0, 1 - (hours_ago / 168)) # Decay over a week + score += recency_score * 0.2 + + # Importance boost + score += memory.importance * 0.1 + + # Emotional relevance (memories with strong emotions are more memorable) + emotional_strength = abs(memory.emotional_value) + score += emotional_strength * 0.1 + + # Access frequency (frequently accessed memories are more relevant) + access_boost = min(memory.access_count / 10, 1.0) * 0.1 + score += access_boost + + return score + + def _extract_tags_from_query(self, query_lower: str) -> Set[str]: + """Extract potential tags from query text.""" + # Simple tag extraction based on common patterns + tags = set() + + # Action-based tags + if any(word in query_lower for word in ['eat', 'food', 'hungry', 'feed']): + tags.add('eating') + if any(word in query_lower for word in ['sleep', 'rest', 'tired', 'nap']): + tags.add('sleeping') + if any(word in query_lower for word in ['train', 'exercise', 'workout']): + tags.add('training') + if any(word in query_lower for word in ['play', 'fun', 'game']): + tags.add('playing') + if any(word in query_lower for word in ['good', 'praise', 'well done']): + tags.add('praise') + if any(word in query_lower for word in ['bad', 'scold', 'no']): + tags.add('discipline') + + # Emotional tags + if any(word in query_lower for word in ['happy', 'joy', 'excited']): + tags.add('positive') + if any(word in query_lower for word in ['sad', 'upset', 'angry']): + tags.add('negative') + + return tags + + +class EnhancedMemoryManager: + """Enhanced memory manager with emotional values, caching, and RAG capabilities.""" + + def __init__(self, storage_manager: StorageManager, cache_size: int = 1000, + max_memories_per_pet: int = 500): + """ + Initialize enhanced memory manager. + + Args: + storage_manager: Storage manager for persistence + cache_size: Size of memory cache + max_memories_per_pet: Maximum memories to keep per pet + """ + self.storage_manager = storage_manager + self.max_memories_per_pet = max_memories_per_pet + + # Memory cache for frequently accessed data + self.memory_cache = MemoryCache(max_size=cache_size) + + # Pet memories storage (pet_id -> List[EmotionalMemory]) + self.pet_memories: Dict[str, List[EmotionalMemory]] = defaultdict(list) + + # RAG system for memory retrieval + self.rag_system = SimpleRAG() + + # Memory statistics + self.memory_stats = defaultdict(lambda: { + 'total_memories': 0, + 'happy_memories': 0, + 'stressful_memories': 0, + 'neutral_memories': 0, + 'last_cleanup': datetime.now() + }) + + # Background cleanup + self._cleanup_thread = None + self._stop_cleanup = False + + logger.info("Enhanced memory manager initialized") + + def add_memory(self, pet_id: str, content: str, memory_type: str, + emotional_value: float = 0.0, importance: float = 0.5, + tags: Optional[Set[str]] = None, + related_attributes: Optional[Dict[str, int]] = None) -> str: + """ + Add a new memory for a pet. + + Args: + pet_id: Pet identifier + content: Memory content + memory_type: Type of memory ('interaction', 'action', 'event', 'detail') + emotional_value: Emotional value (-1.0 to 1.0) + importance: Importance level (0.0 to 1.0) + tags: Optional tags for categorization + related_attributes: Optional attribute changes related to this memory + + Returns: + Memory ID + """ + memory_id = f"{pet_id}_{int(time.time() * 1000)}" + + memory = EmotionalMemory( + id=memory_id, + timestamp=datetime.now(), + content=content, + memory_type=memory_type, + emotional_value=max(-1.0, min(1.0, emotional_value)), + importance=max(0.0, min(1.0, importance)), + tags=tags or set(), + related_attributes=related_attributes or {} + ) + + # Add to pet memories + self.pet_memories[pet_id].append(memory) + + # Update statistics + self._update_memory_stats(pet_id, memory) + + # Manage memory size + self._manage_memory_size(pet_id) + + # Cache the memory + self.memory_cache.put(f"memory_{memory_id}", memory) + + logger.debug(f"Added memory {memory_id} for pet {pet_id}: {content[:50]}...") + return memory_id + + def add_interaction_memory(self, pet: DigiPal, interaction: Interaction) -> str: + """ + Add memory from an interaction with emotional context. + + Args: + pet: DigiPal instance + interaction: Interaction to convert to memory + + Returns: + Memory ID + """ + # Calculate emotional value based on interaction + emotional_value = self._calculate_emotional_value(interaction, pet) + + # Calculate importance based on success and attribute changes + importance = self._calculate_importance(interaction) + + # Extract tags from interaction + tags = self._extract_interaction_tags(interaction) + + # Create memory content + content = f"User said: '{interaction.user_input}' - I responded: '{interaction.pet_response}'" + + return self.add_memory( + pet_id=pet.id, + content=content, + memory_type='interaction', + emotional_value=emotional_value, + importance=importance, + tags=tags, + related_attributes=interaction.attribute_changes + ) + + def add_action_memory(self, pet_id: str, action: str, result: str, + attribute_changes: Dict[str, int]) -> str: + """ + Add memory from a care action. + + Args: + pet_id: Pet identifier + action: Action performed + result: Result of the action + attribute_changes: Attribute changes from action + + Returns: + Memory ID + """ + # Calculate emotional value based on attribute changes + emotional_value = 0.0 + if 'happiness' in attribute_changes: + emotional_value += attribute_changes['happiness'] / 100.0 + if 'energy' in attribute_changes: + emotional_value += attribute_changes['energy'] / 200.0 + + # Clamp emotional value + emotional_value = max(-1.0, min(1.0, emotional_value)) + + # Calculate importance based on magnitude of changes + importance = min(1.0, sum(abs(v) for v in attribute_changes.values()) / 100.0) + + # Extract tags + tags = {action, 'action'} + if emotional_value > 0.3: + tags.add('positive') + elif emotional_value < -0.3: + tags.add('negative') + + content = f"Action: {action} - Result: {result}" + + return self.add_memory( + pet_id=pet_id, + content=content, + memory_type='action', + emotional_value=emotional_value, + importance=importance, + tags=tags, + related_attributes=attribute_changes + ) + + def add_life_event_memory(self, pet_id: str, event: str, emotional_impact: float = 0.0) -> str: + """ + Add memory for significant life events (evolution, achievements, etc.). + + Args: + pet_id: Pet identifier + event: Event description + emotional_impact: Emotional impact of the event + + Returns: + Memory ID + """ + return self.add_memory( + pet_id=pet_id, + content=event, + memory_type='event', + emotional_value=emotional_impact, + importance=0.9, # Life events are usually important + tags={'life_event', 'milestone'} + ) + + def get_relevant_memories(self, pet_id: str, query: str, + current_context: Optional[Dict[str, Any]] = None) -> List[EmotionalMemory]: + """ + Get relevant memories for a query using RAG. + + Args: + pet_id: Pet identifier + query: Query text + current_context: Current pet state context + + Returns: + List of relevant memories + """ + memories = self.pet_memories.get(pet_id, []) + if not memories: + return [] + + context = current_context or {} + relevant_memories = self.rag_system.retrieve_relevant_memories(query, memories, context) + + # Update access counts for retrieved memories + for memory in relevant_memories: + memory.access_count += 1 + memory.last_accessed = datetime.now() + + return relevant_memories + + def get_memory_context_for_llm(self, pet_id: str, query: str, + current_context: Optional[Dict[str, Any]] = None) -> str: + """ + Get formatted memory context for LLM input. + + Args: + pet_id: Pet identifier + query: Current query + current_context: Current pet state + + Returns: + Formatted memory context string + """ + relevant_memories = self.get_relevant_memories(pet_id, query, current_context) + + if not relevant_memories: + return "" + + context_parts = ["Recent relevant memories:"] + + for memory in relevant_memories: + # Format memory with emotional context + emotional_indicator = "" + if memory.emotional_value > 0.3: + emotional_indicator = " (happy memory)" + elif memory.emotional_value < -0.3: + emotional_indicator = " (stressful memory)" + + time_ago = self._format_time_ago(memory.timestamp) + context_parts.append(f"- {time_ago}: {memory.content}{emotional_indicator}") + + return "\n".join(context_parts) + + def get_emotional_state_summary(self, pet_id: str) -> Dict[str, Any]: + """ + Get emotional state summary based on recent memories. + + Args: + pet_id: Pet identifier + + Returns: + Emotional state summary + """ + memories = self.pet_memories.get(pet_id, []) + if not memories: + return {'overall_mood': 'neutral', 'recent_trend': 'stable'} + + # Analyze recent memories (last 24 hours) + recent_cutoff = datetime.now() - timedelta(hours=24) + recent_memories = [m for m in memories if m.timestamp > recent_cutoff] + + if not recent_memories: + recent_memories = memories[-10:] # Use last 10 if no recent ones + + # Calculate emotional metrics + total_emotional_value = sum(m.emotional_value for m in recent_memories) + avg_emotional_value = total_emotional_value / len(recent_memories) + + positive_memories = sum(1 for m in recent_memories if m.emotional_value > 0.1) + negative_memories = sum(1 for m in recent_memories if m.emotional_value < -0.1) + + # Determine overall mood + if avg_emotional_value > 0.3: + overall_mood = 'very_happy' + elif avg_emotional_value > 0.1: + overall_mood = 'happy' + elif avg_emotional_value < -0.3: + overall_mood = 'stressed' + elif avg_emotional_value < -0.1: + overall_mood = 'unhappy' + else: + overall_mood = 'neutral' + + # Determine trend + if len(recent_memories) >= 5: + first_half = recent_memories[:len(recent_memories)//2] + second_half = recent_memories[len(recent_memories)//2:] + + first_avg = sum(m.emotional_value for m in first_half) / len(first_half) + second_avg = sum(m.emotional_value for m in second_half) / len(second_half) + + if second_avg - first_avg > 0.2: + trend = 'improving' + elif first_avg - second_avg > 0.2: + trend = 'declining' + else: + trend = 'stable' + else: + trend = 'stable' + + return { + 'overall_mood': overall_mood, + 'recent_trend': trend, + 'avg_emotional_value': avg_emotional_value, + 'positive_memories': positive_memories, + 'negative_memories': negative_memories, + 'total_recent_memories': len(recent_memories) + } + + def cleanup_old_memories(self, pet_id: str, max_age_days: int = 30) -> int: + """ + Clean up old memories while preserving important ones. + + Args: + pet_id: Pet identifier + max_age_days: Maximum age for memories in days + + Returns: + Number of memories cleaned up + """ + memories = self.pet_memories.get(pet_id, []) + if not memories: + return 0 + + cutoff_date = datetime.now() - timedelta(days=max_age_days) + + # Separate memories into keep and remove lists + keep_memories = [] + removed_count = 0 + + for memory in memories: + # Always keep important memories or recent ones + if (memory.importance > 0.7 or + memory.timestamp > cutoff_date or + abs(memory.emotional_value) > 0.5): + keep_memories.append(memory) + else: + removed_count += 1 + + # Update memories list + self.pet_memories[pet_id] = keep_memories + + # Update statistics + self.memory_stats[pet_id]['last_cleanup'] = datetime.now() + + if removed_count > 0: + logger.info(f"Cleaned up {removed_count} old memories for pet {pet_id}") + + return removed_count + + def get_memory_statistics(self, pet_id: str) -> Dict[str, Any]: + """Get memory statistics for a pet.""" + memories = self.pet_memories.get(pet_id, []) + stats = self.memory_stats[pet_id].copy() + + # Update current counts + stats['total_memories'] = len(memories) + stats['happy_memories'] = sum(1 for m in memories if m.emotional_value > 0.1) + stats['stressful_memories'] = sum(1 for m in memories if m.emotional_value < -0.1) + stats['neutral_memories'] = stats['total_memories'] - stats['happy_memories'] - stats['stressful_memories'] + + # Memory type breakdown + type_counts = defaultdict(int) + for memory in memories: + type_counts[memory.memory_type] += 1 + stats['memory_types'] = dict(type_counts) + + # Recent activity + recent_cutoff = datetime.now() - timedelta(hours=24) + stats['recent_memories'] = sum(1 for m in memories if m.timestamp > recent_cutoff) + + return stats + + def _calculate_emotional_value(self, interaction: Interaction, pet: DigiPal) -> float: + """Calculate emotional value for an interaction.""" + emotional_value = 0.0 + + # Base emotional value from success/failure + if interaction.success: + emotional_value += 0.2 + else: + emotional_value -= 0.3 + + # Adjust based on interaction result + if interaction.result == InteractionResult.SUCCESS: + emotional_value += 0.1 + elif interaction.result == InteractionResult.FAILURE: + emotional_value -= 0.2 + elif interaction.result == InteractionResult.STAGE_INAPPROPRIATE: + emotional_value -= 0.1 + + # Adjust based on command type + command = interaction.interpreted_command.lower() + if command in ['good', 'praise']: + emotional_value += 0.4 + elif command in ['bad', 'scold']: + emotional_value -= 0.3 + elif command in ['play', 'fun']: + emotional_value += 0.2 + elif command in ['eat', 'food'] and pet.energy < 50: + emotional_value += 0.3 # Food when hungry is very positive + + # Adjust based on attribute changes + if 'happiness' in interaction.attribute_changes: + emotional_value += interaction.attribute_changes['happiness'] / 100.0 + + return max(-1.0, min(1.0, emotional_value)) + + def _calculate_importance(self, interaction: Interaction) -> float: + """Calculate importance level for an interaction.""" + importance = 0.5 # Base importance + + # Increase importance for successful interactions + if interaction.success: + importance += 0.2 + + # Increase importance based on attribute changes + total_change = sum(abs(v) for v in interaction.attribute_changes.values()) + importance += min(0.3, total_change / 100.0) + + # Special commands are more important + special_commands = ['evolution', 'death', 'birth', 'milestone'] + if any(cmd in interaction.interpreted_command.lower() for cmd in special_commands): + importance += 0.3 + + return max(0.0, min(1.0, importance)) + + def _extract_interaction_tags(self, interaction: Interaction) -> Set[str]: + """Extract tags from an interaction.""" + tags = {'interaction'} + + command = interaction.interpreted_command.lower() + + # Command-based tags + if command in ['eat', 'food', 'feed']: + tags.add('eating') + elif command in ['sleep', 'rest']: + tags.add('sleeping') + elif command in ['train', 'exercise']: + tags.add('training') + elif command in ['play', 'fun']: + tags.add('playing') + elif command in ['good', 'praise']: + tags.add('praise') + elif command in ['bad', 'scold']: + tags.add('discipline') + + # Success/failure tags + if interaction.success: + tags.add('successful') + else: + tags.add('failed') + + return tags + + def _update_memory_stats(self, pet_id: str, memory: EmotionalMemory) -> None: + """Update memory statistics.""" + stats = self.memory_stats[pet_id] + stats['total_memories'] += 1 + + if memory.emotional_value > 0.1: + stats['happy_memories'] += 1 + elif memory.emotional_value < -0.1: + stats['stressful_memories'] += 1 + else: + stats['neutral_memories'] += 1 + + def _manage_memory_size(self, pet_id: str) -> None: + """Manage memory size to prevent unlimited growth.""" + memories = self.pet_memories[pet_id] + + if len(memories) > self.max_memories_per_pet: + # Sort by importance and recency, keep the most important/recent + memories.sort(key=lambda m: (m.importance, m.timestamp.timestamp()), reverse=True) + + # Keep top memories + self.pet_memories[pet_id] = memories[:self.max_memories_per_pet] + + removed_count = len(memories) - self.max_memories_per_pet + logger.debug(f"Removed {removed_count} old memories for pet {pet_id}") + + def _format_time_ago(self, timestamp: datetime) -> str: + """Format timestamp as 'time ago' string.""" + delta = datetime.now() - timestamp + + if delta.days > 0: + return f"{delta.days} day{'s' if delta.days != 1 else ''} ago" + elif delta.seconds > 3600: + hours = delta.seconds // 3600 + return f"{hours} hour{'s' if hours != 1 else ''} ago" + elif delta.seconds > 60: + minutes = delta.seconds // 60 + return f"{minutes} minute{'s' if minutes != 1 else ''} ago" + else: + return "just now" + + def start_background_cleanup(self) -> None: + """Start background cleanup thread.""" + if self._cleanup_thread and self._cleanup_thread.is_alive(): + return + + self._stop_cleanup = False + self._cleanup_thread = threading.Thread(target=self._background_cleanup_loop, daemon=True) + self._cleanup_thread.start() + logger.info("Started background memory cleanup") + + def stop_background_cleanup(self) -> None: + """Stop background cleanup thread.""" + self._stop_cleanup = True + if self._cleanup_thread: + self._cleanup_thread.join(timeout=5) + + def _background_cleanup_loop(self) -> None: + """Background cleanup loop.""" + while not self._stop_cleanup: + try: + # Clean up old memories for all pets + for pet_id in list(self.pet_memories.keys()): + self.cleanup_old_memories(pet_id) + + # Force garbage collection + gc.collect() + + # Sleep for 1 hour + time.sleep(3600) + + except Exception as e: + logger.error(f"Error in background memory cleanup: {e}") + time.sleep(300) # Sleep 5 minutes on error + + def shutdown(self) -> None: + """Shutdown the memory manager.""" + logger.info("Shutting down enhanced memory manager") + + # Stop background cleanup + self.stop_background_cleanup() + + # Shutdown cache + self.memory_cache.shutdown() + + # Clear memories to free memory + self.pet_memories.clear() + self.memory_stats.clear() + + logger.info("Enhanced memory manager shutdown complete") \ No newline at end of file diff --git a/digipal/core/models.py b/digipal/core/models.py new file mode 100644 index 0000000000000000000000000000000000000000..15fa6d2914605e0cce9a3a85b43117bafdf75478 --- /dev/null +++ b/digipal/core/models.py @@ -0,0 +1,315 @@ +""" +Core data models for DigiPal application. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional, Set, Any +import json +import uuid + +from .enums import EggType, LifeStage, CareActionType, AttributeType, CommandType, InteractionResult + + +@dataclass +class DigiPal: + """ + Core DigiPal model representing a digital pet with all attributes and lifecycle properties. + """ + # Identity and Basic Info + id: str = field(default_factory=lambda: str(uuid.uuid4())) + user_id: str = "" + name: str = "DigiPal" + egg_type: EggType = EggType.RED + life_stage: LifeStage = LifeStage.EGG + generation: int = 1 + + # Primary Attributes (Digimon World 1 inspired) + hp: int = 100 + mp: int = 50 + offense: int = 10 + defense: int = 10 + speed: int = 10 + brains: int = 10 + + # Secondary Attributes + discipline: int = 0 + happiness: int = 50 + weight: int = 20 + care_mistakes: int = 0 + energy: int = 100 + + # Lifecycle Management + birth_time: datetime = field(default_factory=datetime.now) + last_interaction: datetime = field(default_factory=datetime.now) + evolution_timer: float = 0.0 # Hours until next evolution check + + # Memory and Context + conversation_history: List['Interaction'] = field(default_factory=list) + learned_commands: Set[str] = field(default_factory=set) + personality_traits: Dict[str, float] = field(default_factory=dict) + + # Visual Representation + current_image_path: str = "" + image_generation_prompt: str = "" + + def __post_init__(self): + """Initialize DigiPal with egg-type specific attributes.""" + if self.life_stage == LifeStage.EGG: + self._initialize_egg_attributes() + + # Initialize basic learned commands for baby stage + if self.life_stage == LifeStage.BABY: + self.learned_commands = {"eat", "sleep", "good", "bad"} + + def _initialize_egg_attributes(self): + """Set initial attributes based on egg type.""" + base_attributes = { + EggType.RED: { + 'offense': 15, + 'defense': 8, + 'speed': 12, + 'brains': 8, + 'hp': 90, + 'mp': 40 + }, + EggType.BLUE: { + 'offense': 8, + 'defense': 15, + 'speed': 8, + 'brains': 12, + 'hp': 110, + 'mp': 60 + }, + EggType.GREEN: { + 'offense': 10, + 'defense': 12, + 'speed': 10, + 'brains': 10, + 'hp': 120, + 'mp': 50 + } + } + + if self.egg_type in base_attributes: + attrs = base_attributes[self.egg_type] + for attr, value in attrs.items(): + setattr(self, attr, value) + + def get_age_hours(self) -> float: + """Calculate age in hours since birth.""" + return (datetime.now() - self.birth_time).total_seconds() / 3600 + + def get_attribute(self, attribute: AttributeType) -> int: + """Get attribute value by type.""" + return getattr(self, attribute.value, 0) + + def set_attribute(self, attribute: AttributeType, value: int): + """Set attribute value with bounds checking.""" + # Define attribute bounds + bounds = { + AttributeType.HP: (1, 999), + AttributeType.MP: (0, 999), + AttributeType.OFFENSE: (0, 999), + AttributeType.DEFENSE: (0, 999), + AttributeType.SPEED: (0, 999), + AttributeType.BRAINS: (0, 999), + AttributeType.DISCIPLINE: (0, 100), + AttributeType.HAPPINESS: (0, 100), + AttributeType.WEIGHT: (1, 99), + AttributeType.CARE_MISTAKES: (0, 999), + AttributeType.ENERGY: (0, 100) + } + + min_val, max_val = bounds.get(attribute, (0, 999)) + clamped_value = max(min_val, min(max_val, value)) + setattr(self, attribute.value, clamped_value) + + def modify_attribute(self, attribute: AttributeType, change: int): + """Modify attribute by a delta amount.""" + current_value = self.get_attribute(attribute) + self.set_attribute(attribute, current_value + change) + + def can_understand_command(self, command: str) -> bool: + """Check if DigiPal can understand a command based on life stage.""" + stage_commands = { + LifeStage.EGG: set(), + LifeStage.BABY: {"eat", "sleep", "good", "bad"}, + LifeStage.CHILD: {"eat", "sleep", "good", "bad", "play", "train"}, + LifeStage.TEEN: {"eat", "sleep", "good", "bad", "play", "train", "status", "talk"}, + LifeStage.YOUNG_ADULT: {"eat", "sleep", "good", "bad", "play", "train", "status", "talk", "battle"}, + LifeStage.ADULT: {"eat", "sleep", "good", "bad", "play", "train", "status", "talk", "battle", "teach"}, + LifeStage.ELDERLY: {"eat", "sleep", "good", "bad", "play", "train", "status", "talk", "battle", "teach", "wisdom"} + } + + available_commands = stage_commands.get(self.life_stage, set()) + return command.lower() in available_commands or command.lower() in self.learned_commands + + def to_dict(self) -> Dict[str, Any]: + """Convert DigiPal to dictionary for serialization.""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'name': self.name, + 'egg_type': self.egg_type.value, + 'life_stage': self.life_stage.value, + 'generation': self.generation, + 'hp': self.hp, + 'mp': self.mp, + 'offense': self.offense, + 'defense': self.defense, + 'speed': self.speed, + 'brains': self.brains, + 'discipline': self.discipline, + 'happiness': self.happiness, + 'weight': self.weight, + 'care_mistakes': self.care_mistakes, + 'energy': self.energy, + 'birth_time': self.birth_time.isoformat(), + 'last_interaction': self.last_interaction.isoformat(), + 'evolution_timer': self.evolution_timer, + 'conversation_history': [interaction.to_dict() for interaction in self.conversation_history], + 'learned_commands': list(self.learned_commands), + 'personality_traits': self.personality_traits, + 'current_image_path': self.current_image_path, + 'image_generation_prompt': self.image_generation_prompt + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'DigiPal': + """Create DigiPal from dictionary.""" + # Convert string enums back to enum objects + data['egg_type'] = EggType(data['egg_type']) + data['life_stage'] = LifeStage(data['life_stage']) + + # Convert ISO strings back to datetime objects + data['birth_time'] = datetime.fromisoformat(data['birth_time']) + data['last_interaction'] = datetime.fromisoformat(data['last_interaction']) + + # Convert conversation history + data['conversation_history'] = [ + Interaction.from_dict(interaction_data) + for interaction_data in data.get('conversation_history', []) + ] + + # Convert learned commands to set + data['learned_commands'] = set(data.get('learned_commands', [])) + + return cls(**data) + + +@dataclass +class Interaction: + """Represents a single interaction between user and DigiPal.""" + timestamp: datetime = field(default_factory=datetime.now) + user_input: str = "" + interpreted_command: str = "" + pet_response: str = "" + attribute_changes: Dict[str, int] = field(default_factory=dict) + success: bool = True + result: InteractionResult = InteractionResult.SUCCESS + + def to_dict(self) -> Dict[str, Any]: + """Convert Interaction to dictionary.""" + return { + 'timestamp': self.timestamp.isoformat(), + 'user_input': self.user_input, + 'interpreted_command': self.interpreted_command, + 'pet_response': self.pet_response, + 'attribute_changes': self.attribute_changes, + 'success': self.success, + 'result': self.result.value + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Interaction': + """Create Interaction from dictionary.""" + data['timestamp'] = datetime.fromisoformat(data['timestamp']) + data['result'] = InteractionResult(data['result']) + return cls(**data) + + +@dataclass +class Command: + """Represents a parsed command from user input.""" + action: str = "" + parameters: Dict[str, Any] = field(default_factory=dict) + stage_appropriate: bool = True + energy_required: int = 0 + command_type: CommandType = CommandType.UNKNOWN + + def to_dict(self) -> Dict[str, Any]: + """Convert Command to dictionary.""" + return { + 'action': self.action, + 'parameters': self.parameters, + 'stage_appropriate': self.stage_appropriate, + 'energy_required': self.energy_required, + 'command_type': self.command_type.value + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Command': + """Create Command from dictionary.""" + data['command_type'] = CommandType(data['command_type']) + return cls(**data) + + +@dataclass +class AttributeModifier: + """Represents a modification to DigiPal attributes.""" + attribute: AttributeType + change: int + conditions: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert AttributeModifier to dictionary.""" + return { + 'attribute': self.attribute.value, + 'change': self.change, + 'conditions': self.conditions + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'AttributeModifier': + """Create AttributeModifier from dictionary.""" + data['attribute'] = AttributeType(data['attribute']) + return cls(**data) + + +@dataclass +class CareAction: + """Represents a care action that can be performed on DigiPal.""" + name: str + action_type: CareActionType + energy_cost: int + happiness_change: int + attribute_modifiers: List[AttributeModifier] = field(default_factory=list) + success_conditions: List[str] = field(default_factory=list) + failure_effects: List[AttributeModifier] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert CareAction to dictionary.""" + return { + 'name': self.name, + 'action_type': self.action_type.value, + 'energy_cost': self.energy_cost, + 'happiness_change': self.happiness_change, + 'attribute_modifiers': [mod.to_dict() for mod in self.attribute_modifiers], + 'success_conditions': self.success_conditions, + 'failure_effects': [mod.to_dict() for mod in self.failure_effects] + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'CareAction': + """Create CareAction from dictionary.""" + data['action_type'] = CareActionType(data['action_type']) + data['attribute_modifiers'] = [ + AttributeModifier.from_dict(mod_data) + for mod_data in data.get('attribute_modifiers', []) + ] + data['failure_effects'] = [ + AttributeModifier.from_dict(mod_data) + for mod_data in data.get('failure_effects', []) + ] + return cls(**data) \ No newline at end of file diff --git a/digipal/core/performance_optimizer.py b/digipal/core/performance_optimizer.py new file mode 100644 index 0000000000000000000000000000000000000000..95377a50498f318c84f3fe77e170c14357b9bd1b --- /dev/null +++ b/digipal/core/performance_optimizer.py @@ -0,0 +1,934 @@ +""" +Performance optimization module for DigiPal application. + +This module provides: +- Lazy model loading with optimization +- Background task system for attribute decay and evolution checks +- Resource cleanup and garbage collection +- Database query optimization +- Memory usage monitoring +""" + +import logging +import time +import threading +import gc +import psutil +import weakref +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any, Callable, Tuple +from dataclasses import dataclass +from collections import defaultdict +import asyncio +import sqlite3 + +import torch +from transformers import AutoModelForCausalLM, AutoTokenizer + +from .models import DigiPal +from .enums import LifeStage +from ..storage.storage_manager import StorageManager + +logger = logging.getLogger(__name__) + + +@dataclass +class ModelLoadingConfig: + """Configuration for model loading optimization.""" + lazy_loading: bool = True + quantization: bool = True + model_cache_size: int = 2 # Maximum models to keep in memory + unload_after_idle_minutes: int = 30 + preload_on_startup: bool = False + use_cpu_offload: bool = True + + +@dataclass +class BackgroundTaskConfig: + """Configuration for background tasks.""" + attribute_decay_interval: int = 300 # 5 minutes + evolution_check_interval: int = 600 # 10 minutes + memory_cleanup_interval: int = 1800 # 30 minutes + database_optimization_interval: int = 3600 # 1 hour + performance_monitoring_interval: int = 60 # 1 minute + + +@dataclass +class PerformanceMetrics: + """Performance metrics tracking.""" + timestamp: datetime + cpu_usage: float + memory_usage: float + gpu_memory_usage: float + active_pets: int + cached_models: int + database_connections: int + response_time_avg: float + + +class LazyModelLoader: + """Lazy loading system for AI models with optimization.""" + + def __init__(self, config: ModelLoadingConfig): + """Initialize lazy model loader.""" + self.config = config + self.loaded_models: Dict[str, Any] = {} + self.model_last_used: Dict[str, datetime] = {} + self.model_loading_locks: Dict[str, threading.Lock] = defaultdict(threading.Lock) + self._cleanup_thread = None + self._stop_cleanup = False + + logger.info("Lazy model loader initialized") + + def get_language_model(self, model_name: str = "Qwen/Qwen3-0.6B") -> Tuple[Any, Any]: + """ + Get language model with lazy loading. + + Args: + model_name: Model identifier + + Returns: + Tuple of (model, tokenizer) + """ + model_key = f"language_{model_name}" + + with self.model_loading_locks[model_key]: + # Check if model is already loaded + if model_key in self.loaded_models: + self.model_last_used[model_key] = datetime.now() + model_data = self.loaded_models[model_key] + return model_data['model'], model_data['tokenizer'] + + # Load model if not in cache + if self.config.lazy_loading: + logger.info(f"Lazy loading language model: {model_name}") + model, tokenizer = self._load_language_model(model_name) + + # Cache the model + self.loaded_models[model_key] = { + 'model': model, + 'tokenizer': tokenizer, + 'type': 'language', + 'size_mb': self._estimate_model_size(model) + } + self.model_last_used[model_key] = datetime.now() + + # Manage cache size + self._manage_model_cache() + + return model, tokenizer + else: + # Direct loading without caching + return self._load_language_model(model_name) + + def get_speech_model(self, model_name: str = "kyutai/stt-2.6b-en_fr-trfs") -> Tuple[Any, Any]: + """ + Get speech model with lazy loading. + + Args: + model_name: Model identifier + + Returns: + Tuple of (model, processor) + """ + model_key = f"speech_{model_name}" + + with self.model_loading_locks[model_key]: + # Check if model is already loaded + if model_key in self.loaded_models: + self.model_last_used[model_key] = datetime.now() + model_data = self.loaded_models[model_key] + return model_data['model'], model_data['processor'] + + # Load model if not in cache + if self.config.lazy_loading: + logger.info(f"Lazy loading speech model: {model_name}") + model, processor = self._load_speech_model(model_name) + + # Cache the model + self.loaded_models[model_key] = { + 'model': model, + 'processor': processor, + 'type': 'speech', + 'size_mb': self._estimate_model_size(model) + } + self.model_last_used[model_key] = datetime.now() + + # Manage cache size + self._manage_model_cache() + + return model, processor + else: + # Direct loading without caching + return self._load_speech_model(model_name) + + def _load_language_model(self, model_name: str) -> Tuple[Any, Any]: + """Load language model with optimization.""" + try: + # Load tokenizer + tokenizer = AutoTokenizer.from_pretrained(model_name) + + # Configure model loading + model_kwargs = { + "torch_dtype": "auto", + "device_map": "auto" if torch.cuda.is_available() else None + } + + # Add quantization if enabled + if self.config.quantization and torch.cuda.is_available(): + from transformers import BitsAndBytesConfig + quantization_config = BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_compute_dtype=torch.float16, + bnb_4bit_use_double_quant=True, + bnb_4bit_quant_type="nf4" + ) + model_kwargs["quantization_config"] = quantization_config + + # Load model + model = AutoModelForCausalLM.from_pretrained(model_name, **model_kwargs) + + # Enable CPU offload if configured + if self.config.use_cpu_offload and hasattr(model, 'enable_model_cpu_offload'): + model.enable_model_cpu_offload() + + logger.info(f"Successfully loaded language model: {model_name}") + return model, tokenizer + + except Exception as e: + logger.error(f"Failed to load language model {model_name}: {e}") + raise + + def _load_speech_model(self, model_name: str) -> Tuple[Any, Any]: + """Load speech model with optimization.""" + try: + from transformers import KyutaiSpeechToTextProcessor, KyutaiSpeechToTextForConditionalGeneration + + device = "cuda" if torch.cuda.is_available() else "cpu" + + processor = KyutaiSpeechToTextProcessor.from_pretrained(model_name) + model = KyutaiSpeechToTextForConditionalGeneration.from_pretrained( + model_name, + device_map=device, + torch_dtype="auto" + ) + + logger.info(f"Successfully loaded speech model: {model_name}") + return model, processor + + except Exception as e: + logger.error(f"Failed to load speech model {model_name}: {e}") + raise + + def _estimate_model_size(self, model) -> float: + """Estimate model size in MB.""" + try: + if hasattr(model, 'get_memory_footprint'): + return model.get_memory_footprint() / (1024 * 1024) + else: + # Rough estimation based on parameters + total_params = sum(p.numel() for p in model.parameters()) + # Assume 4 bytes per parameter (float32) or 2 bytes (float16) + bytes_per_param = 2 if self.config.quantization else 4 + return (total_params * bytes_per_param) / (1024 * 1024) + except: + return 1000.0 # Default estimate + + def _manage_model_cache(self) -> None: + """Manage model cache size by unloading least recently used models.""" + if len(self.loaded_models) <= self.config.model_cache_size: + return + + # Sort models by last used time + models_by_usage = sorted( + self.loaded_models.items(), + key=lambda x: self.model_last_used.get(x[0], datetime.min) + ) + + # Unload oldest models + models_to_unload = models_by_usage[:-self.config.model_cache_size] + + for model_key, model_data in models_to_unload: + self._unload_model(model_key) + logger.info(f"Unloaded model {model_key} due to cache size limit") + + def _unload_model(self, model_key: str) -> None: + """Unload a specific model from memory.""" + if model_key in self.loaded_models: + model_data = self.loaded_models[model_key] + + # Clear model references + if 'model' in model_data: + del model_data['model'] + if 'tokenizer' in model_data: + del model_data['tokenizer'] + if 'processor' in model_data: + del model_data['processor'] + + # Remove from cache + del self.loaded_models[model_key] + if model_key in self.model_last_used: + del self.model_last_used[model_key] + + # Force garbage collection + gc.collect() + + # Clear CUDA cache if available + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + def start_cleanup_thread(self) -> None: + """Start background cleanup thread for idle models.""" + if self._cleanup_thread and self._cleanup_thread.is_alive(): + return + + self._stop_cleanup = False + self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) + self._cleanup_thread.start() + logger.info("Started model cleanup thread") + + def stop_cleanup_thread(self) -> None: + """Stop background cleanup thread.""" + self._stop_cleanup = True + if self._cleanup_thread: + self._cleanup_thread.join(timeout=5) + + def _cleanup_loop(self) -> None: + """Background cleanup loop for idle models.""" + while not self._stop_cleanup: + try: + current_time = datetime.now() + idle_threshold = timedelta(minutes=self.config.unload_after_idle_minutes) + + models_to_unload = [] + for model_key, last_used in self.model_last_used.items(): + if current_time - last_used > idle_threshold: + models_to_unload.append(model_key) + + for model_key in models_to_unload: + self._unload_model(model_key) + logger.info(f"Unloaded idle model: {model_key}") + + # Sleep for cleanup interval + time.sleep(300) # 5 minutes + + except Exception as e: + logger.error(f"Error in model cleanup loop: {e}") + time.sleep(60) + + def get_cache_info(self) -> Dict[str, Any]: + """Get information about model cache.""" + total_size_mb = sum( + model_data.get('size_mb', 0) + for model_data in self.loaded_models.values() + ) + + return { + 'loaded_models': len(self.loaded_models), + 'cache_limit': self.config.model_cache_size, + 'total_size_mb': total_size_mb, + 'models': { + key: { + 'type': data.get('type', 'unknown'), + 'size_mb': data.get('size_mb', 0), + 'last_used': self.model_last_used.get(key, datetime.min).isoformat() + } + for key, data in self.loaded_models.items() + } + } + + def shutdown(self) -> None: + """Shutdown the model loader and cleanup resources.""" + logger.info("Shutting down lazy model loader") + + # Stop cleanup thread + self.stop_cleanup_thread() + + # Unload all models + for model_key in list(self.loaded_models.keys()): + self._unload_model(model_key) + + logger.info("Lazy model loader shutdown complete") + + +class BackgroundTaskManager: + """Manages background tasks for attribute decay, evolution checks, etc.""" + + def __init__(self, config: BackgroundTaskConfig, storage_manager: StorageManager): + """Initialize background task manager.""" + self.config = config + self.storage_manager = storage_manager + self.active_tasks: Dict[str, threading.Thread] = {} + self.task_stop_events: Dict[str, threading.Event] = {} + self.task_callbacks: Dict[str, Callable] = {} + + # Performance tracking + self.task_performance: Dict[str, List[float]] = defaultdict(list) + + logger.info("Background task manager initialized") + + def register_task(self, task_name: str, callback: Callable, interval_seconds: int) -> None: + """ + Register a background task. + + Args: + task_name: Unique task name + callback: Function to call periodically + interval_seconds: Interval between calls in seconds + """ + self.task_callbacks[task_name] = callback + self.task_stop_events[task_name] = threading.Event() + + # Create and start task thread + task_thread = threading.Thread( + target=self._task_loop, + args=(task_name, callback, interval_seconds), + daemon=True, + name=f"BackgroundTask-{task_name}" + ) + + self.active_tasks[task_name] = task_thread + task_thread.start() + + logger.info(f"Registered background task: {task_name} (interval: {interval_seconds}s)") + + def _task_loop(self, task_name: str, callback: Callable, interval_seconds: int) -> None: + """Background task execution loop.""" + stop_event = self.task_stop_events[task_name] + + while not stop_event.is_set(): + try: + start_time = time.time() + + # Execute task callback + callback() + + # Track performance + execution_time = time.time() - start_time + self.task_performance[task_name].append(execution_time) + + # Keep only recent performance data + if len(self.task_performance[task_name]) > 100: + self.task_performance[task_name] = self.task_performance[task_name][-50:] + + # Wait for next execution + stop_event.wait(timeout=interval_seconds) + + except Exception as e: + logger.error(f"Error in background task {task_name}: {e}") + # Wait before retrying + stop_event.wait(timeout=min(60, interval_seconds)) + + def stop_task(self, task_name: str) -> None: + """Stop a specific background task.""" + if task_name in self.task_stop_events: + self.task_stop_events[task_name].set() + + if task_name in self.active_tasks: + thread = self.active_tasks[task_name] + thread.join(timeout=5) + del self.active_tasks[task_name] + + logger.info(f"Stopped background task: {task_name}") + + def stop_all_tasks(self) -> None: + """Stop all background tasks.""" + logger.info("Stopping all background tasks") + + # Signal all tasks to stop + for stop_event in self.task_stop_events.values(): + stop_event.set() + + # Wait for all threads to finish + for task_name, thread in self.active_tasks.items(): + thread.join(timeout=5) + logger.debug(f"Stopped task: {task_name}") + + # Clear task data + self.active_tasks.clear() + self.task_stop_events.clear() + + logger.info("All background tasks stopped") + + def get_task_performance(self) -> Dict[str, Dict[str, float]]: + """Get performance statistics for all tasks.""" + performance_stats = {} + + for task_name, execution_times in self.task_performance.items(): + if execution_times: + performance_stats[task_name] = { + 'avg_execution_time': sum(execution_times) / len(execution_times), + 'max_execution_time': max(execution_times), + 'min_execution_time': min(execution_times), + 'total_executions': len(execution_times) + } + else: + performance_stats[task_name] = { + 'avg_execution_time': 0.0, + 'max_execution_time': 0.0, + 'min_execution_time': 0.0, + 'total_executions': 0 + } + + return performance_stats + + +class DatabaseOptimizer: + """Database query optimization and maintenance.""" + + def __init__(self, storage_manager: StorageManager): + """Initialize database optimizer.""" + self.storage_manager = storage_manager + self.query_cache: Dict[str, Tuple[Any, datetime]] = {} + self.cache_ttl_seconds = 300 # 5 minutes + + logger.info("Database optimizer initialized") + + def optimize_database(self) -> Dict[str, Any]: + """Perform database optimization tasks.""" + results = {} + + try: + # Analyze database + results['analyze'] = self._analyze_database() + + # Vacuum database + results['vacuum'] = self._vacuum_database() + + # Update statistics + results['statistics'] = self._update_statistics() + + # Check indexes + results['indexes'] = self._check_indexes() + + logger.info("Database optimization completed") + + except Exception as e: + logger.error(f"Database optimization failed: {e}") + results['error'] = str(e) + + return results + + def _analyze_database(self) -> Dict[str, Any]: + """Analyze database for optimization opportunities.""" + try: + db = self.storage_manager.db + + # Get table sizes + table_info = db.execute_query(""" + SELECT name, + (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=m.name) as table_count + FROM sqlite_master m WHERE type='table' + """) + + # Get index information + index_info = db.execute_query(""" + SELECT name, tbl_name, sql + FROM sqlite_master + WHERE type='index' AND sql IS NOT NULL + """) + + return { + 'tables': table_info, + 'indexes': index_info, + 'database_size': self._get_database_size() + } + + except Exception as e: + logger.error(f"Database analysis failed: {e}") + return {'error': str(e)} + + def _vacuum_database(self) -> Dict[str, Any]: + """Vacuum database to reclaim space.""" + try: + db = self.storage_manager.db + + # Get size before vacuum + size_before = self._get_database_size() + + # Perform vacuum + db.execute_update("VACUUM") + + # Get size after vacuum + size_after = self._get_database_size() + + space_reclaimed = size_before - size_after + + return { + 'size_before_mb': size_before / (1024 * 1024), + 'size_after_mb': size_after / (1024 * 1024), + 'space_reclaimed_mb': space_reclaimed / (1024 * 1024) + } + + except Exception as e: + logger.error(f"Database vacuum failed: {e}") + return {'error': str(e)} + + def _update_statistics(self) -> Dict[str, Any]: + """Update database statistics.""" + try: + db = self.storage_manager.db + + # Analyze all tables + db.execute_update("ANALYZE") + + return {'status': 'completed'} + + except Exception as e: + logger.error(f"Statistics update failed: {e}") + return {'error': str(e)} + + def _check_indexes(self) -> Dict[str, Any]: + """Check and suggest index optimizations.""" + try: + db = self.storage_manager.db + + # Check for missing indexes on frequently queried columns + suggestions = [] + + # Check digipals table + digipals_count = db.execute_query("SELECT COUNT(*) as count FROM digipals")[0]['count'] + if digipals_count > 1000: + # Suggest index on user_id if not exists + existing_indexes = db.execute_query(""" + SELECT name FROM sqlite_master + WHERE type='index' AND tbl_name='digipals' AND sql LIKE '%user_id%' + """) + + if not existing_indexes: + suggestions.append("CREATE INDEX idx_digipals_user_id ON digipals(user_id)") + + # Check interactions table + interactions_count = db.execute_query("SELECT COUNT(*) as count FROM interactions")[0]['count'] + if interactions_count > 5000: + # Suggest index on digipal_id and timestamp + existing_indexes = db.execute_query(""" + SELECT name FROM sqlite_master + WHERE type='index' AND tbl_name='interactions' + AND sql LIKE '%digipal_id%' AND sql LIKE '%timestamp%' + """) + + if not existing_indexes: + suggestions.append("CREATE INDEX idx_interactions_digipal_timestamp ON interactions(digipal_id, timestamp)") + + return { + 'suggestions': suggestions, + 'digipals_count': digipals_count, + 'interactions_count': interactions_count + } + + except Exception as e: + logger.error(f"Index check failed: {e}") + return {'error': str(e)} + + def _get_database_size(self) -> int: + """Get database file size in bytes.""" + try: + import os + return os.path.getsize(self.storage_manager.db_path) + except: + return 0 + + def create_suggested_indexes(self) -> Dict[str, Any]: + """Create suggested indexes for better performance.""" + try: + db = self.storage_manager.db + results = [] + + # Essential indexes for DigiPal application + indexes_to_create = [ + "CREATE INDEX IF NOT EXISTS idx_digipals_user_id ON digipals(user_id)", + "CREATE INDEX IF NOT EXISTS idx_digipals_active ON digipals(is_active)", + "CREATE INDEX IF NOT EXISTS idx_interactions_digipal ON interactions(digipal_id)", + "CREATE INDEX IF NOT EXISTS idx_interactions_timestamp ON interactions(timestamp)", + "CREATE INDEX IF NOT EXISTS idx_care_actions_digipal ON care_actions(digipal_id)", + "CREATE INDEX IF NOT EXISTS idx_users_id ON users(id)" + ] + + for index_sql in indexes_to_create: + try: + db.execute_update(index_sql) + results.append(f"Created: {index_sql}") + except Exception as e: + results.append(f"Failed: {index_sql} - {e}") + + return {'results': results} + + except Exception as e: + logger.error(f"Index creation failed: {e}") + return {'error': str(e)} + + +class PerformanceMonitor: + """System performance monitoring and optimization.""" + + def __init__(self): + """Initialize performance monitor.""" + self.metrics_history: List[PerformanceMetrics] = [] + self.max_history_size = 1440 # 24 hours of minute-by-minute data + + logger.info("Performance monitor initialized") + + def collect_metrics(self, active_pets: int = 0, cached_models: int = 0, + response_time_avg: float = 0.0) -> PerformanceMetrics: + """Collect current performance metrics.""" + try: + # System metrics + cpu_usage = psutil.cpu_percent(interval=1) + memory = psutil.virtual_memory() + memory_usage = memory.percent + + # GPU metrics + gpu_memory_usage = 0.0 + if torch.cuda.is_available(): + gpu_memory_usage = (torch.cuda.memory_allocated() / torch.cuda.max_memory_allocated()) * 100 + + # Database connections (approximate) + database_connections = 1 # Simplified for SQLite + + metrics = PerformanceMetrics( + timestamp=datetime.now(), + cpu_usage=cpu_usage, + memory_usage=memory_usage, + gpu_memory_usage=gpu_memory_usage, + active_pets=active_pets, + cached_models=cached_models, + database_connections=database_connections, + response_time_avg=response_time_avg + ) + + # Add to history + self.metrics_history.append(metrics) + + # Manage history size + if len(self.metrics_history) > self.max_history_size: + self.metrics_history = self.metrics_history[-self.max_history_size:] + + return metrics + + except Exception as e: + logger.error(f"Failed to collect performance metrics: {e}") + return PerformanceMetrics( + timestamp=datetime.now(), + cpu_usage=0.0, + memory_usage=0.0, + gpu_memory_usage=0.0, + active_pets=active_pets, + cached_models=cached_models, + database_connections=0, + response_time_avg=response_time_avg + ) + + def get_performance_summary(self, hours: int = 1) -> Dict[str, Any]: + """Get performance summary for the last N hours.""" + if not self.metrics_history: + return {} + + cutoff_time = datetime.now() - timedelta(hours=hours) + recent_metrics = [m for m in self.metrics_history if m.timestamp > cutoff_time] + + if not recent_metrics: + recent_metrics = self.metrics_history[-60:] # Last 60 data points + + # Calculate averages + avg_cpu = sum(m.cpu_usage for m in recent_metrics) / len(recent_metrics) + avg_memory = sum(m.memory_usage for m in recent_metrics) / len(recent_metrics) + avg_gpu_memory = sum(m.gpu_memory_usage for m in recent_metrics) / len(recent_metrics) + avg_response_time = sum(m.response_time_avg for m in recent_metrics) / len(recent_metrics) + + # Calculate peaks + max_cpu = max(m.cpu_usage for m in recent_metrics) + max_memory = max(m.memory_usage for m in recent_metrics) + max_gpu_memory = max(m.gpu_memory_usage for m in recent_metrics) + + return { + 'time_period_hours': hours, + 'data_points': len(recent_metrics), + 'averages': { + 'cpu_usage': avg_cpu, + 'memory_usage': avg_memory, + 'gpu_memory_usage': avg_gpu_memory, + 'response_time': avg_response_time + }, + 'peaks': { + 'cpu_usage': max_cpu, + 'memory_usage': max_memory, + 'gpu_memory_usage': max_gpu_memory + }, + 'current': { + 'active_pets': recent_metrics[-1].active_pets if recent_metrics else 0, + 'cached_models': recent_metrics[-1].cached_models if recent_metrics else 0 + } + } + + def check_performance_alerts(self) -> List[Dict[str, Any]]: + """Check for performance issues and return alerts.""" + alerts = [] + + if not self.metrics_history: + return alerts + + latest = self.metrics_history[-1] + + # CPU usage alert + if latest.cpu_usage > 80: + alerts.append({ + 'type': 'high_cpu', + 'severity': 'warning' if latest.cpu_usage < 90 else 'critical', + 'message': f"High CPU usage: {latest.cpu_usage:.1f}%", + 'value': latest.cpu_usage + }) + + # Memory usage alert + if latest.memory_usage > 85: + alerts.append({ + 'type': 'high_memory', + 'severity': 'warning' if latest.memory_usage < 95 else 'critical', + 'message': f"High memory usage: {latest.memory_usage:.1f}%", + 'value': latest.memory_usage + }) + + # GPU memory alert + if latest.gpu_memory_usage > 90: + alerts.append({ + 'type': 'high_gpu_memory', + 'severity': 'warning', + 'message': f"High GPU memory usage: {latest.gpu_memory_usage:.1f}%", + 'value': latest.gpu_memory_usage + }) + + # Response time alert + if latest.response_time_avg > 5.0: + alerts.append({ + 'type': 'slow_response', + 'severity': 'warning', + 'message': f"Slow response time: {latest.response_time_avg:.2f}s", + 'value': latest.response_time_avg + }) + + return alerts + + def suggest_optimizations(self) -> List[str]: + """Suggest performance optimizations based on metrics.""" + suggestions = [] + + if not self.metrics_history: + return suggestions + + # Analyze recent performance + recent_metrics = self.metrics_history[-60:] # Last hour of data + + avg_cpu = sum(m.cpu_usage for m in recent_metrics) / len(recent_metrics) + avg_memory = sum(m.memory_usage for m in recent_metrics) / len(recent_metrics) + avg_gpu_memory = sum(m.gpu_memory_usage for m in recent_metrics) / len(recent_metrics) + + # CPU optimization suggestions + if avg_cpu > 70: + suggestions.append("Consider reducing background task frequency") + suggestions.append("Enable model CPU offloading to reduce CPU load") + + # Memory optimization suggestions + if avg_memory > 80: + suggestions.append("Reduce model cache size to free memory") + suggestions.append("Enable more aggressive memory cleanup") + suggestions.append("Consider using smaller quantized models") + + # GPU memory optimization suggestions + if avg_gpu_memory > 80: + suggestions.append("Enable 4-bit quantization for models") + suggestions.append("Reduce maximum concurrent model loading") + suggestions.append("Use CPU offloading for less frequently used models") + + # General suggestions + if len(recent_metrics) > 0: + max_active_pets = max(m.active_pets for m in recent_metrics) + if max_active_pets > 100: + suggestions.append("Consider implementing pet data pagination") + suggestions.append("Optimize database queries with better indexing") + + return suggestions + + +class ResourceCleanupManager: + """Manages resource cleanup and garbage collection.""" + + def __init__(self): + """Initialize resource cleanup manager.""" + self.cleanup_callbacks: List[Callable] = [] + self.last_cleanup = datetime.now() + + logger.info("Resource cleanup manager initialized") + + def register_cleanup_callback(self, callback: Callable) -> None: + """Register a cleanup callback function.""" + self.cleanup_callbacks.append(callback) + + def perform_cleanup(self, force_gc: bool = True) -> Dict[str, Any]: + """Perform comprehensive resource cleanup.""" + cleanup_results = {} + + try: + # Execute registered cleanup callbacks + for i, callback in enumerate(self.cleanup_callbacks): + try: + callback() + cleanup_results[f'callback_{i}'] = 'success' + except Exception as e: + cleanup_results[f'callback_{i}'] = f'error: {e}' + + # Python garbage collection + if force_gc: + collected = gc.collect() + cleanup_results['gc_collected'] = collected + + # PyTorch cleanup + if torch.cuda.is_available(): + torch.cuda.empty_cache() + cleanup_results['cuda_cache_cleared'] = True + + # Update last cleanup time + self.last_cleanup = datetime.now() + cleanup_results['cleanup_time'] = self.last_cleanup.isoformat() + + logger.info("Resource cleanup completed") + + except Exception as e: + logger.error(f"Resource cleanup failed: {e}") + cleanup_results['error'] = str(e) + + return cleanup_results + + def get_memory_info(self) -> Dict[str, Any]: + """Get current memory usage information.""" + try: + # System memory + memory = psutil.virtual_memory() + + # Python memory (approximate) + import sys + python_objects = len(gc.get_objects()) + + # PyTorch memory + torch_memory = {} + if torch.cuda.is_available(): + torch_memory = { + 'allocated_mb': torch.cuda.memory_allocated() / (1024 * 1024), + 'cached_mb': torch.cuda.memory_reserved() / (1024 * 1024), + 'max_allocated_mb': torch.cuda.max_memory_allocated() / (1024 * 1024) + } + + return { + 'system_memory': { + 'total_mb': memory.total / (1024 * 1024), + 'available_mb': memory.available / (1024 * 1024), + 'used_mb': memory.used / (1024 * 1024), + 'percent': memory.percent + }, + 'python_objects': python_objects, + 'torch_memory': torch_memory, + 'last_cleanup': self.last_cleanup.isoformat() + } + + except Exception as e: + logger.error(f"Failed to get memory info: {e}") + return {'error': str(e)} \ No newline at end of file diff --git a/digipal/core/recovery_strategies.py b/digipal/core/recovery_strategies.py new file mode 100644 index 0000000000000000000000000000000000000000..263aeaa251786ce42eaca302e84f62ceb0c24314 --- /dev/null +++ b/digipal/core/recovery_strategies.py @@ -0,0 +1,717 @@ +""" +Recovery strategies for DigiPal error handling system. + +This module implements specific recovery strategies for different types of errors, +providing automated recovery mechanisms and fallback procedures. +""" + +import logging +import time +import shutil +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional, Any, Callable, Tuple +from dataclasses import dataclass + +from .exceptions import ( + DigiPalException, StorageError, AIModelError, NetworkError, + AuthenticationError, ImageGenerationError, SpeechProcessingError, + PetLifecycleError, MCPProtocolError, RecoveryError, ErrorSeverity +) +from .error_handler import recovery_manager +from ..storage.backup_recovery import BackupRecoveryManager + +logger = logging.getLogger(__name__) + + +@dataclass +class RecoveryResult: + """Result of a recovery attempt.""" + success: bool + strategy_used: str + message: str + context: Dict[str, Any] + recovery_time_seconds: float + + +class StorageRecoveryStrategy: + """Recovery strategies for storage-related errors.""" + + def __init__(self, backup_manager: Optional[BackupRecoveryManager] = None): + """Initialize storage recovery strategy.""" + self.backup_manager = backup_manager + + def recover_corrupted_database(self, error: StorageError) -> bool: + """ + Recover from database corruption. + + Args: + error: Storage error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting database corruption recovery") + + if not self.backup_manager: + logger.error("No backup manager available for recovery") + return False + + # Get the most recent backup + backups = self.backup_manager.list_backups() + if not backups: + logger.error("No backups available for recovery") + return False + + latest_backup = backups[0] # Already sorted by timestamp + + # Attempt to restore from backup + success = self.backup_manager.restore_backup(latest_backup.backup_id) + + if success: + logger.info(f"Database recovered from backup: {latest_backup.backup_id}") + return True + else: + logger.error("Failed to restore from backup") + return False + + except Exception as e: + logger.error(f"Database recovery failed: {e}") + return False + + def recover_disk_space_issue(self, error: StorageError) -> bool: + """ + Recover from disk space issues. + + Args: + error: Storage error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting disk space recovery") + + # Clean up old backups + if self.backup_manager: + old_backups = self.backup_manager.list_backups() + # Keep only the 3 most recent backups + backups_to_delete = old_backups[3:] + + for backup in backups_to_delete: + self.backup_manager.delete_backup(backup.backup_id) + logger.info(f"Deleted old backup: {backup.backup_id}") + + # Clean up temporary files + temp_dirs = [Path("/tmp"), Path.cwd() / "temp", Path.cwd() / "cache"] + for temp_dir in temp_dirs: + if temp_dir.exists(): + for temp_file in temp_dir.glob("digipal_*"): + try: + if temp_file.is_file(): + temp_file.unlink() + elif temp_file.is_dir(): + shutil.rmtree(temp_file) + except Exception as e: + logger.warning(f"Failed to delete temp file {temp_file}: {e}") + + logger.info("Disk space cleanup completed") + return True + + except Exception as e: + logger.error(f"Disk space recovery failed: {e}") + return False + + def recover_permission_error(self, error: StorageError) -> bool: + """ + Recover from permission errors. + + Args: + error: Storage error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting permission error recovery") + + # Try to create alternative storage location + alternative_paths = [ + Path.home() / ".digipal" / "data", + Path("/tmp") / "digipal_data", + Path.cwd() / "digipal_data_alt" + ] + + for alt_path in alternative_paths: + try: + alt_path.mkdir(parents=True, exist_ok=True) + + # Test write access + test_file = alt_path / "test_write.tmp" + test_file.write_text("test") + test_file.unlink() + + logger.info(f"Alternative storage location available: {alt_path}") + # Store the alternative path in error context for later use + error.context['alternative_storage_path'] = str(alt_path) + return True + + except Exception as e: + logger.debug(f"Alternative path {alt_path} not accessible: {e}") + continue + + logger.error("No alternative storage locations available") + return False + + except Exception as e: + logger.error(f"Permission error recovery failed: {e}") + return False + + +class AIModelRecoveryStrategy: + """Recovery strategies for AI model-related errors.""" + + def __init__(self): + """Initialize AI model recovery strategy.""" + self.model_fallback_hierarchy = { + 'language_model': ['qwen3-0.6b', 'simple_responses', 'static_responses'], + 'speech_processing': ['kyutai', 'basic_speech', 'text_only'], + 'image_generation': ['flux', 'stable_diffusion', 'default_images'] + } + + def recover_model_loading_failure(self, error: AIModelError) -> bool: + """ + Recover from model loading failures. + + Args: + error: AI model error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting AI model loading recovery") + + # Clear model cache + import gc + import torch + + # Force garbage collection + gc.collect() + + # Clear CUDA cache if available + if torch.cuda.is_available(): + torch.cuda.empty_cache() + logger.info("Cleared CUDA cache") + + # Try loading with reduced precision + error.context['use_reduced_precision'] = True + error.context['use_cpu_only'] = True + + logger.info("Set fallback options for model loading") + return True + + except Exception as e: + logger.error(f"Model loading recovery failed: {e}") + return False + + def recover_model_inference_failure(self, error: AIModelError) -> bool: + """ + Recover from model inference failures. + + Args: + error: AI model error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting AI model inference recovery") + + # Switch to fallback response mode + error.context['use_fallback_responses'] = True + error.context['degradation_level'] = 'basic_responses' + + logger.info("Switched to fallback response mode") + return True + + except Exception as e: + logger.error(f"Model inference recovery failed: {e}") + return False + + def recover_memory_error(self, error: AIModelError) -> bool: + """ + Recover from memory-related errors. + + Args: + error: AI model error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting memory error recovery") + + import gc + import torch + + # Aggressive memory cleanup + gc.collect() + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + torch.cuda.synchronize() + + # Reduce batch sizes and model precision + error.context['reduce_batch_size'] = True + error.context['use_fp16'] = True + error.context['offload_to_cpu'] = True + + logger.info("Applied memory optimization settings") + return True + + except Exception as e: + logger.error(f"Memory error recovery failed: {e}") + return False + + +class NetworkRecoveryStrategy: + """Recovery strategies for network-related errors.""" + + def __init__(self): + """Initialize network recovery strategy.""" + self.retry_delays = [1, 2, 5, 10, 30] # Progressive delays in seconds + + def recover_connection_timeout(self, error: NetworkError) -> bool: + """ + Recover from connection timeout errors. + + Args: + error: Network error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting connection timeout recovery") + + # Switch to offline mode + error.context['enable_offline_mode'] = True + error.context['use_cached_data'] = True + + logger.info("Switched to offline mode") + return True + + except Exception as e: + logger.error(f"Connection timeout recovery failed: {e}") + return False + + def recover_dns_resolution_failure(self, error: NetworkError) -> bool: + """ + Recover from DNS resolution failures. + + Args: + error: Network error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting DNS resolution recovery") + + # Try alternative DNS servers or cached endpoints + alternative_endpoints = [ + "8.8.8.8", # Google DNS + "1.1.1.1", # Cloudflare DNS + "208.67.222.222" # OpenDNS + ] + + error.context['alternative_dns_servers'] = alternative_endpoints + error.context['use_ip_addresses'] = True + + logger.info("Set alternative DNS configuration") + return True + + except Exception as e: + logger.error(f"DNS resolution recovery failed: {e}") + return False + + def recover_rate_limit_error(self, error: NetworkError) -> bool: + """ + Recover from rate limiting errors. + + Args: + error: Network error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting rate limit recovery") + + # Implement exponential backoff + error.context['implement_backoff'] = True + error.context['backoff_multiplier'] = 2.0 + error.context['max_backoff_seconds'] = 300 + + # Switch to cached responses temporarily + error.context['use_cached_responses'] = True + error.context['cache_duration_hours'] = 1 + + logger.info("Applied rate limiting recovery measures") + return True + + except Exception as e: + logger.error(f"Rate limit recovery failed: {e}") + return False + + +class AuthenticationRecoveryStrategy: + """Recovery strategies for authentication-related errors.""" + + def recover_token_expiry(self, error: AuthenticationError) -> bool: + """ + Recover from token expiry errors. + + Args: + error: Authentication error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting token expiry recovery") + + # Switch to offline mode with cached authentication + error.context['use_offline_auth'] = True + error.context['extend_session_timeout'] = True + + logger.info("Switched to offline authentication mode") + return True + + except Exception as e: + logger.error(f"Token expiry recovery failed: {e}") + return False + + def recover_invalid_credentials(self, error: AuthenticationError) -> bool: + """ + Recover from invalid credentials errors. + + Args: + error: Authentication error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting invalid credentials recovery") + + # Enable guest mode + error.context['enable_guest_mode'] = True + error.context['limited_functionality'] = True + + logger.info("Enabled guest mode for limited functionality") + return True + + except Exception as e: + logger.error(f"Invalid credentials recovery failed: {e}") + return False + + +class PetLifecycleRecoveryStrategy: + """Recovery strategies for pet lifecycle errors.""" + + def __init__(self, backup_manager: Optional[BackupRecoveryManager] = None): + """Initialize pet lifecycle recovery strategy.""" + self.backup_manager = backup_manager + + def recover_corrupted_pet_data(self, error: PetLifecycleError) -> bool: + """ + Recover from corrupted pet data. + + Args: + error: Pet lifecycle error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting corrupted pet data recovery") + + if not self.backup_manager: + logger.error("No backup manager available for pet recovery") + return False + + # Try to restore pet from backup + pet_id = error.context.get('pet_id') + user_id = error.context.get('user_id') + + if pet_id: + # Look for pet-specific backups + backups = self.backup_manager.list_backups() + pet_backups = [b for b in backups if b.pet_id == pet_id] + + if pet_backups: + latest_backup = pet_backups[0] + success = self.backup_manager.restore_backup(latest_backup.backup_id) + if success: + logger.info(f"Pet data recovered from backup: {latest_backup.backup_id}") + return True + + # Fallback to user-level backup + if user_id: + user_backups = [b for b in self.backup_manager.list_backups() if b.user_id == user_id] + if user_backups: + latest_backup = user_backups[0] + success = self.backup_manager.restore_backup(latest_backup.backup_id) + if success: + logger.info(f"User data recovered from backup: {latest_backup.backup_id}") + return True + + logger.error("No suitable backups found for pet recovery") + return False + + except Exception as e: + logger.error(f"Pet data recovery failed: {e}") + return False + + def recover_evolution_failure(self, error: PetLifecycleError) -> bool: + """ + Recover from evolution failures. + + Args: + error: Pet lifecycle error that occurred + + Returns: + True if recovery successful + """ + try: + logger.info("Attempting evolution failure recovery") + + # Reset evolution state to previous stable state + error.context['reset_evolution_state'] = True + error.context['use_safe_evolution'] = True + error.context['skip_complex_calculations'] = True + + logger.info("Set evolution recovery parameters") + return True + + except Exception as e: + logger.error(f"Evolution failure recovery failed: {e}") + return False + + +class SystemRecoveryOrchestrator: + """Orchestrates recovery strategies across all system components.""" + + def __init__(self, backup_manager: Optional[BackupRecoveryManager] = None): + """Initialize system recovery orchestrator.""" + self.backup_manager = backup_manager + + # Initialize recovery strategies + self.storage_recovery = StorageRecoveryStrategy(backup_manager) + self.ai_model_recovery = AIModelRecoveryStrategy() + self.network_recovery = NetworkRecoveryStrategy() + self.auth_recovery = AuthenticationRecoveryStrategy() + self.pet_lifecycle_recovery = PetLifecycleRecoveryStrategy(backup_manager) + + # Register recovery strategies + self._register_recovery_strategies() + + def _register_recovery_strategies(self): + """Register all recovery strategies with the recovery manager.""" + + # Storage recovery strategies + recovery_manager.register_recovery_strategy( + 'storage', self.storage_recovery.recover_corrupted_database + ) + recovery_manager.register_recovery_strategy( + 'storage', self.storage_recovery.recover_disk_space_issue + ) + recovery_manager.register_recovery_strategy( + 'storage', self.storage_recovery.recover_permission_error + ) + + # AI model recovery strategies + recovery_manager.register_recovery_strategy( + 'ai_model', self.ai_model_recovery.recover_model_loading_failure + ) + recovery_manager.register_recovery_strategy( + 'ai_model', self.ai_model_recovery.recover_model_inference_failure + ) + recovery_manager.register_recovery_strategy( + 'ai_model', self.ai_model_recovery.recover_memory_error + ) + + # Network recovery strategies + recovery_manager.register_recovery_strategy( + 'network', self.network_recovery.recover_connection_timeout + ) + recovery_manager.register_recovery_strategy( + 'network', self.network_recovery.recover_dns_resolution_failure + ) + recovery_manager.register_recovery_strategy( + 'network', self.network_recovery.recover_rate_limit_error + ) + + # Authentication recovery strategies + recovery_manager.register_recovery_strategy( + 'authentication', self.auth_recovery.recover_token_expiry + ) + recovery_manager.register_recovery_strategy( + 'authentication', self.auth_recovery.recover_invalid_credentials + ) + + # Pet lifecycle recovery strategies + recovery_manager.register_recovery_strategy( + 'pet_lifecycle', self.pet_lifecycle_recovery.recover_corrupted_pet_data + ) + recovery_manager.register_recovery_strategy( + 'pet_lifecycle', self.pet_lifecycle_recovery.recover_evolution_failure + ) + + logger.info("All recovery strategies registered") + + def execute_comprehensive_recovery(self, error: DigiPalException) -> RecoveryResult: + """ + Execute comprehensive recovery for any DigiPal error. + + Args: + error: The error to recover from + + Returns: + RecoveryResult with recovery outcome + """ + start_time = time.time() + + try: + logger.info(f"Starting comprehensive recovery for error: {error.category.value}") + + # Create pre-recovery backup if critical error + if error.severity in [ErrorSeverity.HIGH, ErrorSeverity.CRITICAL]: + if self.backup_manager: + backup_id = self.backup_manager.create_pre_operation_backup( + "error_recovery", + {"error_type": error.category.value, "error_code": error.error_code} + ) + if backup_id: + logger.info(f"Pre-recovery backup created: {backup_id}") + + # Attempt recovery using registered strategies + recovery_success = recovery_manager.attempt_recovery(error) + + recovery_time = time.time() - start_time + + if recovery_success: + return RecoveryResult( + success=True, + strategy_used="comprehensive_recovery", + message="Recovery completed successfully", + context=error.context, + recovery_time_seconds=recovery_time + ) + else: + return RecoveryResult( + success=False, + strategy_used="comprehensive_recovery", + message="Recovery failed - manual intervention required", + context=error.context, + recovery_time_seconds=recovery_time + ) + + except Exception as recovery_error: + recovery_time = time.time() - start_time + logger.error(f"Comprehensive recovery failed: {recovery_error}") + + return RecoveryResult( + success=False, + strategy_used="comprehensive_recovery", + message=f"Recovery failed with error: {str(recovery_error)}", + context={"recovery_error": str(recovery_error)}, + recovery_time_seconds=recovery_time + ) + + def get_recovery_recommendations(self, error: DigiPalException) -> List[str]: + """ + Get recovery recommendations for a specific error. + + Args: + error: The error to get recommendations for + + Returns: + List of recovery recommendations + """ + recommendations = [] + + # Add error-specific recommendations + recommendations.extend(error.recovery_suggestions) + + # Add category-specific recommendations + category_recommendations = { + 'storage': [ + "Check available disk space", + "Verify file permissions", + "Run database integrity check", + "Consider restoring from backup" + ], + 'ai_model': [ + "Check available memory", + "Verify model files are not corrupted", + "Try restarting the application", + "Consider using reduced model precision" + ], + 'network': [ + "Check internet connection", + "Verify firewall settings", + "Try using offline mode", + "Check for service outages" + ], + 'authentication': [ + "Verify credentials are correct", + "Check token expiration", + "Try logging out and back in", + "Consider using offline mode" + ], + 'pet_lifecycle': [ + "Check pet data integrity", + "Verify backup availability", + "Try reloading pet data", + "Consider restoring from backup" + ] + } + + category_recs = category_recommendations.get(error.category.value, []) + recommendations.extend(category_recs) + + # Remove duplicates while preserving order + seen = set() + unique_recommendations = [] + for rec in recommendations: + if rec not in seen: + seen.add(rec) + unique_recommendations.append(rec) + + return unique_recommendations + + +# Global system recovery orchestrator +system_recovery_orchestrator = None + + +def initialize_system_recovery(backup_manager: Optional[BackupRecoveryManager] = None): + """ + Initialize the global system recovery orchestrator. + + Args: + backup_manager: Backup manager instance + """ + global system_recovery_orchestrator + system_recovery_orchestrator = SystemRecoveryOrchestrator(backup_manager) + logger.info("System recovery orchestrator initialized") + + +def get_system_recovery_orchestrator() -> Optional[SystemRecoveryOrchestrator]: + """Get the global system recovery orchestrator.""" + return system_recovery_orchestrator \ No newline at end of file diff --git a/digipal/core/user_error_messages.py b/digipal/core/user_error_messages.py new file mode 100644 index 0000000000000000000000000000000000000000..0a360be8bf8afeff4782d8f4d63121a1f2ead0c4 --- /dev/null +++ b/digipal/core/user_error_messages.py @@ -0,0 +1,482 @@ +""" +User-friendly error messages and recovery guidance for DigiPal. + +This module provides comprehensive user-facing error messages and +step-by-step recovery instructions for different error scenarios. +""" + +import logging +from typing import Dict, List, Optional, Any +from enum import Enum + +from .exceptions import DigiPalException, ErrorCategory, ErrorSeverity + +logger = logging.getLogger(__name__) + + +class MessageTone(Enum): + """Tone for error messages.""" + FRIENDLY = "friendly" + PROFESSIONAL = "professional" + CASUAL = "casual" + EMPATHETIC = "empathetic" + + +class UserErrorMessageGenerator: + """Generates user-friendly error messages and recovery guidance.""" + + def __init__(self, default_tone: MessageTone = MessageTone.FRIENDLY): + """Initialize message generator.""" + self.default_tone = default_tone + self.message_templates = self._initialize_message_templates() + self.recovery_guides = self._initialize_recovery_guides() + self.contextual_messages = self._initialize_contextual_messages() + + def _initialize_message_templates(self) -> Dict[str, Dict[MessageTone, str]]: + """Initialize message templates for different tones.""" + return { + 'authentication_failed': { + MessageTone.FRIENDLY: "Oops! We couldn't log you in. Let's get this sorted out! ๐", + MessageTone.PROFESSIONAL: "Authentication failed. Please verify your credentials.", + MessageTone.CASUAL: "Login didn't work. Let's try again!", + MessageTone.EMPATHETIC: "We understand login issues can be frustrating. Let's help you get back in." + }, + 'storage_error': { + MessageTone.FRIENDLY: "We're having trouble saving your DigiPal's data. Don't worry, we'll fix this! ๐พ", + MessageTone.PROFESSIONAL: "A storage error occurred. Your data may not have been saved properly.", + MessageTone.CASUAL: "Couldn't save your stuff. Let's figure this out.", + MessageTone.EMPATHETIC: "We know how important your DigiPal's progress is. Let's recover your data." + }, + 'ai_model_error': { + MessageTone.FRIENDLY: "Your DigiPal is having trouble understanding right now. Give us a moment! ๐ค", + MessageTone.PROFESSIONAL: "AI service is temporarily unavailable. Please try again shortly.", + MessageTone.CASUAL: "The AI is being a bit wonky. Hang tight!", + MessageTone.EMPATHETIC: "We know you want to chat with your DigiPal. We're working on getting them back online." + }, + 'speech_processing_error': { + MessageTone.FRIENDLY: "I couldn't quite catch what you said. Could you try speaking again? ๐ค", + MessageTone.PROFESSIONAL: "Speech recognition failed. Please ensure clear audio input.", + MessageTone.CASUAL: "Didn't catch that. Say it again?", + MessageTone.EMPATHETIC: "Sometimes speech recognition can be tricky. Let's try a different approach." + }, + 'image_generation_error': { + MessageTone.FRIENDLY: "We're having trouble creating your DigiPal's image, but they're still there! ๐จ", + MessageTone.PROFESSIONAL: "Image generation service is unavailable. Default images will be used.", + MessageTone.CASUAL: "Can't make the picture right now, but your DigiPal is fine!", + MessageTone.EMPATHETIC: "Your DigiPal is beautiful even without a custom image. We'll try again later." + }, + 'network_error': { + MessageTone.FRIENDLY: "Looks like there's a connection hiccup. Let's try to reconnect! ๐", + MessageTone.PROFESSIONAL: "Network connectivity issue detected. Please check your connection.", + MessageTone.CASUAL: "Internet's acting up. Check your connection?", + MessageTone.EMPATHETIC: "Connection problems can be annoying. Let's get you back online." + }, + 'pet_lifecycle_error': { + MessageTone.FRIENDLY: "Something went wrong with your DigiPal's growth. Let's help them! ๐ฃ", + MessageTone.PROFESSIONAL: "Pet lifecycle error occurred. Data integrity may be compromised.", + MessageTone.CASUAL: "Your DigiPal hit a snag. Let's fix them up!", + MessageTone.EMPATHETIC: "We understand your DigiPal means a lot to you. Let's restore them safely." + }, + 'system_error': { + MessageTone.FRIENDLY: "Something unexpected happened, but we're on it! ๐ง", + MessageTone.PROFESSIONAL: "A system error occurred. Please try restarting the application.", + MessageTone.CASUAL: "Things got weird. Maybe restart?", + MessageTone.EMPATHETIC: "Technical issues can be frustrating. We're here to help you through this." + } + } + + def _initialize_recovery_guides(self) -> Dict[str, List[Dict[str, Any]]]: + """Initialize step-by-step recovery guides.""" + return { + 'authentication': [ + { + 'step': 1, + 'title': 'Check Your Credentials', + 'description': 'Make sure your HuggingFace username and token are correct', + 'action': 'verify_credentials', + 'difficulty': 'easy' + }, + { + 'step': 2, + 'title': 'Test Your Connection', + 'description': 'Ensure you have a stable internet connection', + 'action': 'test_connection', + 'difficulty': 'easy' + }, + { + 'step': 3, + 'title': 'Try Offline Mode', + 'description': 'Use the application in offline mode with limited features', + 'action': 'enable_offline_mode', + 'difficulty': 'easy' + }, + { + 'step': 4, + 'title': 'Clear Browser Data', + 'description': 'Clear cookies and cached data, then try logging in again', + 'action': 'clear_browser_data', + 'difficulty': 'medium' + } + ], + 'storage': [ + { + 'step': 1, + 'title': 'Check Disk Space', + 'description': 'Make sure you have at least 100MB of free disk space', + 'action': 'check_disk_space', + 'difficulty': 'easy' + }, + { + 'step': 2, + 'title': 'Restart Application', + 'description': 'Close and reopen DigiPal to reset the storage connection', + 'action': 'restart_application', + 'difficulty': 'easy' + }, + { + 'step': 3, + 'title': 'Restore from Backup', + 'description': 'Use an automatic backup to restore your DigiPal data', + 'action': 'restore_backup', + 'difficulty': 'medium' + }, + { + 'step': 4, + 'title': 'Check File Permissions', + 'description': 'Ensure DigiPal has permission to write to its data directory', + 'action': 'check_permissions', + 'difficulty': 'hard' + } + ], + 'ai_model': [ + { + 'step': 1, + 'title': 'Wait and Retry', + 'description': 'AI services may be temporarily busy. Wait 30 seconds and try again', + 'action': 'wait_and_retry', + 'difficulty': 'easy' + }, + { + 'step': 2, + 'title': 'Use Simple Commands', + 'description': 'Try using shorter, simpler phrases when talking to your DigiPal', + 'action': 'use_simple_commands', + 'difficulty': 'easy' + }, + { + 'step': 3, + 'title': 'Switch to Text Mode', + 'description': 'Use text input instead of speech if available', + 'action': 'switch_to_text', + 'difficulty': 'easy' + }, + { + 'step': 4, + 'title': 'Restart Application', + 'description': 'Close and reopen DigiPal to reload the AI models', + 'action': 'restart_application', + 'difficulty': 'medium' + } + ], + 'speech_processing': [ + { + 'step': 1, + 'title': 'Check Microphone', + 'description': 'Make sure your microphone is connected and working', + 'action': 'check_microphone', + 'difficulty': 'easy' + }, + { + 'step': 2, + 'title': 'Reduce Background Noise', + 'description': 'Find a quieter location or reduce background noise', + 'action': 'reduce_noise', + 'difficulty': 'easy' + }, + { + 'step': 3, + 'title': 'Speak Clearly', + 'description': 'Speak slowly and clearly, facing the microphone', + 'action': 'speak_clearly', + 'difficulty': 'easy' + }, + { + 'step': 4, + 'title': 'Use Text Input', + 'description': 'Switch to typing your messages instead of speaking', + 'action': 'use_text_input', + 'difficulty': 'easy' + } + ], + 'network': [ + { + 'step': 1, + 'title': 'Check Internet Connection', + 'description': 'Make sure you\'re connected to the internet', + 'action': 'check_internet', + 'difficulty': 'easy' + }, + { + 'step': 2, + 'title': 'Try Different Network', + 'description': 'Switch to a different WiFi network or use mobile data', + 'action': 'switch_network', + 'difficulty': 'easy' + }, + { + 'step': 3, + 'title': 'Use Offline Mode', + 'description': 'Continue using DigiPal with cached data and limited features', + 'action': 'enable_offline_mode', + 'difficulty': 'easy' + }, + { + 'step': 4, + 'title': 'Check Firewall Settings', + 'description': 'Ensure DigiPal is allowed through your firewall', + 'action': 'check_firewall', + 'difficulty': 'hard' + } + ], + 'pet_lifecycle': [ + { + 'step': 1, + 'title': 'Reload Your DigiPal', + 'description': 'Try refreshing the page or restarting the application', + 'action': 'reload_pet', + 'difficulty': 'easy' + }, + { + 'step': 2, + 'title': 'Check Recent Backups', + 'description': 'Look for recent automatic backups of your DigiPal', + 'action': 'check_backups', + 'difficulty': 'medium' + }, + { + 'step': 3, + 'title': 'Restore from Backup', + 'description': 'Restore your DigiPal from the most recent backup', + 'action': 'restore_from_backup', + 'difficulty': 'medium' + }, + { + 'step': 4, + 'title': 'Contact Support', + 'description': 'If all else fails, contact support for manual data recovery', + 'action': 'contact_support', + 'difficulty': 'easy' + } + ] + } + + def _initialize_contextual_messages(self) -> Dict[str, Dict[str, str]]: + """Initialize contextual messages based on user state.""" + return { + 'first_time_user': { + 'authentication': "Welcome to DigiPal! Let's get you set up with your HuggingFace account.", + 'storage': "We're setting up your DigiPal's home. This might take a moment.", + 'ai_model': "Your DigiPal is learning to talk! This may take a few minutes on first startup." + }, + 'returning_user': { + 'authentication': "Welcome back! Let's get you reconnected to your DigiPal.", + 'storage': "Loading your DigiPal's data. They've missed you!", + 'ai_model': "Your DigiPal is waking up and getting ready to chat!" + }, + 'during_evolution': { + 'pet_lifecycle': "Your DigiPal is in the middle of evolving! Let's make sure this goes smoothly.", + 'storage': "We're saving your DigiPal's evolution progress. This is important!" + }, + 'during_interaction': { + 'speech_processing': "Your DigiPal is listening carefully. Let's make sure they can hear you clearly.", + 'ai_model': "Your DigiPal is thinking about what to say. Sometimes they need a moment!" + } + } + + def generate_user_message( + self, + error: DigiPalException, + tone: Optional[MessageTone] = None, + user_context: Optional[Dict[str, Any]] = None + ) -> str: + """ + Generate a user-friendly error message. + + Args: + error: The error that occurred + tone: Message tone to use + user_context: Additional user context + + Returns: + User-friendly error message + """ + tone = tone or self.default_tone + user_context = user_context or {} + + # Get base message template + error_type = self._get_error_type_key(error) + base_message = self.message_templates.get(error_type, {}).get( + tone, + "Something unexpected happened, but we're working on it!" + ) + + # Add contextual information + contextual_key = user_context.get('user_state', 'general') + if contextual_key in self.contextual_messages: + contextual_msg = self.contextual_messages[contextual_key].get(error.category.value) + if contextual_msg: + base_message = f"{contextual_msg} {base_message}" + + # Add severity-specific information + if error.severity == ErrorSeverity.CRITICAL: + base_message += " This is a critical issue that needs immediate attention." + elif error.severity == ErrorSeverity.HIGH: + base_message += " This is important to fix to ensure your DigiPal works properly." + + return base_message + + def get_recovery_guide( + self, + error: DigiPalException, + max_steps: int = 4, + difficulty_filter: Optional[str] = None + ) -> List[Dict[str, Any]]: + """ + Get step-by-step recovery guide for an error. + + Args: + error: The error that occurred + max_steps: Maximum number of steps to return + difficulty_filter: Filter by difficulty ('easy', 'medium', 'hard') + + Returns: + List of recovery steps + """ + category_key = error.category.value + guide_steps = self.recovery_guides.get(category_key, []) + + # Filter by difficulty if specified + if difficulty_filter: + guide_steps = [step for step in guide_steps if step['difficulty'] == difficulty_filter] + + # Limit number of steps + guide_steps = guide_steps[:max_steps] + + # Add error-specific context to steps + for step in guide_steps: + step['error_code'] = error.error_code + step['error_category'] = error.category.value + + # Add specific instructions based on error context + if error.context: + step['context'] = error.context + + return guide_steps + + def _get_error_type_key(self, error: DigiPalException) -> str: + """Get the error type key for message templates.""" + category_mapping = { + ErrorCategory.AUTHENTICATION: 'authentication_failed', + ErrorCategory.STORAGE: 'storage_error', + ErrorCategory.AI_MODEL: 'ai_model_error', + ErrorCategory.SPEECH_PROCESSING: 'speech_processing_error', + ErrorCategory.IMAGE_GENERATION: 'image_generation_error', + ErrorCategory.NETWORK: 'network_error', + ErrorCategory.PET_LIFECYCLE: 'pet_lifecycle_error', + ErrorCategory.SYSTEM: 'system_error' + } + + return category_mapping.get(error.category, 'system_error') + + def generate_progress_message(self, recovery_step: Dict[str, Any]) -> str: + """ + Generate a progress message for a recovery step. + + Args: + recovery_step: Recovery step information + + Returns: + Progress message + """ + step_num = recovery_step.get('step', 1) + title = recovery_step.get('title', 'Recovery Step') + + progress_messages = [ + f"Step {step_num}: {title}", + f"Working on: {title}", + f"Now trying: {title}", + f"Attempting: {title}" + ] + + # Choose message based on step number + message_index = (step_num - 1) % len(progress_messages) + return progress_messages[message_index] + + def generate_success_message(self, error_category: str, recovery_method: str) -> str: + """ + Generate a success message after recovery. + + Args: + error_category: Category of error that was recovered + recovery_method: Method used for recovery + + Returns: + Success message + """ + success_messages = { + 'authentication': "Great! You're logged in and ready to go! ๐", + 'storage': "Perfect! Your DigiPal's data is safe and sound! ๐พ", + 'ai_model': "Awesome! Your DigiPal is ready to chat again! ๐ค", + 'speech_processing': "Excellent! Your DigiPal can hear you clearly now! ๐ค", + 'network': "Wonderful! You're back online! ๐", + 'pet_lifecycle': "Amazing! Your DigiPal is healthy and happy! ๐ฃ" + } + + base_message = success_messages.get(error_category, "Great! The issue has been resolved! โ ") + + if recovery_method: + base_message += f" (Fixed using: {recovery_method})" + + return base_message + + +# Global message generator instance +user_message_generator = UserErrorMessageGenerator() + + +def get_user_friendly_error_message( + error: DigiPalException, + tone: Optional[MessageTone] = None, + user_context: Optional[Dict[str, Any]] = None +) -> str: + """ + Get a user-friendly error message. + + Args: + error: The error that occurred + tone: Message tone to use + user_context: Additional user context + + Returns: + User-friendly error message + """ + return user_message_generator.generate_user_message(error, tone, user_context) + + +def get_recovery_guide( + error: DigiPalException, + max_steps: int = 4, + difficulty_filter: Optional[str] = None +) -> List[Dict[str, Any]]: + """ + Get recovery guide for an error. + + Args: + error: The error that occurred + max_steps: Maximum number of steps + difficulty_filter: Filter by difficulty + + Returns: + List of recovery steps + """ + return user_message_generator.get_recovery_guide(error, max_steps, difficulty_filter) \ No newline at end of file diff --git a/digipal/mcp/__init__.py b/digipal/mcp/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..32afa2478af4573dc2528bbdbfeaacd4cbf11527 --- /dev/null +++ b/digipal/mcp/__init__.py @@ -0,0 +1,11 @@ +""" +MCP (Model Context Protocol) server implementation for DigiPal. + +This module provides MCP server functionality for external system integration +with DigiPal pets, allowing other AI systems to interact with and manage +digital pets through the Model Context Protocol. +""" + +from .server import MCPServer + +__all__ = ['MCPServer'] \ No newline at end of file diff --git a/digipal/mcp/cli.py b/digipal/mcp/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..3dc1130ee0d9f617630cf0b80388200e2d66d11f --- /dev/null +++ b/digipal/mcp/cli.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +CLI tool for DigiPal MCP server. + +This module provides a command-line interface for starting and managing +the DigiPal MCP server. +""" + +import argparse +import asyncio +import logging +import sys +import tempfile +from pathlib import Path + +# Add the project root to the Python path +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from digipal.core.digipal_core import DigiPalCore +from digipal.storage.storage_manager import StorageManager +from digipal.ai.communication import AICommunication +from digipal.mcp.server import MCPServer + + +def setup_logging(level: str = "INFO"): + """Setup logging configuration.""" + logging.basicConfig( + level=getattr(logging, level.upper()), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + +async def start_server(args): + """Start the MCP server.""" + print("Starting DigiPal MCP Server...") + print(f"Database: {args.database}") + print(f"Assets: {args.assets}") + print(f"Server name: {args.name}") + + try: + # Initialize components + storage_manager = StorageManager(args.database, args.assets) + ai_communication = AICommunication() + digipal_core = DigiPalCore(storage_manager, ai_communication) + + # Initialize MCP server + mcp_server = MCPServer(digipal_core, args.name) + + print("MCP Server initialized successfully!") + print("Available tools:") + print("- get_pet_status: Get current status of a user's DigiPal") + print("- interact_with_pet: Send text message to DigiPal") + print("- apply_care_action: Apply care actions (feeding, training, etc.)") + print("- create_new_pet: Create new DigiPal for a user") + print("- get_pet_statistics: Get comprehensive pet statistics") + print("- trigger_evolution: Manually trigger evolution") + print("- get_available_actions: Get available care actions") + print() + print("Server is ready to accept MCP connections...") + + # Start the server + await mcp_server.start_server() + + except KeyboardInterrupt: + print("\nShutting down server...") + except Exception as e: + print(f"Error starting server: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +async def demo_mode(args): + """Run interactive demo of MCP server functionality.""" + print("DigiPal MCP Server Demo Mode") + print("=" * 40) + + # Use temporary database for demo + with tempfile.NamedTemporaryFile(delete=False, suffix='.db') as f: + temp_db_path = f.name + + try: + # Initialize components + print("Initializing DigiPal components...") + storage_manager = StorageManager(temp_db_path, "demo_assets") + ai_communication = AICommunication() + digipal_core = DigiPalCore(storage_manager, ai_communication) + + # Initialize MCP server + mcp_server = MCPServer(digipal_core, "demo-server") + + # Create demo user + demo_user = "demo_user" + storage_manager.create_user(demo_user, "Demo User") + mcp_server.authenticate_user(demo_user) + + print(f"โ Demo user '{demo_user}' created and authenticated") + + while True: + print("\nAvailable MCP Tools:") + print("1. create_new_pet - Create a new DigiPal") + print("2. get_pet_status - Get pet status") + print("3. interact_with_pet - Send message to pet") + print("4. apply_care_action - Apply care action") + print("5. get_pet_statistics - Get detailed statistics") + print("6. trigger_evolution - Force evolution") + print("7. get_available_actions - List care actions") + print("0. Exit demo") + + choice = input("\nSelect tool (0-7): ").strip() + + if choice == "0": + break + elif choice == "1": + await demo_create_pet(mcp_server, demo_user) + elif choice == "2": + await demo_get_status(mcp_server, demo_user) + elif choice == "3": + await demo_interact(mcp_server, demo_user) + elif choice == "4": + await demo_care_action(mcp_server, demo_user) + elif choice == "5": + await demo_statistics(mcp_server, demo_user) + elif choice == "6": + await demo_evolution(mcp_server, demo_user) + elif choice == "7": + await demo_available_actions(mcp_server, demo_user) + else: + print("Invalid choice. Please select 0-7.") + + print("\nDemo completed. Cleaning up...") + mcp_server.shutdown() + + except KeyboardInterrupt: + print("\nDemo interrupted by user.") + except Exception as e: + print(f"Demo error: {e}") + import traceback + traceback.print_exc() + finally: + # Cleanup + import os + if os.path.exists(temp_db_path): + os.unlink(temp_db_path) + + +async def demo_create_pet(mcp_server, user_id): + """Demo create_new_pet tool.""" + print("\n--- Create New Pet ---") + + # Check if pet already exists + existing = await mcp_server._handle_get_pet_status({"user_id": user_id}) + if not existing.isError: + print("User already has a pet. Cannot create another.") + return + + egg_type = input("Enter egg type (red/blue/green): ").strip().lower() + if egg_type not in ["red", "blue", "green"]: + print("Invalid egg type. Using 'red'.") + egg_type = "red" + + name = input("Enter pet name (or press Enter for 'DigiPal'): ").strip() + if not name: + name = "DigiPal" + + result = await mcp_server._handle_create_new_pet({ + "user_id": user_id, + "egg_type": egg_type, + "name": name + }) + + print(f"\nResult: {result.content[0].text}") + + +async def demo_get_status(mcp_server, user_id): + """Demo get_pet_status tool.""" + print("\n--- Get Pet Status ---") + + result = await mcp_server._handle_get_pet_status({"user_id": user_id}) + print(f"\nResult:\n{result.content[0].text}") + + +async def demo_interact(mcp_server, user_id): + """Demo interact_with_pet tool.""" + print("\n--- Interact with Pet ---") + + message = input("Enter message to send to pet: ").strip() + if not message: + message = "Hello!" + + result = await mcp_server._handle_interact_with_pet({ + "user_id": user_id, + "message": message + }) + + print(f"\nResult: {result.content[0].text}") + + +async def demo_care_action(mcp_server, user_id): + """Demo apply_care_action tool.""" + print("\n--- Apply Care Action ---") + + # Show available actions first + actions_result = await mcp_server._handle_get_available_actions({"user_id": user_id}) + if not actions_result.isError: + print("Available actions:") + print(actions_result.content[0].text) + + action = input("\nEnter care action: ").strip().lower() + if not action: + action = "meat" + + result = await mcp_server._handle_apply_care_action({ + "user_id": user_id, + "action": action + }) + + print(f"\nResult: {result.content[0].text}") + + +async def demo_statistics(mcp_server, user_id): + """Demo get_pet_statistics tool.""" + print("\n--- Get Pet Statistics ---") + + result = await mcp_server._handle_get_pet_statistics({"user_id": user_id}) + print(f"\nResult:\n{result.content[0].text}") + + +async def demo_evolution(mcp_server, user_id): + """Demo trigger_evolution tool.""" + print("\n--- Trigger Evolution ---") + + force = input("Force evolution regardless of requirements? (y/n): ").strip().lower() + force_bool = force.startswith('y') + + result = await mcp_server._handle_trigger_evolution({ + "user_id": user_id, + "force": force_bool + }) + + print(f"\nResult: {result.content[0].text}") + + +async def demo_available_actions(mcp_server, user_id): + """Demo get_available_actions tool.""" + print("\n--- Get Available Actions ---") + + result = await mcp_server._handle_get_available_actions({"user_id": user_id}) + print(f"\nResult:\n{result.content[0].text}") + + +def main(): + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + description="DigiPal MCP Server CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Start MCP server with default settings + python -m digipal.mcp.cli start + + # Start with custom database and assets + python -m digipal.mcp.cli start --database /path/to/digipal.db --assets /path/to/assets + + # Run interactive demo + python -m digipal.mcp.cli demo + + # Start with debug logging + python -m digipal.mcp.cli start --log-level DEBUG + """ + ) + + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default="INFO", + help="Set logging level (default: INFO)" + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Start server command + start_parser = subparsers.add_parser("start", help="Start MCP server") + start_parser.add_argument( + "--database", + default="digipal.db", + help="Path to SQLite database file (default: digipal.db)" + ) + start_parser.add_argument( + "--assets", + default="assets", + help="Path to assets directory (default: assets)" + ) + start_parser.add_argument( + "--name", + default="digipal-mcp-server", + help="MCP server name (default: digipal-mcp-server)" + ) + + # Demo command + demo_parser = subparsers.add_parser("demo", help="Run interactive demo") + + args = parser.parse_args() + + # Setup logging + setup_logging(args.log_level) + + if args.command == "start": + asyncio.run(start_server(args)) + elif args.command == "demo": + asyncio.run(demo_mode(args)) + else: + parser.print_help() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/digipal/mcp/server.py b/digipal/mcp/server.py new file mode 100644 index 0000000000000000000000000000000000000000..50dcaaf3a906408872f89cd84e6cbc8efc192dcd --- /dev/null +++ b/digipal/mcp/server.py @@ -0,0 +1,671 @@ +""" +MCP Server implementation for DigiPal. + +This module provides the MCPServer class that implements the Model Context Protocol +for external system integration with DigiPal functionality. +""" + +import logging +import asyncio +from typing import Dict, List, Optional, Any, Sequence +from datetime import datetime + +from mcp.server import Server +from mcp.server.models import InitializationOptions +from mcp.types import ( + Tool, + TextContent, + ImageContent, + EmbeddedResource, + CallToolResult, + ListToolsResult +) + +from ..core.digipal_core import DigiPalCore, PetState +from ..core.models import DigiPal, Interaction +from ..core.enums import EggType, LifeStage, CareActionType + +logger = logging.getLogger(__name__) + + +class MCPServer: + """ + MCP Server implementation for DigiPal. + + Provides MCP protocol compliance for external system integration, + allowing other AI systems to interact with DigiPal pets. + """ + + def __init__(self, digipal_core: DigiPalCore, server_name: str = "digipal-mcp-server"): + """ + Initialize MCP Server. + + Args: + digipal_core: DigiPal core engine instance + server_name: Name of the MCP server + """ + self.digipal_core = digipal_core + self.server_name = server_name + self.server = Server(server_name) + + # Authentication and permissions + self.authenticated_users: Dict[str, bool] = {} + self.user_permissions: Dict[str, List[str]] = {} + + # Register MCP handlers + self._register_handlers() + + logger.info(f"MCPServer initialized: {server_name}") + + def _register_handlers(self): + """Register MCP protocol handlers.""" + + @self.server.list_tools() + async def list_tools() -> ListToolsResult: + """List available DigiPal interaction tools.""" + tools = [ + Tool( + name="get_pet_status", + description="Get the current status and attributes of a user's DigiPal", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User ID to get pet status for" + } + }, + "required": ["user_id"] + } + ), + Tool( + name="interact_with_pet", + description="Send a text message to interact with a user's DigiPal", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User ID whose pet to interact with" + }, + "message": { + "type": "string", + "description": "Message to send to the DigiPal" + } + }, + "required": ["user_id", "message"] + } + ), + Tool( + name="apply_care_action", + description="Apply a care action to a user's DigiPal (feeding, training, etc.)", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User ID whose pet to care for" + }, + "action": { + "type": "string", + "description": "Care action to apply", + "enum": [ + "meat", "fish", "vegetables", "protein", "vitamins", + "strength_training", "speed_training", "defense_training", + "brain_training", "rest", "play", "praise", "scold" + ] + } + }, + "required": ["user_id", "action"] + } + ), + Tool( + name="create_new_pet", + description="Create a new DigiPal for a user", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User ID to create pet for" + }, + "egg_type": { + "type": "string", + "description": "Type of egg to create", + "enum": ["red", "blue", "green"] + }, + "name": { + "type": "string", + "description": "Name for the new DigiPal", + "default": "DigiPal" + } + }, + "required": ["user_id", "egg_type"] + } + ), + Tool( + name="get_pet_statistics", + description="Get comprehensive statistics and analysis for a user's DigiPal", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User ID to get statistics for" + } + }, + "required": ["user_id"] + } + ), + Tool( + name="trigger_evolution", + description="Manually trigger evolution for a user's DigiPal if eligible", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User ID whose pet to evolve" + }, + "force": { + "type": "boolean", + "description": "Force evolution regardless of requirements", + "default": False + } + }, + "required": ["user_id"] + } + ), + Tool( + name="get_available_actions", + description="Get list of available care actions for a user's DigiPal", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User ID to get available actions for" + } + }, + "required": ["user_id"] + } + ) + ] + + return ListToolsResult(tools=tools) + + @self.server.call_tool() + async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult: + """Handle tool calls from MCP clients.""" + try: + # Validate user authentication and permissions + user_id = arguments.get("user_id") + if not user_id: + return CallToolResult( + content=[TextContent( + type="text", + text="Error: user_id is required for all DigiPal operations" + )], + isError=True + ) + + # Check authentication (simplified for now) + if not self._is_user_authenticated(user_id): + return CallToolResult( + content=[TextContent( + type="text", + text="Error: User not authenticated" + )], + isError=True + ) + + # Check permissions + if not self._has_permission(user_id, name): + return CallToolResult( + content=[TextContent( + type="text", + text=f"Error: User does not have permission for action: {name}" + )], + isError=True + ) + + # Route to appropriate handler + if name == "get_pet_status": + return await self._handle_get_pet_status(arguments) + elif name == "interact_with_pet": + return await self._handle_interact_with_pet(arguments) + elif name == "apply_care_action": + return await self._handle_apply_care_action(arguments) + elif name == "create_new_pet": + return await self._handle_create_new_pet(arguments) + elif name == "get_pet_statistics": + return await self._handle_get_pet_statistics(arguments) + elif name == "trigger_evolution": + return await self._handle_trigger_evolution(arguments) + elif name == "get_available_actions": + return await self._handle_get_available_actions(arguments) + else: + return CallToolResult( + content=[TextContent( + type="text", + text=f"Error: Unknown tool: {name}" + )], + isError=True + ) + + except Exception as e: + logger.error(f"Error handling tool call {name}: {e}") + return CallToolResult( + content=[TextContent( + type="text", + text=f"Error: {str(e)}" + )], + isError=True + ) + + async def _handle_get_pet_status(self, arguments: Dict[str, Any]) -> CallToolResult: + """Handle get_pet_status tool call.""" + user_id = arguments.get("user_id") + if not user_id: + return CallToolResult( + content=[TextContent( + type="text", + text="Error: user_id is required for get_pet_status" + )], + isError=True + ) + + pet_state = self.digipal_core.get_pet_state(user_id) + if not pet_state: + return CallToolResult( + content=[TextContent( + type="text", + text=f"No DigiPal found for user: {user_id}" + )], + isError=True + ) + + status_dict = pet_state.to_dict() + status_text = self._format_pet_status(status_dict) + + return CallToolResult( + content=[TextContent( + type="text", + text=status_text + )] + ) + + async def _handle_interact_with_pet(self, arguments: Dict[str, Any]) -> CallToolResult: + """Handle interact_with_pet tool call.""" + user_id = arguments["user_id"] + message = arguments["message"] + + success, interaction = self.digipal_core.process_interaction(user_id, message) + + if not success: + return CallToolResult( + content=[TextContent( + type="text", + text=f"Interaction failed: {interaction.pet_response}" + )], + isError=True + ) + + response_text = f"DigiPal Response: {interaction.pet_response}" + if interaction.attribute_changes: + changes = ", ".join([f"{attr}: {change:+d}" for attr, change in interaction.attribute_changes.items()]) + response_text += f"\nAttribute Changes: {changes}" + + return CallToolResult( + content=[TextContent( + type="text", + text=response_text + )] + ) + + async def _handle_apply_care_action(self, arguments: Dict[str, Any]) -> CallToolResult: + """Handle apply_care_action tool call.""" + user_id = arguments["user_id"] + action = arguments["action"] + + success, interaction = self.digipal_core.apply_care_action(user_id, action) + + if not success: + return CallToolResult( + content=[TextContent( + type="text", + text=f"Care action failed: {interaction.pet_response}" + )], + isError=True + ) + + response_text = f"Care Action Applied: {action}\nResult: {interaction.pet_response}" + if interaction.attribute_changes: + changes = ", ".join([f"{attr}: {change:+d}" for attr, change in interaction.attribute_changes.items()]) + response_text += f"\nAttribute Changes: {changes}" + + return CallToolResult( + content=[TextContent( + type="text", + text=response_text + )] + ) + + async def _handle_create_new_pet(self, arguments: Dict[str, Any]) -> CallToolResult: + """Handle create_new_pet tool call.""" + user_id = arguments["user_id"] + egg_type_str = arguments["egg_type"] + name = arguments.get("name", "DigiPal") + + # Check if user already has a pet + existing_pet = self.digipal_core.load_existing_pet(user_id) + if existing_pet: + return CallToolResult( + content=[TextContent( + type="text", + text=f"User {user_id} already has a DigiPal: {existing_pet.name}" + )], + isError=True + ) + + # Convert egg type string to enum + try: + egg_type = EggType(egg_type_str.lower()) + except ValueError: + return CallToolResult( + content=[TextContent( + type="text", + text=f"Invalid egg type: {egg_type_str}. Must be red, blue, or green." + )], + isError=True + ) + + try: + new_pet = self.digipal_core.create_new_pet(egg_type, user_id, name) + + return CallToolResult( + content=[TextContent( + type="text", + text=f"Successfully created new {egg_type_str} DigiPal '{name}' for user {user_id}. Pet ID: {new_pet.id}" + )] + ) + + except Exception as e: + return CallToolResult( + content=[TextContent( + type="text", + text=f"Failed to create new pet: {str(e)}" + )], + isError=True + ) + + async def _handle_get_pet_statistics(self, arguments: Dict[str, Any]) -> CallToolResult: + """Handle get_pet_statistics tool call.""" + user_id = arguments["user_id"] + + statistics = self.digipal_core.get_pet_statistics(user_id) + if not statistics: + return CallToolResult( + content=[TextContent( + type="text", + text=f"No DigiPal found for user: {user_id}" + )], + isError=True + ) + + stats_text = self._format_pet_statistics(statistics) + + return CallToolResult( + content=[TextContent( + type="text", + text=stats_text + )] + ) + + async def _handle_trigger_evolution(self, arguments: Dict[str, Any]) -> CallToolResult: + """Handle trigger_evolution tool call.""" + user_id = arguments["user_id"] + force = arguments.get("force", False) + + success, evolution_result = self.digipal_core.trigger_evolution(user_id, force) + + if not success: + return CallToolResult( + content=[TextContent( + type="text", + text=f"Evolution failed: {evolution_result.message}" + )], + isError=True + ) + + response_text = f"Evolution successful!\nFrom: {evolution_result.old_stage.value}\nTo: {evolution_result.new_stage.value}" + if evolution_result.attribute_changes: + changes = ", ".join([f"{attr}: {change:+d}" for attr, change in evolution_result.attribute_changes.items()]) + response_text += f"\nAttribute Changes: {changes}" + + return CallToolResult( + content=[TextContent( + type="text", + text=response_text + )] + ) + + async def _handle_get_available_actions(self, arguments: Dict[str, Any]) -> CallToolResult: + """Handle get_available_actions tool call.""" + user_id = arguments["user_id"] + + actions = self.digipal_core.get_care_actions(user_id) + if not actions: + return CallToolResult( + content=[TextContent( + type="text", + text=f"No DigiPal found for user: {user_id}" + )], + isError=True + ) + + actions_text = "Available Care Actions:\n" + "\n".join([f"- {action}" for action in actions]) + + return CallToolResult( + content=[TextContent( + type="text", + text=actions_text + )] + ) + + def _format_pet_status(self, status_dict: Dict[str, Any]) -> str: + """Format pet status dictionary into readable text.""" + basic = status_dict.get('basic_info', {}) + attrs = status_dict.get('attributes', {}) + status = status_dict.get('status', {}) + + text = f"DigiPal Status Report\n" + text += f"=====================\n" + text += f"Name: {status_dict.get('name', 'Unknown')}\n" + text += f"Life Stage: {status_dict.get('life_stage', basic.get('life_stage', 'Unknown'))}\n" + text += f"Generation: {basic.get('generation', 0)}\n" + text += f"Age: {status.get('age_hours', 0):.1f} hours\n" + text += f"Status: {status.get('status_summary', 'Unknown')}\n" + text += f"Needs Attention: {'Yes' if status.get('needs_attention', False) else 'No'}\n" + text += f"Evolution Ready: {'Yes' if status.get('evolution_ready', False) else 'No'}\n\n" + + text += f"Attributes:\n" + text += f"-----------\n" + text += f"HP: {attrs.get('hp', 0)}\n" + text += f"MP: {attrs.get('mp', 0)}\n" + text += f"Offense: {attrs.get('offense', 0)}\n" + text += f"Defense: {attrs.get('defense', 0)}\n" + text += f"Speed: {attrs.get('speed', 0)}\n" + text += f"Brains: {attrs.get('brains', 0)}\n" + text += f"Discipline: {attrs.get('discipline', 0)}\n" + text += f"Happiness: {attrs.get('happiness', 0)}\n" + text += f"Weight: {attrs.get('weight', 0)}\n" + text += f"Energy: {attrs.get('energy', 0)}\n" + text += f"Care Mistakes: {attrs.get('care_mistakes', 0)}\n" + + return text + + def _format_pet_statistics(self, statistics: Dict[str, Any]) -> str: + """Format pet statistics dictionary into readable text.""" + basic = statistics.get('basic_info', {}) + attrs = statistics.get('attributes', {}) + care = statistics.get('care_assessment', {}) + evolution = statistics.get('evolution_status', {}) + + text = f"DigiPal Statistics Report\n" + text += f"=========================\n" + text += f"Name: {basic.get('name', 'Unknown')}\n" + text += f"ID: {basic.get('id', 'Unknown')}\n" + text += f"Life Stage: {basic.get('life_stage', 'Unknown')}\n" + text += f"Generation: {basic.get('generation', 0)}\n" + text += f"Age: {basic.get('age_hours', 0):.1f} hours\n" + text += f"Egg Type: {basic.get('egg_type', 'Unknown')}\n\n" + + text += f"Current Attributes:\n" + text += f"------------------\n" + for attr, value in attrs.items(): + text += f"{attr.capitalize()}: {value}\n" + + text += f"\nCare Assessment:\n" + text += f"---------------\n" + text += f"Care Quality: {care.get('care_quality', 'Unknown')}\n" + text += f"Overall Score: {care.get('overall_score', 0)}/100\n" + + text += f"\nEvolution Status:\n" + text += f"----------------\n" + text += f"Eligible for Evolution: {'Yes' if evolution.get('eligible', False) else 'No'}\n" + if evolution.get('next_stage'): + text += f"Next Stage: {evolution['next_stage']}\n" + + personality = statistics.get('personality_traits', {}) + if personality: + text += f"\nPersonality Traits:\n" + text += f"------------------\n" + for trait, value in personality.items(): + text += f"{trait.capitalize()}: {value:.2f}\n" + + learned = statistics.get('learned_commands', []) + if learned: + text += f"\nLearned Commands:\n" + text += f"----------------\n" + text += ", ".join(learned) + + return text + + def _is_user_authenticated(self, user_id: str) -> bool: + """ + Check if user is authenticated. + + For now, this is a simplified implementation. + In production, this would integrate with proper authentication. + """ + # For development/demo purposes, allow all users + # In production, implement proper authentication + return True + + def _has_permission(self, user_id: str, action: str) -> bool: + """ + Check if user has permission for specific action. + + Args: + user_id: User ID to check + action: Action name to check permission for + + Returns: + True if user has permission + """ + # For development/demo purposes, allow all actions for authenticated users + # In production, implement proper permission system + if not self._is_user_authenticated(user_id): + return False + + # Check user-specific permissions + user_perms = self.user_permissions.get(user_id, []) + if user_perms and action not in user_perms: + return False + + return True + + def authenticate_user(self, user_id: str, token: Optional[str] = None) -> bool: + """ + Authenticate a user for MCP access. + + Args: + user_id: User ID to authenticate + token: Authentication token (optional for now) + + Returns: + True if authentication successful + """ + # Simplified authentication for development + # In production, validate token against HuggingFace or other auth provider + self.authenticated_users[user_id] = True + logger.info(f"User {user_id} authenticated for MCP access") + return True + + def set_user_permissions(self, user_id: str, permissions: List[str]): + """ + Set permissions for a user. + + Args: + user_id: User ID + permissions: List of allowed action names + """ + self.user_permissions[user_id] = permissions + logger.info(f"Set permissions for user {user_id}: {permissions}") + + def revoke_user_access(self, user_id: str): + """ + Revoke access for a user. + + Args: + user_id: User ID to revoke access for + """ + self.authenticated_users.pop(user_id, None) + self.user_permissions.pop(user_id, None) + logger.info(f"Revoked access for user {user_id}") + + async def start_server(self, host: str = "localhost", port: int = 8000): + """ + Start the MCP server. + + Args: + host: Host to bind to + port: Port to bind to + """ + logger.info(f"Starting MCP server on {host}:{port}") + + # Start background updates for DigiPal core + self.digipal_core.start_background_updates() + + try: + # Run the MCP server + await self.server.run( + transport="stdio", # Use stdio transport for MCP + options=InitializationOptions( + server_name=self.server_name, + server_version="1.0.0" + ) + ) + except Exception as e: + logger.error(f"Error running MCP server: {e}") + raise + finally: + # Stop background updates + self.digipal_core.stop_background_updates() + + def shutdown(self): + """Shutdown the MCP server and cleanup resources.""" + logger.info("Shutting down MCP server") + + # Stop DigiPal core background updates + self.digipal_core.stop_background_updates() + + # Shutdown DigiPal core + self.digipal_core.shutdown() + + # Clear authentication data + self.authenticated_users.clear() + self.user_permissions.clear() + + logger.info("MCP server shutdown complete") \ No newline at end of file diff --git a/digipal/monitoring.py b/digipal/monitoring.py new file mode 100644 index 0000000000000000000000000000000000000000..2719076969699f33853bebbb9e9d85fe164ea1c5 --- /dev/null +++ b/digipal/monitoring.py @@ -0,0 +1,386 @@ +""" +DigiPal Monitoring and Metrics Collection +Provides Prometheus metrics and health checks for production deployment +""" + +import time +import logging +from typing import Dict, Any, Optional +from prometheus_client import Counter, Histogram, Gauge, start_http_server, generate_latest +from prometheus_client.core import CollectorRegistry +import threading +import psutil +import os + + +class DigiPalMetrics: + """Prometheus metrics collector for DigiPal""" + + def __init__(self, registry: Optional[CollectorRegistry] = None): + self.registry = registry or CollectorRegistry() + self.logger = logging.getLogger(__name__) + + # Initialize metrics + self._init_metrics() + + # System metrics + self._init_system_metrics() + + # Start background metrics collection + self._start_background_collection() + + def _init_metrics(self): + """Initialize application-specific metrics""" + # Request metrics + self.http_requests_total = Counter( + 'digipal_http_requests_total', + 'Total HTTP requests', + ['method', 'endpoint', 'status'], + registry=self.registry + ) + + self.http_request_duration = Histogram( + 'digipal_http_request_duration_seconds', + 'HTTP request duration', + ['method', 'endpoint'], + registry=self.registry + ) + + # Pet interaction metrics + self.pet_interactions_total = Counter( + 'digipal_pet_interactions_total', + 'Total pet interactions', + ['interaction_type', 'success'], + registry=self.registry + ) + + self.active_pets = Gauge( + 'digipal_active_pets', + 'Number of active pets', + registry=self.registry + ) + + self.pet_evolutions_total = Counter( + 'digipal_pet_evolutions_total', + 'Total pet evolutions', + ['from_stage', 'to_stage'], + registry=self.registry + ) + + # AI model metrics + self.ai_model_requests_total = Counter( + 'digipal_ai_model_requests_total', + 'Total AI model requests', + ['model_type', 'success'], + registry=self.registry + ) + + self.ai_model_response_time = Histogram( + 'digipal_ai_model_response_time_seconds', + 'AI model response time', + ['model_type'], + registry=self.registry + ) + + # Database metrics + self.database_operations_total = Counter( + 'digipal_database_operations_total', + 'Total database operations', + ['operation', 'success'], + registry=self.registry + ) + + self.database_errors_total = Counter( + 'digipal_database_errors_total', + 'Total database errors', + ['error_type'], + registry=self.registry + ) + + # MCP server metrics + self.mcp_requests_total = Counter( + 'digipal_mcp_requests_total', + 'Total MCP requests', + ['tool_name', 'success'], + registry=self.registry + ) + + # Error metrics + self.errors_total = Counter( + 'digipal_errors_total', + 'Total application errors', + ['error_type', 'severity'], + registry=self.registry + ) + + # Cache metrics + self.cache_hits_total = Counter( + 'digipal_cache_hits_total', + 'Total cache hits', + ['cache_type'], + registry=self.registry + ) + + self.cache_misses_total = Counter( + 'digipal_cache_misses_total', + 'Total cache misses', + ['cache_type'], + registry=self.registry + ) + + def _init_system_metrics(self): + """Initialize system-level metrics""" + self.memory_usage_bytes = Gauge( + 'digipal_memory_usage_bytes', + 'Memory usage in bytes', + registry=self.registry + ) + + self.cpu_usage_percent = Gauge( + 'digipal_cpu_usage_percent', + 'CPU usage percentage', + registry=self.registry + ) + + self.disk_usage_bytes = Gauge( + 'digipal_disk_usage_bytes', + 'Disk usage in bytes', + ['path'], + registry=self.registry + ) + + self.uptime_seconds = Gauge( + 'digipal_uptime_seconds', + 'Application uptime in seconds', + registry=self.registry + ) + + self.start_time = time.time() + + def _start_background_collection(self): + """Start background thread for system metrics collection""" + def collect_system_metrics(): + while True: + try: + # Memory usage + process = psutil.Process() + memory_info = process.memory_info() + self.memory_usage_bytes.set(memory_info.rss) + + # CPU usage + cpu_percent = process.cpu_percent() + self.cpu_usage_percent.set(cpu_percent) + + # Disk usage + disk_usage = psutil.disk_usage('/') + self.disk_usage_bytes.labels(path='/').set(disk_usage.used) + + # Uptime + uptime = time.time() - self.start_time + self.uptime_seconds.set(uptime) + + except Exception as e: + self.logger.error(f"Error collecting system metrics: {e}") + + time.sleep(30) # Collect every 30 seconds + + thread = threading.Thread(target=collect_system_metrics, daemon=True) + thread.start() + + # Metric recording methods + def record_http_request(self, method: str, endpoint: str, status: int, duration: float): + """Record HTTP request metrics""" + self.http_requests_total.labels(method=method, endpoint=endpoint, status=str(status)).inc() + self.http_request_duration.labels(method=method, endpoint=endpoint).observe(duration) + + def record_pet_interaction(self, interaction_type: str, success: bool): + """Record pet interaction metrics""" + self.pet_interactions_total.labels( + interaction_type=interaction_type, + success=str(success) + ).inc() + + def set_active_pets(self, count: int): + """Set number of active pets""" + self.active_pets.set(count) + + def record_pet_evolution(self, from_stage: str, to_stage: str): + """Record pet evolution""" + self.pet_evolutions_total.labels(from_stage=from_stage, to_stage=to_stage).inc() + + def record_ai_model_request(self, model_type: str, success: bool, duration: float): + """Record AI model request metrics""" + self.ai_model_requests_total.labels(model_type=model_type, success=str(success)).inc() + self.ai_model_response_time.labels(model_type=model_type).observe(duration) + + def record_database_operation(self, operation: str, success: bool): + """Record database operation metrics""" + self.database_operations_total.labels(operation=operation, success=str(success)).inc() + + def record_database_error(self, error_type: str): + """Record database error""" + self.database_errors_total.labels(error_type=error_type).inc() + + def record_mcp_request(self, tool_name: str, success: bool): + """Record MCP request metrics""" + self.mcp_requests_total.labels(tool_name=tool_name, success=str(success)).inc() + + def record_error(self, error_type: str, severity: str): + """Record application error""" + self.errors_total.labels(error_type=error_type, severity=severity).inc() + + def record_cache_hit(self, cache_type: str): + """Record cache hit""" + self.cache_hits_total.labels(cache_type=cache_type).inc() + + def record_cache_miss(self, cache_type: str): + """Record cache miss""" + self.cache_misses_total.labels(cache_type=cache_type).inc() + + +class HealthChecker: + """Health check system for DigiPal""" + + def __init__(self): + self.logger = logging.getLogger(__name__) + self.checks = {} + self.last_check_time = {} + self.check_results = {} + + def register_check(self, name: str, check_func, timeout: int = 10): + """Register a health check""" + self.checks[name] = { + 'func': check_func, + 'timeout': timeout + } + self.check_results[name] = {'status': 'unknown', 'message': 'Not checked yet'} + + def run_check(self, name: str) -> Dict[str, Any]: + """Run a specific health check""" + if name not in self.checks: + return {'status': 'error', 'message': f'Check {name} not found'} + + check = self.checks[name] + start_time = time.time() + + try: + result = check['func']() + duration = time.time() - start_time + + if duration > check['timeout']: + return { + 'status': 'warning', + 'message': f'Check took {duration:.2f}s (timeout: {check["timeout"]}s)', + 'duration': duration + } + + return { + 'status': 'healthy', + 'message': result.get('message', 'OK'), + 'duration': duration, + **result + } + + except Exception as e: + duration = time.time() - start_time + self.logger.error(f"Health check {name} failed: {e}") + return { + 'status': 'error', + 'message': str(e), + 'duration': duration + } + + def run_all_checks(self) -> Dict[str, Any]: + """Run all registered health checks""" + results = {} + overall_status = 'healthy' + + for name in self.checks: + result = self.run_check(name) + results[name] = result + self.check_results[name] = result + self.last_check_time[name] = time.time() + + if result['status'] == 'error': + overall_status = 'unhealthy' + elif result['status'] == 'warning' and overall_status == 'healthy': + overall_status = 'warning' + + return { + 'status': overall_status, + 'timestamp': time.time(), + 'checks': results + } + + def get_health_status(self) -> Dict[str, Any]: + """Get current health status""" + return self.run_all_checks() + + +# Global instances +metrics = DigiPalMetrics() +health_checker = HealthChecker() + + +def start_metrics_server(port: int = 8000): + """Start Prometheus metrics server""" + try: + start_http_server(port, registry=metrics.registry) + logging.info(f"Metrics server started on port {port}") + except Exception as e: + logging.error(f"Failed to start metrics server: {e}") + + +def get_metrics() -> str: + """Get current metrics in Prometheus format""" + return generate_latest(metrics.registry) + + +def setup_default_health_checks(): + """Setup default health checks for DigiPal components""" + + def check_database(): + """Check database connectivity""" + try: + # This would be implemented with actual database check + return {'message': 'Database connection OK'} + except Exception as e: + raise Exception(f"Database check failed: {e}") + + def check_ai_models(): + """Check AI model availability""" + try: + # This would be implemented with actual model check + return {'message': 'AI models loaded and ready'} + except Exception as e: + raise Exception(f"AI model check failed: {e}") + + def check_disk_space(): + """Check available disk space""" + try: + disk_usage = psutil.disk_usage('/') + free_percent = (disk_usage.free / disk_usage.total) * 100 + + if free_percent < 10: + raise Exception(f"Low disk space: {free_percent:.1f}% free") + + return {'message': f'Disk space OK: {free_percent:.1f}% free'} + except Exception as e: + raise Exception(f"Disk space check failed: {e}") + + def check_memory(): + """Check memory usage""" + try: + memory = psutil.virtual_memory() + if memory.percent > 90: + raise Exception(f"High memory usage: {memory.percent}%") + + return {'message': f'Memory usage OK: {memory.percent}%'} + except Exception as e: + raise Exception(f"Memory check failed: {e}") + + # Register health checks + health_checker.register_check('database', check_database) + health_checker.register_check('ai_models', check_ai_models) + health_checker.register_check('disk_space', check_disk_space) + health_checker.register_check('memory', check_memory) \ No newline at end of file diff --git a/digipal/storage/__init__.py b/digipal/storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ddc18123a60c66f2cf2ec7a46199b70d8422a8ef --- /dev/null +++ b/digipal/storage/__init__.py @@ -0,0 +1,8 @@ +""" +Storage package for DigiPal persistence layer. +""" + +from .storage_manager import StorageManager +from .database import DatabaseSchema, DatabaseConnection + +__all__ = ['StorageManager', 'DatabaseSchema', 'DatabaseConnection'] \ No newline at end of file diff --git a/digipal/storage/__pycache__/__init__.cpython-312.pyc b/digipal/storage/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cea8e990382a3cff1f29ef482be6879a6e26b23e Binary files /dev/null and b/digipal/storage/__pycache__/__init__.cpython-312.pyc differ diff --git a/digipal/storage/__pycache__/backup_recovery.cpython-312.pyc b/digipal/storage/__pycache__/backup_recovery.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6abfbed37ec13f847ccd1633a82b668928ab9584 Binary files /dev/null and b/digipal/storage/__pycache__/backup_recovery.cpython-312.pyc differ diff --git a/digipal/storage/__pycache__/database.cpython-312.pyc b/digipal/storage/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..177ec2351eba067f8a8099e91efb27f71626ccf6 Binary files /dev/null and b/digipal/storage/__pycache__/database.cpython-312.pyc differ diff --git a/digipal/storage/__pycache__/storage_manager.cpython-312.pyc b/digipal/storage/__pycache__/storage_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85c02a56a984112f74eac36ae556f579f7dabf5e Binary files /dev/null and b/digipal/storage/__pycache__/storage_manager.cpython-312.pyc differ diff --git a/digipal/storage/backup_recovery.py b/digipal/storage/backup_recovery.py new file mode 100644 index 0000000000000000000000000000000000000000..113567fb3a6f0e9f1c5b78156db666f769090327 --- /dev/null +++ b/digipal/storage/backup_recovery.py @@ -0,0 +1,678 @@ +""" +Backup and recovery system for DigiPal data. + +This module provides comprehensive backup and recovery functionality +with automatic scheduling, data validation, and recovery mechanisms. +""" + +import json +import logging +import shutil +import threading +import time +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional, Any, Tuple +import sqlite3 +import hashlib + +from ..core.exceptions import StorageError, RecoveryError, DigiPalException, ErrorSeverity +from ..core.error_handler import with_error_handling, with_retry, RetryConfig +from ..core.models import DigiPal + +logger = logging.getLogger(__name__) + + +class BackupConfig: + """Configuration for backup operations.""" + + def __init__( + self, + backup_interval_hours: int = 6, + max_backups: int = 10, + compress_backups: bool = True, + verify_backups: bool = True, + auto_cleanup: bool = True, + backup_on_critical_operations: bool = True + ): + """ + Initialize backup configuration. + + Args: + backup_interval_hours: Hours between automatic backups + max_backups: Maximum number of backups to keep + compress_backups: Whether to compress backup files + verify_backups: Whether to verify backup integrity + auto_cleanup: Whether to automatically clean old backups + backup_on_critical_operations: Whether to backup before critical operations + """ + self.backup_interval_hours = backup_interval_hours + self.max_backups = max_backups + self.compress_backups = compress_backups + self.verify_backups = verify_backups + self.auto_cleanup = auto_cleanup + self.backup_on_critical_operations = backup_on_critical_operations + + +class BackupMetadata: + """Metadata for backup files.""" + + def __init__( + self, + backup_id: str, + timestamp: datetime, + backup_type: str, + file_path: str, + checksum: str, + size_bytes: int, + user_id: Optional[str] = None, + pet_id: Optional[str] = None, + description: Optional[str] = None + ): + """Initialize backup metadata.""" + self.backup_id = backup_id + self.timestamp = timestamp + self.backup_type = backup_type + self.file_path = file_path + self.checksum = checksum + self.size_bytes = size_bytes + self.user_id = user_id + self.pet_id = pet_id + self.description = description + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'backup_id': self.backup_id, + 'timestamp': self.timestamp.isoformat(), + 'backup_type': self.backup_type, + 'file_path': self.file_path, + 'checksum': self.checksum, + 'size_bytes': self.size_bytes, + 'user_id': self.user_id, + 'pet_id': self.pet_id, + 'description': self.description + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'BackupMetadata': + """Create from dictionary.""" + return cls( + backup_id=data['backup_id'], + timestamp=datetime.fromisoformat(data['timestamp']), + backup_type=data['backup_type'], + file_path=data['file_path'], + checksum=data['checksum'], + size_bytes=data['size_bytes'], + user_id=data.get('user_id'), + pet_id=data.get('pet_id'), + description=data.get('description') + ) + + +class BackupRecoveryManager: + """Manages backup and recovery operations for DigiPal data.""" + + def __init__(self, db_path: str, backup_dir: str, config: Optional[BackupConfig] = None): + """ + Initialize backup and recovery manager. + + Args: + db_path: Path to main database + backup_dir: Directory for backup files + config: Backup configuration + """ + self.db_path = Path(db_path) + self.backup_dir = Path(backup_dir) + self.config = config or BackupConfig() + + # Create backup directory + self.backup_dir.mkdir(parents=True, exist_ok=True) + + # Metadata storage + self.metadata_file = self.backup_dir / "backup_metadata.json" + self.metadata: Dict[str, BackupMetadata] = {} + + # Background backup thread + self._backup_thread = None + self._stop_backup_thread = False + + # Load existing metadata + self._load_metadata() + + logger.info(f"BackupRecoveryManager initialized: {backup_dir}") + + def _load_metadata(self): + """Load backup metadata from file.""" + try: + if self.metadata_file.exists(): + with open(self.metadata_file, 'r') as f: + metadata_dict = json.load(f) + self.metadata = { + k: BackupMetadata.from_dict(v) + for k, v in metadata_dict.items() + } + logger.info(f"Loaded {len(self.metadata)} backup metadata entries") + except Exception as e: + logger.error(f"Failed to load backup metadata: {e}") + self.metadata = {} + + def _save_metadata(self): + """Save backup metadata to file.""" + try: + metadata_dict = {k: v.to_dict() for k, v in self.metadata.items()} + with open(self.metadata_file, 'w') as f: + json.dump(metadata_dict, f, indent=2) + except Exception as e: + logger.error(f"Failed to save backup metadata: {e}") + + def _calculate_checksum(self, file_path: Path) -> str: + """Calculate SHA-256 checksum of a file.""" + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + sha256_hash.update(chunk) + return sha256_hash.hexdigest() + + @with_error_handling(fallback_value=False, context={'operation': 'create_backup'}) + @with_retry(RetryConfig(max_attempts=3, retry_on=[StorageError])) + def create_backup( + self, + backup_type: str = "manual", + user_id: Optional[str] = None, + pet_id: Optional[str] = None, + description: Optional[str] = None + ) -> Tuple[bool, Optional[str]]: + """ + Create a backup of the database. + + Args: + backup_type: Type of backup (manual, automatic, pre_operation) + user_id: Specific user ID to backup (None for full backup) + pet_id: Specific pet ID to backup (None for user/full backup) + description: Optional description for the backup + + Returns: + Tuple of (success, backup_id) + """ + try: + # Generate backup ID + backup_id = f"{backup_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{hash(time.time()) % 10000:04d}" + + # Create backup filename + backup_filename = f"backup_{backup_id}.db" + backup_path = self.backup_dir / backup_filename + + logger.info(f"Creating backup: {backup_id}") + + if user_id or pet_id: + # Partial backup + success = self._create_partial_backup(backup_path, user_id, pet_id) + else: + # Full database backup + success = self._create_full_backup(backup_path) + + if not success: + raise StorageError(f"Failed to create backup: {backup_id}") + + # Calculate checksum and size + checksum = self._calculate_checksum(backup_path) + size_bytes = backup_path.stat().st_size + + # Verify backup if configured + if self.config.verify_backups: + if not self._verify_backup(backup_path): + backup_path.unlink() # Delete corrupted backup + raise StorageError(f"Backup verification failed: {backup_id}") + + # Compress if configured + if self.config.compress_backups: + compressed_path = self._compress_backup(backup_path) + if compressed_path: + backup_path.unlink() # Remove uncompressed version + backup_path = compressed_path + checksum = self._calculate_checksum(backup_path) + size_bytes = backup_path.stat().st_size + + # Create metadata + metadata = BackupMetadata( + backup_id=backup_id, + timestamp=datetime.now(), + backup_type=backup_type, + file_path=str(backup_path), + checksum=checksum, + size_bytes=size_bytes, + user_id=user_id, + pet_id=pet_id, + description=description + ) + + # Store metadata + self.metadata[backup_id] = metadata + self._save_metadata() + + # Cleanup old backups if configured + if self.config.auto_cleanup: + self._cleanup_old_backups() + + logger.info(f"Backup created successfully: {backup_id} ({size_bytes} bytes)") + return True, backup_id + + except Exception as e: + logger.error(f"Failed to create backup: {e}") + raise StorageError(f"Backup creation failed: {str(e)}") + + def _create_full_backup(self, backup_path: Path) -> bool: + """Create a full database backup.""" + try: + # Use SQLite backup API for consistent backup + source_conn = sqlite3.connect(str(self.db_path)) + backup_conn = sqlite3.connect(str(backup_path)) + + source_conn.backup(backup_conn) + + source_conn.close() + backup_conn.close() + + return True + + except Exception as e: + logger.error(f"Full backup failed: {e}") + return False + + def _create_partial_backup(self, backup_path: Path, user_id: Optional[str], pet_id: Optional[str]) -> bool: + """Create a partial backup for specific user or pet.""" + try: + # Create new database with same schema + source_conn = sqlite3.connect(str(self.db_path)) + backup_conn = sqlite3.connect(str(backup_path)) + + # Copy schema + source_conn.backup(backup_conn) + + # Clear all data + backup_conn.execute("DELETE FROM digipals") + backup_conn.execute("DELETE FROM interactions") + backup_conn.execute("DELETE FROM care_actions") + backup_conn.execute("DELETE FROM users") + + # Copy specific data + if pet_id: + # Backup specific pet + cursor = source_conn.execute("SELECT * FROM digipals WHERE id = ?", (pet_id,)) + pet_data = cursor.fetchone() + if pet_data: + placeholders = ','.join(['?' for _ in pet_data]) + backup_conn.execute(f"INSERT INTO digipals VALUES ({placeholders})", pet_data) + + # Copy related data + backup_conn.execute("INSERT INTO interactions SELECT * FROM interactions WHERE digipal_id = ?", (pet_id,)) + backup_conn.execute("INSERT INTO care_actions SELECT * FROM care_actions WHERE digipal_id = ?", (pet_id,)) + + # Copy user data + user_cursor = source_conn.execute("SELECT * FROM users WHERE id = (SELECT user_id FROM digipals WHERE id = ?)", (pet_id,)) + user_data = user_cursor.fetchone() + if user_data: + placeholders = ','.join(['?' for _ in user_data]) + backup_conn.execute(f"INSERT INTO users VALUES ({placeholders})", user_data) + + elif user_id: + # Backup all pets for user + backup_conn.execute("INSERT INTO users SELECT * FROM users WHERE id = ?", (user_id,)) + backup_conn.execute("INSERT INTO digipals SELECT * FROM digipals WHERE user_id = ?", (user_id,)) + backup_conn.execute("INSERT INTO interactions SELECT * FROM interactions WHERE user_id = ?", (user_id,)) + backup_conn.execute("INSERT INTO care_actions SELECT * FROM care_actions WHERE user_id = ?", (user_id,)) + + backup_conn.commit() + source_conn.close() + backup_conn.close() + + return True + + except Exception as e: + logger.error(f"Partial backup failed: {e}") + return False + + def _verify_backup(self, backup_path: Path) -> bool: + """Verify backup integrity.""" + try: + # Try to open and query the backup database + conn = sqlite3.connect(str(backup_path)) + cursor = conn.cursor() + + # Check if main tables exist and are accessible + cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'") + table_count = cursor.fetchone()[0] + + if table_count == 0: + return False + + # Try to query main tables + cursor.execute("SELECT COUNT(*) FROM digipals") + cursor.execute("SELECT COUNT(*) FROM users") + cursor.execute("SELECT COUNT(*) FROM interactions") + + conn.close() + return True + + except Exception as e: + logger.error(f"Backup verification failed: {e}") + return False + + def _compress_backup(self, backup_path: Path) -> Optional[Path]: + """Compress backup file.""" + try: + import gzip + + compressed_path = backup_path.with_suffix(backup_path.suffix + '.gz') + + with open(backup_path, 'rb') as f_in: + with gzip.open(compressed_path, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + + return compressed_path + + except Exception as e: + logger.error(f"Backup compression failed: {e}") + return None + + def _cleanup_old_backups(self): + """Clean up old backups based on configuration.""" + try: + # Sort backups by timestamp + sorted_backups = sorted( + self.metadata.values(), + key=lambda x: x.timestamp, + reverse=True + ) + + # Keep only the most recent backups + backups_to_delete = sorted_backups[self.config.max_backups:] + + for backup in backups_to_delete: + try: + backup_path = Path(backup.file_path) + if backup_path.exists(): + backup_path.unlink() + + # Remove from metadata + del self.metadata[backup.backup_id] + + logger.info(f"Cleaned up old backup: {backup.backup_id}") + + except Exception as e: + logger.error(f"Failed to cleanup backup {backup.backup_id}: {e}") + + if backups_to_delete: + self._save_metadata() + + except Exception as e: + logger.error(f"Backup cleanup failed: {e}") + + @with_error_handling(fallback_value=False, context={'operation': 'restore_backup'}) + def restore_backup(self, backup_id: str, target_db_path: Optional[str] = None) -> bool: + """ + Restore from a backup. + + Args: + backup_id: ID of backup to restore + target_db_path: Target database path (None to restore to original) + + Returns: + True if restore successful + """ + try: + if backup_id not in self.metadata: + raise RecoveryError(f"Backup not found: {backup_id}") + + backup_metadata = self.metadata[backup_id] + backup_path = Path(backup_metadata.file_path) + + if not backup_path.exists(): + raise RecoveryError(f"Backup file not found: {backup_path}") + + # Verify backup integrity + if self.config.verify_backups: + current_checksum = self._calculate_checksum(backup_path) + if current_checksum != backup_metadata.checksum: + raise RecoveryError(f"Backup checksum mismatch: {backup_id}") + + target_path = Path(target_db_path) if target_db_path else self.db_path + + logger.info(f"Restoring backup {backup_id} to {target_path}") + + # Create backup of current database before restore + if target_path.exists(): + current_backup_path = target_path.with_suffix('.pre_restore_backup') + shutil.copy2(target_path, current_backup_path) + logger.info(f"Created pre-restore backup: {current_backup_path}") + + # Decompress if needed + restore_source = backup_path + if backup_path.suffix == '.gz': + restore_source = self._decompress_backup(backup_path) + if not restore_source: + raise RecoveryError(f"Failed to decompress backup: {backup_id}") + + # Restore database + shutil.copy2(restore_source, target_path) + + # Clean up temporary decompressed file + if restore_source != backup_path: + restore_source.unlink() + + # Verify restored database + if not self._verify_backup(target_path): + raise RecoveryError(f"Restored database verification failed: {backup_id}") + + logger.info(f"Backup restored successfully: {backup_id}") + return True + + except Exception as e: + logger.error(f"Backup restore failed: {e}") + raise RecoveryError(f"Failed to restore backup {backup_id}: {str(e)}") + + def _decompress_backup(self, compressed_path: Path) -> Optional[Path]: + """Decompress a backup file.""" + try: + import gzip + + decompressed_path = compressed_path.with_suffix('') + + with gzip.open(compressed_path, 'rb') as f_in: + with open(decompressed_path, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + + return decompressed_path + + except Exception as e: + logger.error(f"Backup decompression failed: {e}") + return None + + def list_backups(self, user_id: Optional[str] = None, backup_type: Optional[str] = None) -> List[BackupMetadata]: + """ + List available backups. + + Args: + user_id: Filter by user ID + backup_type: Filter by backup type + + Returns: + List of backup metadata + """ + backups = list(self.metadata.values()) + + if user_id: + backups = [b for b in backups if b.user_id == user_id] + + if backup_type: + backups = [b for b in backups if b.backup_type == backup_type] + + # Sort by timestamp (newest first) + backups.sort(key=lambda x: x.timestamp, reverse=True) + + return backups + + def delete_backup(self, backup_id: str) -> bool: + """ + Delete a backup. + + Args: + backup_id: ID of backup to delete + + Returns: + True if deletion successful + """ + try: + if backup_id not in self.metadata: + return False + + backup_metadata = self.metadata[backup_id] + backup_path = Path(backup_metadata.file_path) + + if backup_path.exists(): + backup_path.unlink() + + del self.metadata[backup_id] + self._save_metadata() + + logger.info(f"Deleted backup: {backup_id}") + return True + + except Exception as e: + logger.error(f"Failed to delete backup {backup_id}: {e}") + return False + + def start_automatic_backups(self): + """Start automatic backup thread.""" + if self._backup_thread and self._backup_thread.is_alive(): + logger.warning("Automatic backups already running") + return + + self._stop_backup_thread = False + self._backup_thread = threading.Thread(target=self._backup_loop, daemon=True) + self._backup_thread.start() + + logger.info("Automatic backups started") + + def stop_automatic_backups(self): + """Stop automatic backup thread.""" + self._stop_backup_thread = True + if self._backup_thread: + self._backup_thread.join(timeout=5) + + logger.info("Automatic backups stopped") + + def _backup_loop(self): + """Background loop for automatic backups.""" + logger.info("Automatic backup loop started") + + while not self._stop_backup_thread: + try: + # Check if it's time for a backup + last_backup_time = self._get_last_automatic_backup_time() + now = datetime.now() + + if not last_backup_time or (now - last_backup_time).total_seconds() >= (self.config.backup_interval_hours * 3600): + logger.info("Creating automatic backup") + success, backup_id = self.create_backup( + backup_type="automatic", + description="Scheduled automatic backup" + ) + + if success: + logger.info(f"Automatic backup created: {backup_id}") + else: + logger.error("Automatic backup failed") + + # Sleep for 1 hour before checking again + time.sleep(3600) + + except Exception as e: + logger.error(f"Error in automatic backup loop: {e}") + time.sleep(3600) # Continue after error + + logger.info("Automatic backup loop stopped") + + def _get_last_automatic_backup_time(self) -> Optional[datetime]: + """Get timestamp of last automatic backup.""" + automatic_backups = [ + b for b in self.metadata.values() + if b.backup_type == "automatic" + ] + + if not automatic_backups: + return None + + return max(b.timestamp for b in automatic_backups) + + def get_backup_statistics(self) -> Dict[str, Any]: + """Get backup statistics.""" + backups = list(self.metadata.values()) + + if not backups: + return { + 'total_backups': 0, + 'total_size_bytes': 0, + 'backup_types': {}, + 'oldest_backup': None, + 'newest_backup': None + } + + total_size = sum(b.size_bytes for b in backups) + backup_types = {} + + for backup in backups: + backup_types[backup.backup_type] = backup_types.get(backup.backup_type, 0) + 1 + + oldest = min(backups, key=lambda x: x.timestamp) + newest = max(backups, key=lambda x: x.timestamp) + + return { + 'total_backups': len(backups), + 'total_size_bytes': total_size, + 'backup_types': backup_types, + 'oldest_backup': { + 'id': oldest.backup_id, + 'timestamp': oldest.timestamp.isoformat(), + 'type': oldest.backup_type + }, + 'newest_backup': { + 'id': newest.backup_id, + 'timestamp': newest.timestamp.isoformat(), + 'type': newest.backup_type + } + } + + def create_pre_operation_backup(self, operation_name: str, context: Optional[Dict[str, Any]] = None) -> Optional[str]: + """ + Create a backup before a critical operation. + + Args: + operation_name: Name of the operation + context: Additional context + + Returns: + Backup ID if successful, None otherwise + """ + if not self.config.backup_on_critical_operations: + return None + + try: + description = f"Pre-operation backup for: {operation_name}" + if context: + description += f" (context: {json.dumps(context)})" + + success, backup_id = self.create_backup( + backup_type="pre_operation", + description=description + ) + + if success: + logger.info(f"Pre-operation backup created: {backup_id} for {operation_name}") + return backup_id + + except Exception as e: + logger.error(f"Failed to create pre-operation backup for {operation_name}: {e}") + + return None \ No newline at end of file diff --git a/digipal/storage/database.py b/digipal/storage/database.py new file mode 100644 index 0000000000000000000000000000000000000000..f0652cc9aef15e3050a97cd34f1de68a642b7e5f --- /dev/null +++ b/digipal/storage/database.py @@ -0,0 +1,276 @@ +""" +Database schema and migration system for DigiPal storage. +""" + +import sqlite3 +import json +import logging +from pathlib import Path +from typing import Dict, List, Optional, Any +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class DatabaseSchema: + """Manages database schema creation and migrations.""" + + # Current schema version + CURRENT_VERSION = 1 + + @staticmethod + def get_schema_sql() -> Dict[str, str]: + """Get SQL statements for creating all tables.""" + return { + 'users': ''' + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + huggingface_token TEXT, + username TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP, + session_data TEXT -- JSON blob for session information + ) + ''', + + 'digipals': ''' + CREATE TABLE IF NOT EXISTS digipals ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + egg_type TEXT NOT NULL, + life_stage TEXT NOT NULL, + generation INTEGER DEFAULT 1, + + -- Primary Attributes + hp INTEGER DEFAULT 100, + mp INTEGER DEFAULT 50, + offense INTEGER DEFAULT 10, + defense INTEGER DEFAULT 10, + speed INTEGER DEFAULT 10, + brains INTEGER DEFAULT 10, + + -- Secondary Attributes + discipline INTEGER DEFAULT 0, + happiness INTEGER DEFAULT 50, + weight INTEGER DEFAULT 20, + care_mistakes INTEGER DEFAULT 0, + energy INTEGER DEFAULT 100, + + -- Lifecycle Management + birth_time TIMESTAMP NOT NULL, + last_interaction TIMESTAMP NOT NULL, + evolution_timer REAL DEFAULT 0.0, + + -- Memory and Context (JSON blobs) + learned_commands TEXT, -- JSON array + personality_traits TEXT, -- JSON object + + -- Visual Representation + current_image_path TEXT, + image_generation_prompt TEXT, + + -- Metadata + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1, + + FOREIGN KEY (user_id) REFERENCES users (id) + ) + ''', + + 'interactions': ''' + CREATE TABLE IF NOT EXISTS interactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + digipal_id TEXT NOT NULL, + user_id TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL, + user_input TEXT NOT NULL, + interpreted_command TEXT, + pet_response TEXT, + attribute_changes TEXT, -- JSON object + success BOOLEAN DEFAULT 1, + result TEXT, -- InteractionResult enum value + + FOREIGN KEY (digipal_id) REFERENCES digipals (id), + FOREIGN KEY (user_id) REFERENCES users (id) + ) + ''', + + 'care_actions': ''' + CREATE TABLE IF NOT EXISTS care_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + digipal_id TEXT NOT NULL, + user_id TEXT NOT NULL, + action_type TEXT NOT NULL, + action_name TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL, + energy_cost INTEGER DEFAULT 0, + happiness_change INTEGER DEFAULT 0, + attribute_changes TEXT, -- JSON object + success BOOLEAN DEFAULT 1, + + FOREIGN KEY (digipal_id) REFERENCES digipals (id), + FOREIGN KEY (user_id) REFERENCES users (id) + ) + ''', + + 'backups': ''' + CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + digipal_id TEXT, + backup_type TEXT NOT NULL, -- 'full', 'incremental', 'manual' + backup_data TEXT NOT NULL, -- JSON blob + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + file_path TEXT, -- Optional file path for large backups + + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (digipal_id) REFERENCES digipals (id) + ) + ''', + + 'schema_migrations': ''' + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + description TEXT + ) + ''' + } + + @staticmethod + def get_indexes_sql() -> List[str]: + """Get SQL statements for creating database indexes.""" + return [ + 'CREATE INDEX IF NOT EXISTS idx_digipals_user_id ON digipals (user_id)', + 'CREATE INDEX IF NOT EXISTS idx_digipals_active ON digipals (user_id, is_active)', + 'CREATE INDEX IF NOT EXISTS idx_interactions_digipal ON interactions (digipal_id, timestamp)', + 'CREATE INDEX IF NOT EXISTS idx_interactions_user ON interactions (user_id, timestamp)', + 'CREATE INDEX IF NOT EXISTS idx_care_actions_digipal ON care_actions (digipal_id, timestamp)', + 'CREATE INDEX IF NOT EXISTS idx_backups_user ON backups (user_id, created_at)', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username)' + ] + + @staticmethod + def create_database(db_path: str) -> bool: + """Create database with all tables and indexes.""" + try: + # Ensure directory exists + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + with sqlite3.connect(db_path) as conn: + # Enable foreign key constraints + conn.execute('PRAGMA foreign_keys = ON') + + # Create all tables + schema_sql = DatabaseSchema.get_schema_sql() + for table_name, sql in schema_sql.items(): + conn.execute(sql) + logger.info(f"Created table: {table_name}") + + # Create indexes + for index_sql in DatabaseSchema.get_indexes_sql(): + conn.execute(index_sql) + + # Record schema version + conn.execute( + 'INSERT OR REPLACE INTO schema_migrations (version, description) VALUES (?, ?)', + (DatabaseSchema.CURRENT_VERSION, 'Initial schema creation') + ) + + conn.commit() + logger.info(f"Database created successfully at {db_path}") + return True + + except Exception as e: + logger.error(f"Failed to create database: {e}") + return False + + @staticmethod + def get_schema_version(db_path: str) -> int: + """Get current schema version from database.""" + try: + with sqlite3.connect(db_path) as conn: + cursor = conn.execute('SELECT MAX(version) FROM schema_migrations') + result = cursor.fetchone() + return result[0] if result[0] is not None else 0 + except Exception: + return 0 + + @staticmethod + def migrate_database(db_path: str) -> bool: + """Apply any pending database migrations.""" + current_version = DatabaseSchema.get_schema_version(db_path) + target_version = DatabaseSchema.CURRENT_VERSION + + if current_version >= target_version: + logger.info("Database is up to date") + return True + + logger.info(f"Migrating database from version {current_version} to {target_version}") + + # Future migrations would be implemented here + # For now, we only have version 1 + + return True + + +class DatabaseConnection: + """Manages database connections with proper error handling.""" + + def __init__(self, db_path: str): + self.db_path = db_path + self._ensure_database_exists() + + def _ensure_database_exists(self): + """Ensure database exists and is properly initialized.""" + if not Path(self.db_path).exists(): + DatabaseSchema.create_database(self.db_path) + else: + # Check if database has required tables + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='digipals'") + if not cursor.fetchone(): + # Database exists but doesn't have required tables, create them + DatabaseSchema.create_database(self.db_path) + else: + # Check if migration is needed + DatabaseSchema.migrate_database(self.db_path) + except Exception as e: + logger.error(f"Error checking database structure: {e}") + # Recreate database if there's an error + DatabaseSchema.create_database(self.db_path) + + def get_connection(self) -> sqlite3.Connection: + """Get a database connection with proper configuration.""" + conn = sqlite3.connect(self.db_path) + conn.execute('PRAGMA foreign_keys = ON') + conn.row_factory = sqlite3.Row # Enable dict-like access to rows + return conn + + def execute_query(self, query: str, params: tuple = ()) -> List[sqlite3.Row]: + """Execute a SELECT query and return results.""" + with self.get_connection() as conn: + cursor = conn.execute(query, params) + return cursor.fetchall() + + def execute_update(self, query: str, params: tuple = ()) -> int: + """Execute an INSERT/UPDATE/DELETE query and return affected rows.""" + with self.get_connection() as conn: + cursor = conn.execute(query, params) + conn.commit() + return cursor.rowcount + + def execute_transaction(self, queries: List[tuple]) -> bool: + """Execute multiple queries in a transaction.""" + try: + with self.get_connection() as conn: + for query, params in queries: + conn.execute(query, params) + conn.commit() + return True + except Exception as e: + logger.error(f"Transaction failed: {e}") + return False \ No newline at end of file diff --git a/digipal/storage/storage_manager.py b/digipal/storage/storage_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..060eed7e26e41bdfda4e87cbf610f4f9314bb5aa --- /dev/null +++ b/digipal/storage/storage_manager.py @@ -0,0 +1,950 @@ +""" +StorageManager class for DigiPal data persistence with CRUD operations. +""" + +import json +import logging +import shutil +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Any, Tuple + +from ..core.models import DigiPal, Interaction, CareAction +from ..core.enums import EggType, LifeStage, CareActionType, InteractionResult +from ..core.exceptions import StorageError, RecoveryError +from ..core.error_handler import with_error_handling, with_retry, RetryConfig +from .database import DatabaseConnection +from .backup_recovery import BackupRecoveryManager, BackupConfig + +logger = logging.getLogger(__name__) + + +class StorageManager: + """ + Manages all data persistence operations for DigiPal application. + Provides CRUD operations for pets, users, interactions, and backup/recovery. + """ + + def __init__(self, db_path: str, assets_path: str = "assets"): + """ + Initialize StorageManager with database and assets paths. + + Args: + db_path: Path to SQLite database file + assets_path: Path to assets directory for images and backups + """ + self.db_path = db_path + self.assets_path = Path(assets_path) + self.assets_path.mkdir(parents=True, exist_ok=True) + + # Initialize database connection + self.db = DatabaseConnection(db_path) + + # Create subdirectories for assets + (self.assets_path / "images").mkdir(exist_ok=True) + (self.assets_path / "backups").mkdir(exist_ok=True) + + # Initialize backup and recovery manager + backup_config = BackupConfig( + backup_interval_hours=6, + max_backups=10, + verify_backups=True, + backup_on_critical_operations=True + ) + self.backup_manager = BackupRecoveryManager( + db_path, + str(self.assets_path / "backups"), + backup_config + ) + + # Start automatic backups + self.backup_manager.start_automatic_backups() + + logger.info(f"StorageManager initialized with db: {db_path}, assets: {assets_path}") + + # User Management + def create_user(self, user_id: str, username: str, huggingface_token: str = "") -> bool: + """Create a new user record.""" + try: + query = ''' + INSERT INTO users (id, username, huggingface_token, last_login, session_data) + VALUES (?, ?, ?, ?, ?) + ''' + session_data = json.dumps({"created": datetime.now().isoformat()}) + + affected = self.db.execute_update( + query, + (user_id, username, huggingface_token, datetime.now(), session_data) + ) + + if affected > 0: + logger.info(f"Created user: {user_id}") + return True + return False + + except Exception as e: + logger.error(f"Failed to create user {user_id}: {e}") + return False + + def get_user(self, user_id: str) -> Optional[Dict[str, Any]]: + """Get user information by ID.""" + try: + query = 'SELECT * FROM users WHERE id = ?' + results = self.db.execute_query(query, (user_id,)) + + if results: + row = results[0] + return { + 'id': row['id'], + 'username': row['username'], + 'huggingface_token': row['huggingface_token'], + 'created_at': row['created_at'], + 'last_login': row['last_login'], + 'session_data': json.loads(row['session_data']) if row['session_data'] else {} + } + return None + + except Exception as e: + logger.error(f"Failed to get user {user_id}: {e}") + return None + + def update_user_session(self, user_id: str, session_data: Dict[str, Any]) -> bool: + """Update user session data and last login time.""" + try: + query = ''' + UPDATE users + SET last_login = ?, session_data = ? + WHERE id = ? + ''' + + affected = self.db.execute_update( + query, + (datetime.now(), json.dumps(session_data), user_id) + ) + + return affected > 0 + + except Exception as e: + logger.error(f"Failed to update user session {user_id}: {e}") + return False + + # DigiPal CRUD Operations + @with_error_handling(fallback_value=False, context={'operation': 'save_pet'}) + @with_retry(RetryConfig(max_attempts=3, retry_on=[StorageError])) + def save_pet(self, pet: DigiPal) -> bool: + """Save or update a DigiPal to the database.""" + try: + # Create pre-operation backup for critical pet data changes + backup_id = self.backup_manager.create_pre_operation_backup( + "save_pet", + {"pet_id": pet.id, "user_id": pet.user_id} + ) + + # Check if pet exists + existing = self.get_pet(pet.id) + + if existing: + result = self._update_pet(pet) + else: + result = self._insert_pet(pet) + + if not result: + raise StorageError(f"Failed to save pet {pet.id}") + + return result + + except Exception as e: + logger.error(f"Failed to save pet {pet.id}: {e}") + raise StorageError(f"Pet save operation failed: {str(e)}") + + def _insert_pet(self, pet: DigiPal) -> bool: + """Insert a new DigiPal record.""" + query = ''' + INSERT INTO digipals ( + id, user_id, name, egg_type, life_stage, generation, + hp, mp, offense, defense, speed, brains, + discipline, happiness, weight, care_mistakes, energy, + birth_time, last_interaction, evolution_timer, + learned_commands, personality_traits, + current_image_path, image_generation_prompt, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + + params = ( + pet.id, pet.user_id, pet.name, pet.egg_type.value, pet.life_stage.value, pet.generation, + pet.hp, pet.mp, pet.offense, pet.defense, pet.speed, pet.brains, + pet.discipline, pet.happiness, pet.weight, pet.care_mistakes, pet.energy, + pet.birth_time, pet.last_interaction, pet.evolution_timer, + json.dumps(list(pet.learned_commands)), json.dumps(pet.personality_traits), + pet.current_image_path, pet.image_generation_prompt, + datetime.now() + ) + + affected = self.db.execute_update(query, params) + + if affected > 0: + # Save conversation history as interactions + self._save_conversation_history(pet) + logger.info(f"Inserted pet: {pet.id}") + return True + + return False + + def _update_pet(self, pet: DigiPal) -> bool: + """Update an existing DigiPal record.""" + query = ''' + UPDATE digipals SET + name = ?, egg_type = ?, life_stage = ?, generation = ?, + hp = ?, mp = ?, offense = ?, defense = ?, speed = ?, brains = ?, + discipline = ?, happiness = ?, weight = ?, care_mistakes = ?, energy = ?, + birth_time = ?, last_interaction = ?, evolution_timer = ?, + learned_commands = ?, personality_traits = ?, + current_image_path = ?, image_generation_prompt = ?, + updated_at = ? + WHERE id = ? + ''' + + params = ( + pet.name, pet.egg_type.value, pet.life_stage.value, pet.generation, + pet.hp, pet.mp, pet.offense, pet.defense, pet.speed, pet.brains, + pet.discipline, pet.happiness, pet.weight, pet.care_mistakes, pet.energy, + pet.birth_time, pet.last_interaction, pet.evolution_timer, + json.dumps(list(pet.learned_commands)), json.dumps(pet.personality_traits), + pet.current_image_path, pet.image_generation_prompt, + datetime.now(), pet.id + ) + + affected = self.db.execute_update(query, params) + + if affected > 0: + # Update conversation history + self._save_conversation_history(pet) + logger.info(f"Updated pet: {pet.id}") + return True + + return False + + def load_pet(self, user_id: str) -> Optional[DigiPal]: + """Load the active DigiPal for a user.""" + try: + query = ''' + SELECT * FROM digipals + WHERE user_id = ? AND is_active = 1 + ORDER BY updated_at DESC + LIMIT 1 + ''' + + results = self.db.execute_query(query, (user_id,)) + + if not results: + return None + + row = results[0] + + # Convert database row to DigiPal object + pet_data = { + 'id': row['id'], + 'user_id': row['user_id'], + 'name': row['name'], + 'egg_type': EggType(row['egg_type']), + 'life_stage': LifeStage(row['life_stage']), + 'generation': row['generation'], + 'hp': row['hp'], + 'mp': row['mp'], + 'offense': row['offense'], + 'defense': row['defense'], + 'speed': row['speed'], + 'brains': row['brains'], + 'discipline': row['discipline'], + 'happiness': row['happiness'], + 'weight': row['weight'], + 'care_mistakes': row['care_mistakes'], + 'energy': row['energy'], + 'birth_time': datetime.fromisoformat(row['birth_time']), + 'last_interaction': datetime.fromisoformat(row['last_interaction']), + 'evolution_timer': row['evolution_timer'], + 'learned_commands': set(json.loads(row['learned_commands']) if row['learned_commands'] else []), + 'personality_traits': json.loads(row['personality_traits']) if row['personality_traits'] else {}, + 'current_image_path': row['current_image_path'] or "", + 'image_generation_prompt': row['image_generation_prompt'] or "", + 'conversation_history': [] + } + + pet = DigiPal(**pet_data) + + # Load conversation history + pet.conversation_history = self._load_conversation_history(pet.id) + + logger.info(f"Loaded pet: {pet.id} for user: {user_id}") + return pet + + except Exception as e: + logger.error(f"Failed to load pet for user {user_id}: {e}") + return None + + def get_pet(self, pet_id: str) -> Optional[DigiPal]: + """Get a specific DigiPal by ID.""" + try: + query = 'SELECT * FROM digipals WHERE id = ?' + results = self.db.execute_query(query, (pet_id,)) + + if not results: + return None + + row = results[0] + + pet_data = { + 'id': row['id'], + 'user_id': row['user_id'], + 'name': row['name'], + 'egg_type': EggType(row['egg_type']), + 'life_stage': LifeStage(row['life_stage']), + 'generation': row['generation'], + 'hp': row['hp'], + 'mp': row['mp'], + 'offense': row['offense'], + 'defense': row['defense'], + 'speed': row['speed'], + 'brains': row['brains'], + 'discipline': row['discipline'], + 'happiness': row['happiness'], + 'weight': row['weight'], + 'care_mistakes': row['care_mistakes'], + 'energy': row['energy'], + 'birth_time': datetime.fromisoformat(row['birth_time']), + 'last_interaction': datetime.fromisoformat(row['last_interaction']), + 'evolution_timer': row['evolution_timer'], + 'learned_commands': set(json.loads(row['learned_commands']) if row['learned_commands'] else []), + 'personality_traits': json.loads(row['personality_traits']) if row['personality_traits'] else {}, + 'current_image_path': row['current_image_path'] or "", + 'image_generation_prompt': row['image_generation_prompt'] or "", + 'conversation_history': [] + } + + pet = DigiPal(**pet_data) + pet.conversation_history = self._load_conversation_history(pet.id) + + return pet + + except Exception as e: + logger.error(f"Failed to get pet {pet_id}: {e}") + return None + + def delete_pet(self, pet_id: str) -> bool: + """Soft delete a DigiPal (mark as inactive).""" + try: + query = 'UPDATE digipals SET is_active = 0, updated_at = ? WHERE id = ?' + affected = self.db.execute_update(query, (datetime.now(), pet_id)) + + if affected > 0: + logger.info(f"Deleted pet: {pet_id}") + return True + return False + + except Exception as e: + logger.error(f"Failed to delete pet {pet_id}: {e}") + return False + + def get_user_pets(self, user_id: str, include_inactive: bool = False) -> List[DigiPal]: + """Get all pets for a user.""" + try: + if include_inactive: + query = 'SELECT * FROM digipals WHERE user_id = ? ORDER BY updated_at DESC' + else: + query = 'SELECT * FROM digipals WHERE user_id = ? AND is_active = 1 ORDER BY updated_at DESC' + + results = self.db.execute_query(query, (user_id,)) + pets = [] + + for row in results: + pet_data = { + 'id': row['id'], + 'user_id': row['user_id'], + 'name': row['name'], + 'egg_type': EggType(row['egg_type']), + 'life_stage': LifeStage(row['life_stage']), + 'generation': row['generation'], + 'hp': row['hp'], + 'mp': row['mp'], + 'offense': row['offense'], + 'defense': row['defense'], + 'speed': row['speed'], + 'brains': row['brains'], + 'discipline': row['discipline'], + 'happiness': row['happiness'], + 'weight': row['weight'], + 'care_mistakes': row['care_mistakes'], + 'energy': row['energy'], + 'birth_time': datetime.fromisoformat(row['birth_time']), + 'last_interaction': datetime.fromisoformat(row['last_interaction']), + 'evolution_timer': row['evolution_timer'], + 'learned_commands': set(json.loads(row['learned_commands']) if row['learned_commands'] else []), + 'personality_traits': json.loads(row['personality_traits']) if row['personality_traits'] else {}, + 'current_image_path': row['current_image_path'] or "", + 'image_generation_prompt': row['image_generation_prompt'] or "", + 'conversation_history': [] + } + + pet = DigiPal(**pet_data) + # Note: Not loading full conversation history for performance + pets.append(pet) + + return pets + + except Exception as e: + logger.error(f"Failed to get pets for user {user_id}: {e}") + return [] + + # Interaction History Management + def save_interaction_history(self, interactions: List[Interaction]) -> bool: + """Save multiple interactions to the database.""" + if not interactions: + return True + + try: + queries = [] + for interaction in interactions: + query = ''' + INSERT INTO interactions ( + digipal_id, user_id, timestamp, user_input, interpreted_command, + pet_response, attribute_changes, success, result + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + + # Extract digipal_id and user_id from context (would need to be passed) + # For now, we'll handle this in _save_conversation_history + pass + + return True + + except Exception as e: + logger.error(f"Failed to save interaction history: {e}") + return False + + def _save_conversation_history(self, pet: DigiPal) -> bool: + """Save pet's conversation history to interactions table.""" + if not pet.conversation_history: + return True + + try: + # First, delete existing interactions for this pet to avoid duplicates + delete_query = 'DELETE FROM interactions WHERE digipal_id = ?' + self.db.execute_update(delete_query, (pet.id,)) + + # Insert all interactions + queries = [] + for interaction in pet.conversation_history: + query = ''' + INSERT INTO interactions ( + digipal_id, user_id, timestamp, user_input, interpreted_command, + pet_response, attribute_changes, success, result + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + + params = ( + pet.id, pet.user_id, interaction.timestamp, interaction.user_input, + interaction.interpreted_command, interaction.pet_response, + json.dumps(interaction.attribute_changes), interaction.success, + interaction.result.value + ) + + queries.append((query, params)) + + return self.db.execute_transaction(queries) + + except Exception as e: + logger.error(f"Failed to save conversation history for pet {pet.id}: {e}") + return False + + def _load_conversation_history(self, pet_id: str, limit: int = 100) -> List[Interaction]: + """Load conversation history for a pet.""" + try: + query = ''' + SELECT * FROM interactions + WHERE digipal_id = ? + ORDER BY timestamp DESC + LIMIT ? + ''' + + results = self.db.execute_query(query, (pet_id, limit)) + interactions = [] + + for row in results: + interaction = Interaction( + timestamp=datetime.fromisoformat(row['timestamp']), + user_input=row['user_input'], + interpreted_command=row['interpreted_command'] or "", + pet_response=row['pet_response'] or "", + attribute_changes=json.loads(row['attribute_changes']) if row['attribute_changes'] else {}, + success=bool(row['success']), + result=InteractionResult(row['result']) if row['result'] else InteractionResult.SUCCESS + ) + interactions.append(interaction) + + # Reverse to get chronological order + return list(reversed(interactions)) + + except Exception as e: + logger.error(f"Failed to load conversation history for pet {pet_id}: {e}") + return [] + + def get_interaction_history(self, user_id: str, pet_id: str = None, limit: int = 50) -> List[Dict[str, Any]]: + """Get interaction history for a user or specific pet.""" + try: + if pet_id: + query = ''' + SELECT * FROM interactions + WHERE user_id = ? AND digipal_id = ? + ORDER BY timestamp DESC + LIMIT ? + ''' + params = (user_id, pet_id, limit) + else: + query = ''' + SELECT * FROM interactions + WHERE user_id = ? + ORDER BY timestamp DESC + LIMIT ? + ''' + params = (user_id, limit) + + results = self.db.execute_query(query, params) + + return [ + { + 'id': row['id'], + 'digipal_id': row['digipal_id'], + 'user_id': row['user_id'], + 'timestamp': row['timestamp'], + 'user_input': row['user_input'], + 'interpreted_command': row['interpreted_command'], + 'pet_response': row['pet_response'], + 'attribute_changes': json.loads(row['attribute_changes']) if row['attribute_changes'] else {}, + 'success': bool(row['success']), + 'result': row['result'] + } + for row in results + ] + + except Exception as e: + logger.error(f"Failed to get interaction history: {e}") + return [] + + # Care Actions Management + def save_care_action(self, pet_id: str, user_id: str, care_action: CareAction, + attribute_changes: Dict[str, int], success: bool = True) -> bool: + """Save a care action to the database.""" + try: + query = ''' + INSERT INTO care_actions ( + digipal_id, user_id, action_type, action_name, timestamp, + energy_cost, happiness_change, attribute_changes, success + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + + params = ( + pet_id, user_id, care_action.action_type.value, care_action.name, + datetime.now(), care_action.energy_cost, care_action.happiness_change, + json.dumps(attribute_changes), success + ) + + affected = self.db.execute_update(query, params) + return affected > 0 + + except Exception as e: + logger.error(f"Failed to save care action: {e}") + return False + + def get_care_history(self, pet_id: str, limit: int = 50) -> List[Dict[str, Any]]: + """Get care action history for a pet.""" + try: + query = ''' + SELECT * FROM care_actions + WHERE digipal_id = ? + ORDER BY timestamp DESC + LIMIT ? + ''' + + results = self.db.execute_query(query, (pet_id, limit)) + + return [ + { + 'id': row['id'], + 'digipal_id': row['digipal_id'], + 'user_id': row['user_id'], + 'action_type': row['action_type'], + 'action_name': row['action_name'], + 'timestamp': row['timestamp'], + 'energy_cost': row['energy_cost'], + 'happiness_change': row['happiness_change'], + 'attribute_changes': json.loads(row['attribute_changes']) if row['attribute_changes'] else {}, + 'success': bool(row['success']) + } + for row in results + ] + + except Exception as e: + logger.error(f"Failed to get care history for pet {pet_id}: {e}") + return [] + + # Asset Management + def get_user_assets(self, user_id: str) -> List[Dict[str, str]]: + """Get list of asset references for a user.""" + try: + user_assets_path = self.assets_path / "images" / user_id + if not user_assets_path.exists(): + return [] + + assets = [] + for asset_file in user_assets_path.iterdir(): + if asset_file.is_file() and asset_file.suffix.lower() in ['.png', '.jpg', '.jpeg', '.gif']: + assets.append({ + 'filename': asset_file.name, + 'path': str(asset_file), + 'size': asset_file.stat().st_size, + 'modified': datetime.fromtimestamp(asset_file.stat().st_mtime).isoformat() + }) + + return assets + + except Exception as e: + logger.error(f"Failed to get assets for user {user_id}: {e}") + return [] + + def save_asset(self, user_id: str, filename: str, data: bytes) -> str: + """Save an asset file and return the path.""" + try: + user_assets_path = self.assets_path / "images" / user_id + user_assets_path.mkdir(parents=True, exist_ok=True) + + asset_path = user_assets_path / filename + asset_path.write_bytes(data) + + logger.info(f"Saved asset: {asset_path}") + return str(asset_path) + + except Exception as e: + logger.error(f"Failed to save asset {filename} for user {user_id}: {e}") + return "" + + # Backup and Recovery System + def create_backup(self, user_id: str, backup_type: str = "manual", pet_id: str = None) -> bool: + """Create a backup of user data.""" + try: + backup_data = { + 'timestamp': datetime.now().isoformat(), + 'backup_type': backup_type, + 'user_data': self.get_user(user_id), + 'pets': [], + 'interactions': [], + 'care_actions': [] + } + + # Get pets data + if pet_id: + pet = self.get_pet(pet_id) + if pet: + backup_data['pets'] = [pet.to_dict()] + backup_data['interactions'] = self.get_interaction_history(user_id, pet_id, limit=1000) + backup_data['care_actions'] = self.get_care_history(pet_id, limit=1000) + else: + # Backup all user pets + pets = self.get_user_pets(user_id, include_inactive=True) + backup_data['pets'] = [pet.to_dict() for pet in pets] + backup_data['interactions'] = self.get_interaction_history(user_id, limit=1000) + + # Get care actions for all pets + for pet in pets: + pet_care_actions = self.get_care_history(pet.id, limit=1000) + backup_data['care_actions'].extend(pet_care_actions) + + # Save backup to database + backup_json = json.dumps(backup_data, indent=2) + + query = ''' + INSERT INTO backups (user_id, digipal_id, backup_type, backup_data) + VALUES (?, ?, ?, ?) + ''' + + affected = self.db.execute_update(query, (user_id, pet_id, backup_type, backup_json)) + + if affected > 0: + # Also save to file for additional safety + backup_filename = f"backup_{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + backup_file_path = self.assets_path / "backups" / backup_filename + + backup_file_path.write_text(backup_json) + + # Update backup record with file path + update_query = 'UPDATE backups SET file_path = ? WHERE user_id = ? AND created_at = (SELECT MAX(created_at) FROM backups WHERE user_id = ?)' + self.db.execute_update(update_query, (str(backup_file_path), user_id, user_id)) + + logger.info(f"Created backup for user {user_id}: {backup_filename}") + return True + + return False + + except Exception as e: + logger.error(f"Failed to create backup for user {user_id}: {e}") + return False + + def restore_backup(self, user_id: str, backup_id: int = None) -> bool: + """Restore user data from backup.""" + try: + # Get backup data + if backup_id: + query = 'SELECT * FROM backups WHERE id = ? AND user_id = ?' + results = self.db.execute_query(query, (backup_id, user_id)) + else: + # Get most recent backup + query = ''' + SELECT * FROM backups + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT 1 + ''' + results = self.db.execute_query(query, (user_id,)) + + if not results: + logger.error(f"No backup found for user {user_id}") + return False + + backup_row = results[0] + backup_data = json.loads(backup_row['backup_data']) + + # Start transaction for restoration + queries = [] + + # Restore user data + if backup_data.get('user_data'): + user_data = backup_data['user_data'] + user_query = ''' + INSERT OR REPLACE INTO users (id, username, huggingface_token, created_at, last_login, session_data) + VALUES (?, ?, ?, ?, ?, ?) + ''' + user_params = ( + user_data['id'], user_data['username'], user_data['huggingface_token'], + user_data['created_at'], user_data['last_login'], + json.dumps(user_data['session_data']) + ) + queries.append((user_query, user_params)) + + # Restore pets + for pet_data in backup_data.get('pets', []): + pet = DigiPal.from_dict(pet_data) + + pet_query = ''' + INSERT OR REPLACE INTO digipals ( + id, user_id, name, egg_type, life_stage, generation, + hp, mp, offense, defense, speed, brains, + discipline, happiness, weight, care_mistakes, energy, + birth_time, last_interaction, evolution_timer, + learned_commands, personality_traits, + current_image_path, image_generation_prompt, + created_at, updated_at, is_active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + + pet_params = ( + pet.id, pet.user_id, pet.name, pet.egg_type.value, pet.life_stage.value, pet.generation, + pet.hp, pet.mp, pet.offense, pet.defense, pet.speed, pet.brains, + pet.discipline, pet.happiness, pet.weight, pet.care_mistakes, pet.energy, + pet.birth_time, pet.last_interaction, pet.evolution_timer, + json.dumps(list(pet.learned_commands)), json.dumps(pet.personality_traits), + pet.current_image_path, pet.image_generation_prompt, + datetime.now(), datetime.now(), 1 + ) + queries.append((pet_query, pet_params)) + + # Clear existing interactions before restoring + clear_interactions_query = 'DELETE FROM interactions WHERE user_id = ?' + queries.append((clear_interactions_query, (user_id,))) + + # Restore interactions + for interaction_data in backup_data.get('interactions', []): + interaction_query = ''' + INSERT INTO interactions ( + digipal_id, user_id, timestamp, user_input, interpreted_command, + pet_response, attribute_changes, success, result + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + + interaction_params = ( + interaction_data['digipal_id'], interaction_data['user_id'], + interaction_data['timestamp'], interaction_data['user_input'], + interaction_data['interpreted_command'], interaction_data['pet_response'], + json.dumps(interaction_data['attribute_changes']), + interaction_data['success'], interaction_data['result'] + ) + queries.append((interaction_query, interaction_params)) + + # Restore care actions + for care_data in backup_data.get('care_actions', []): + care_query = ''' + INSERT OR REPLACE INTO care_actions ( + digipal_id, user_id, action_type, action_name, timestamp, + energy_cost, happiness_change, attribute_changes, success + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + + care_params = ( + care_data['digipal_id'], care_data['user_id'], + care_data['action_type'], care_data['action_name'], + care_data['timestamp'], care_data['energy_cost'], + care_data['happiness_change'], json.dumps(care_data['attribute_changes']), + care_data['success'] + ) + queries.append((care_query, care_params)) + + # Execute all restoration queries in transaction + success = self.db.execute_transaction(queries) + + if success: + logger.info(f"Successfully restored backup for user {user_id}") + return True + else: + logger.error(f"Failed to restore backup for user {user_id}") + return False + + except Exception as e: + logger.error(f"Failed to restore backup for user {user_id}: {e}") + return False + + def get_backups(self, user_id: str, limit: int = 10) -> List[Dict[str, Any]]: + """Get list of available backups for a user.""" + try: + query = ''' + SELECT id, user_id, digipal_id, backup_type, created_at, file_path + FROM backups + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? + ''' + + results = self.db.execute_query(query, (user_id, limit)) + + return [ + { + 'id': row['id'], + 'user_id': row['user_id'], + 'digipal_id': row['digipal_id'], + 'backup_type': row['backup_type'], + 'created_at': row['created_at'], + 'file_path': row['file_path'] + } + for row in results + ] + + except Exception as e: + logger.error(f"Failed to get backups for user {user_id}: {e}") + return [] + + def delete_old_backups(self, user_id: str, keep_count: int = 5) -> bool: + """Delete old backups, keeping only the most recent ones.""" + try: + # Get all backups for user, ordered by creation time (newest first) + query = ''' + SELECT id, file_path FROM backups + WHERE user_id = ? + ORDER BY created_at DESC + ''' + + results = self.db.execute_query(query, (user_id,)) + + if len(results) <= keep_count: + return True # No old backups to delete + + # Get backups to delete (skip the first keep_count) + backups_to_delete = results[keep_count:] + + # Delete backup files + for row in backups_to_delete: + if row['file_path']: + try: + Path(row['file_path']).unlink(missing_ok=True) + except Exception as e: + logger.warning(f"Failed to delete backup file {row['file_path']}: {e}") + + # Delete database records + backup_ids = [str(row['id']) for row in backups_to_delete] + if backup_ids: + placeholders = ','.join(['?' for _ in backup_ids]) + delete_query = f'DELETE FROM backups WHERE id IN ({placeholders})' + affected = self.db.execute_update(delete_query, tuple(backup_ids)) + + logger.info(f"Deleted {affected} old backups for user {user_id}") + return affected > 0 + + return True + + except Exception as e: + logger.error(f"Failed to delete old backups for user {user_id}: {e}") + return False + + def create_automatic_backup(self, user_id: str) -> bool: + """Create an automatic backup and clean up old ones.""" + try: + # Create backup + success = self.create_backup(user_id, backup_type="automatic") + + if success: + # Clean up old automatic backups (keep last 10) + self.delete_old_backups(user_id, keep_count=10) + + return success + + except Exception as e: + logger.error(f"Failed to create automatic backup for user {user_id}: {e}") + return False + + # Database Maintenance + def vacuum_database(self) -> bool: + """Optimize database by running VACUUM.""" + try: + with self.db.get_connection() as conn: + conn.execute('VACUUM') + conn.commit() + + logger.info("Database vacuum completed") + return True + + except Exception as e: + logger.error(f"Failed to vacuum database: {e}") + return False + + def get_database_stats(self) -> Dict[str, Any]: + """Get database statistics.""" + try: + stats = {} + + # Table row counts + tables = ['users', 'digipals', 'interactions', 'care_actions', 'backups'] + for table in tables: + query = f'SELECT COUNT(*) as count FROM {table}' + result = self.db.execute_query(query) + stats[f'{table}_count'] = result[0]['count'] if result else 0 + + # Database file size + db_path = Path(self.db_path) + if db_path.exists(): + stats['database_size_bytes'] = db_path.stat().st_size + stats['database_size_mb'] = round(stats['database_size_bytes'] / (1024 * 1024), 2) + + # Assets directory size + if self.assets_path.exists(): + total_size = sum(f.stat().st_size for f in self.assets_path.rglob('*') if f.is_file()) + stats['assets_size_bytes'] = total_size + stats['assets_size_mb'] = round(total_size / (1024 * 1024), 2) + + return stats + + except Exception as e: + logger.error(f"Failed to get database stats: {e}") + return {} + + def close(self): + """Close database connections and cleanup.""" + # SQLite connections are closed automatically with context managers + # This method is here for future cleanup if needed + logger.info("StorageManager closed") \ No newline at end of file diff --git a/digipal/ui/__init__.py b/digipal/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0811a858c10be21e2f4aff9bc56d1b06dd17f1eb --- /dev/null +++ b/digipal/ui/__init__.py @@ -0,0 +1,3 @@ +""" +User interface components for DigiPal Gradio web interface. +""" \ No newline at end of file diff --git a/digipal/ui/__pycache__/__init__.cpython-312.pyc b/digipal/ui/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93e14b92840b9ceb9ccb708efae5f878c965cb7e Binary files /dev/null and b/digipal/ui/__pycache__/__init__.cpython-312.pyc differ diff --git a/digipal/ui/__pycache__/gradio_interface.cpython-312.pyc b/digipal/ui/__pycache__/gradio_interface.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..972185ee22180a2de7f7b3c15c24646e28f3ae01 Binary files /dev/null and b/digipal/ui/__pycache__/gradio_interface.cpython-312.pyc differ diff --git a/digipal/ui/gradio_interface.py b/digipal/ui/gradio_interface.py new file mode 100644 index 0000000000000000000000000000000000000000..5ed5d6b8dbb81053bd62fc5b6b5b21817844b826 --- /dev/null +++ b/digipal/ui/gradio_interface.py @@ -0,0 +1,1872 @@ +""" +Gradio web interface for DigiPal application. + +This module provides the main web interface using Gradio with custom CSS +for a game-style UI. Includes authentication, egg selection, and main +pet interaction interfaces. +""" + +import gradio as gr +import logging +from typing import Optional, Tuple, Dict, Any +from pathlib import Path +import base64 +import io + +from ..core.digipal_core import DigiPalCore, PetState +from ..core.enums import EggType, LifeStage +from ..auth.auth_manager import AuthManager +from ..auth.models import AuthStatus +from ..storage.database import DatabaseConnection + +logger = logging.getLogger(__name__) + + +class GradioInterface: + """Main Gradio interface for DigiPal application.""" + + def __init__(self, digipal_core: DigiPalCore, auth_manager: AuthManager): + """ + Initialize Gradio interface. + + Args: + digipal_core: DigiPal core engine + auth_manager: Authentication manager + """ + self.digipal_core = digipal_core + self.auth_manager = auth_manager + + # Current session state + self.current_user_id: Optional[str] = None + self.current_token: Optional[str] = None + + # Interface components + self.app = None + + logger.info("GradioInterface initialized") + + def create_interface(self) -> gr.Blocks: + """ + Create the main Gradio interface. + + Returns: + Gradio Blocks interface + """ + # Custom CSS for game-style UI + custom_css = self._get_custom_css() + + with gr.Blocks( + css=custom_css, + title="DigiPal - Your Digital Companion", + theme=gr.themes.Soft() + ) as interface: + + # State variables for session management + user_state = gr.State(None) + token_state = gr.State(None) + + # Main interface tabs with proper tab switching + with gr.Tabs(selected="auth_tab") as main_tabs: + + # Authentication Tab + with gr.Tab("Login", id="auth_tab") as auth_tab: + auth_components = self._create_authentication_tab() + + # Egg Selection Tab + with gr.Tab("Choose Your Egg", id="egg_tab") as egg_tab: + egg_components = self._create_egg_selection_interface() + + # Main DigiPal Interface + with gr.Tab("Your DigiPal", id="main_tab") as main_tab: + main_components = self._create_digipal_main_interface() + + # Event handlers + self._setup_event_handlers( + auth_components, egg_components, main_components, + user_state, token_state, main_tabs + ) + + self.app = interface + return interface + + def _create_authentication_tab(self) -> Dict[str, Any]: + """Create the authentication tab components.""" + + with gr.Column(elem_classes=["auth-container"]): + gr.HTML(""" +
Your digital companion awaits!
+Each egg type gives your DigiPal different starting attributes!
+Higher Attack & Offense
+Brave and energetic personality
+Higher Defense & HP
+Calm and protective personality
+Higher Health & Symbiosis
+Gentle and nurturing personality
+Your DigiPal is waiting for you to say something!
", + elem_classes=["response-display"] + ) + + # Live conversation history (always visible) + conversation_history = gr.HTML( + "", + elem_classes=["conversation-history"] + ) + + # Conversation controls + with gr.Row(): + clear_history_btn = gr.Button("๐๏ธ Clear History", elem_classes=["clear-btn"]) + export_history_btn = gr.Button("๐ฅ Export Chat", elem_classes=["export-btn"]) + + return { + 'pet_image': pet_image, + 'pet_name_display': pet_name_display, + 'action_feedback': action_feedback, + 'status_info': status_info, + 'attributes_display': attributes_display, + 'needs_display': needs_display, + 'auto_refresh': auto_refresh, + 'feed_btn': feed_btn, + 'train_btn': train_btn, + 'strength_train_btn': strength_train_btn, + 'speed_train_btn': speed_train_btn, + 'brain_train_btn': brain_train_btn, + 'defense_train_btn': defense_train_btn, + 'praise_btn': praise_btn, + 'scold_btn': scold_btn, + 'rest_btn': rest_btn, + 'play_btn': play_btn, + 'medicine_btn': medicine_btn, + 'clean_btn': clean_btn, + 'audio_input': audio_input, + 'audio_status': audio_status, + 'process_audio_btn': process_audio_btn, + 'text_input': text_input, + 'quick_hello_btn': quick_hello_btn, + 'quick_status_btn': quick_status_btn, + 'send_btn': send_btn, + 'response_display': response_display, + 'conversation_history': conversation_history, + 'clear_history_btn': clear_history_btn, + 'export_history_btn': export_history_btn + } + + def _setup_event_handlers(self, auth_components: Dict, egg_components: Dict, + main_components: Dict, user_state: gr.State, + token_state: gr.State, main_tabs: gr.Tabs): + """Set up event handlers for all interface components.""" + + # Authentication handlers + auth_components['login_btn'].click( + fn=self._handle_login, + inputs=[ + auth_components['token_input'], + auth_components['offline_toggle'], + user_state, + token_state + ], + outputs=[ + auth_components['auth_status'], + user_state, + token_state, + main_tabs + ] + ) + + # Egg selection handlers + for egg_type, btn in [ + (EggType.RED, egg_components['red_egg_btn']), + (EggType.BLUE, egg_components['blue_egg_btn']), + (EggType.GREEN, egg_components['green_egg_btn']) + ]: + btn.click( + fn=lambda user_state_val, egg=egg_type: self._handle_egg_selection(egg, user_state_val), + inputs=[user_state], + outputs=[ + egg_components['egg_status'], + main_tabs + ] + ) + + # Text interaction handlers + main_components['send_btn'].click( + fn=self._handle_text_interaction, + inputs=[ + main_components['text_input'], + user_state + ], + outputs=[ + main_components['response_display'], + main_components['text_input'], + main_components['status_info'], + main_components['attributes_display'], + main_components['conversation_history'], + main_components['action_feedback'], + main_components['needs_display'] + ] + ) + + # Quick message handlers + main_components['quick_hello_btn'].click( + fn=self._handle_quick_message, + inputs=[gr.State("Hello!"), user_state], + outputs=[ + main_components['response_display'], + main_components['status_info'], + main_components['attributes_display'], + main_components['conversation_history'], + main_components['action_feedback'] + ] + ) + + main_components['quick_status_btn'].click( + fn=self._handle_quick_message, + inputs=[gr.State("How are you?"), user_state], + outputs=[ + main_components['response_display'], + main_components['status_info'], + main_components['attributes_display'], + main_components['conversation_history'], + main_components['action_feedback'] + ] + ) + + # Audio processing handler + main_components['process_audio_btn'].click( + fn=self._handle_audio_interaction, + inputs=[ + main_components['audio_input'], + user_state + ], + outputs=[ + main_components['audio_status'], + main_components['response_display'], + main_components['status_info'], + main_components['attributes_display'], + main_components['conversation_history'] + ] + ) + + # Primary care action handlers + primary_care_actions = { + 'feed': main_components['feed_btn'], + 'train': main_components['train_btn'], + 'praise': main_components['praise_btn'], + 'scold': main_components['scold_btn'], + 'rest': main_components['rest_btn'], + 'play': main_components['play_btn'] + } + + for action_name, btn in primary_care_actions.items(): + btn.click( + fn=lambda user_state_val, action=action_name: self._handle_care_action(action, user_state_val), + inputs=[user_state], + outputs=[ + main_components['response_display'], + main_components['status_info'], + main_components['attributes_display'], + main_components['action_feedback'], + main_components['needs_display'] + ] + ) + + # Training sub-action handlers + training_actions = { + 'strength_train': main_components['strength_train_btn'], + 'speed_train': main_components['speed_train_btn'], + 'brain_train': main_components['brain_train_btn'], + 'defense_train': main_components['defense_train_btn'] + } + + for action_name, btn in training_actions.items(): + btn.click( + fn=lambda user_state_val, action=action_name: self._handle_care_action(action, user_state_val), + inputs=[user_state], + outputs=[ + main_components['response_display'], + main_components['status_info'], + main_components['attributes_display'], + main_components['action_feedback'] + ] + ) + + # Advanced care action handlers + advanced_care_actions = { + 'medicine': main_components['medicine_btn'], + 'clean': main_components['clean_btn'] + } + + for action_name, btn in advanced_care_actions.items(): + btn.click( + fn=lambda user_state_val, action=action_name: self._handle_care_action(action, user_state_val), + inputs=[user_state], + outputs=[ + main_components['response_display'], + main_components['status_info'], + main_components['attributes_display'], + main_components['action_feedback'] + ] + ) + + # Conversation management handlers + main_components['clear_history_btn'].click( + fn=self._handle_clear_history, + inputs=[user_state], + outputs=[ + main_components['conversation_history'], + main_components['response_display'] + ] + ) + + main_components['export_history_btn'].click( + fn=self._handle_export_history, + inputs=[user_state], + outputs=[gr.File()] + ) + + # Auto-refresh handler (periodic update) + main_components['auto_refresh'].change( + fn=self._toggle_auto_refresh, + inputs=[main_components['auto_refresh'], user_state], + outputs=[main_components['status_info']] + ) + + def _handle_login(self, token: str, offline_mode: bool, + current_user: Optional[str], current_token: Optional[str]) -> Tuple: + """Handle user login.""" + if not token: + return ( + 'You chose the {egg_type.value} egg. Your DigiPal journey begins!
+Go to the main interface to start caring for your digital companion.
+DigiPal: {interaction.pet_response}
+Care Action: {icon} {action.replace('_', ' ').title()}
+DigiPal: {interaction.pet_response}
+No conversation history yet.
" + + history_html = "Error loading conversation history.
" + + def _format_needs_display(self, pet_state: Optional[PetState]) -> str: + """Format pet needs and alerts for display.""" + if not pet_state: + return "" + + needs_html = "โ All needs are being met!
" + + needs_html += "Quick Message: {message}
+DigiPal: {interaction.pet_response}
+Speech Input: {interaction.user_input}
+DigiPal: {interaction.pet_response}
+Please login first.
", + 'Conversation history cleared.
", + 'Error clearing conversation history.
", + 'Error clearing conversation history.
", + f'