commit 7d3770dba382b0fe7f1f93c2d4ea3b67ab3db9e0 Author: root Date: Sat Aug 30 15:55:23 2025 +0000 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb2079d --- /dev/null +++ b/.gitignore @@ -0,0 +1,207 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be added to the global gitignore or merged into this project gitignore. For a PyCharm +# project, it is recommended to include the following files: +# .idea/ +# *.iml +# *.ipr +# *.iws +.idea/ +*.iml +*.ipr +*.iws + +# VS Code +.vscode/ +*.code-workspace + +# Claude AI +.claude/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.swp +*.swo +*~ + +# Logs +*.log +logs/ + +# Local configuration files +config.local.json +*.local.json +.env.local + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Backup files +*.bak +*.backup diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7c78990 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +Project Description: + +This is a python based project. keep it lightweighted + + +Features: +1. the app can invoke ollama client to send prompt and get response. +2. send the llm response to 3rd party rest apis, this apis can push notification to ios/andorid. the apis are not in the scope +3. the prompt can have different template, each time it will pick the template ramdonly. +4. each template have some placeholders, for example, fill in the date, time of the real time +5. the invoke ollama and send notifications will be triggered at random intervals(around 45 minutes, it can be 3 or 5 minutes, can be 2 or 3 hours) +6. the invoke will be set with a slient time, for example every day 12pm to 8am. +7. the silent time, ollama api endpoint, etc, can be configurable +8. Notification APIs: +regarding notification api, there are 2 apis to push: +bark: +https://bark.day.app/#/tutorial?id=%e5%8f%91%e9%80%81%e6%8e%a8%e9%80%81 +ntfy: +https://docs.ntfy.sh/publish/ diff --git a/config.json b/config.json new file mode 100644 index 0000000..f609e97 --- /dev/null +++ b/config.json @@ -0,0 +1,14 @@ +{ + "ollama_endpoint": "http://localhost:11434", + "ollama_model": "llama2", + "silent_start": "20:00", + "silent_end": "12:00", + "min_interval": 3, + "max_interval": 180, + "bark_api_url": "", + "bark_device_key": "", + "ntfy_api_url": "", + "ntfy_topic": "", + "ntfy_access_token": "", + "templates_dir": "templates" +} \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..9923856 --- /dev/null +++ b/config.py @@ -0,0 +1,47 @@ +import json +from pathlib import Path +from typing import Dict, Any +from dataclasses import dataclass, asdict + +@dataclass +class Config: + """Configuration management for the notification system.""" + + ollama_endpoint: str = "http://localhost:11434" + ollama_model: str = "llama2" + + # Silent time configuration (12pm to 8am) + silent_start: str = "20:00" + silent_end: str = "12:00" + + # Interval configuration (in minutes) + min_interval: int = 3 + max_interval: int = 180 + + # Bark notification service + bark_api_url: str = "" + bark_device_key: str = "" + + # Ntfy notification service + ntfy_api_url: str = "" + ntfy_topic: str = "" + ntfy_access_token: str = "" + + # Template settings + templates_dir: str = "templates" + + def __post_init__(self): + """Load configuration from file if exists.""" + config_file = Path("config.json") + if config_file.exists(): + with open(config_file, 'r') as f: + data = json.load(f) + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + + def save(self): + """Save current configuration to file.""" + config_file = Path("config.json") + with open(config_file, 'w') as f: + json.dump(asdict(self), f, indent=2) \ No newline at end of file diff --git a/config_cli.py b/config_cli.py new file mode 100755 index 0000000..889e8d8 --- /dev/null +++ b/config_cli.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""CLI tool for managing configuration.""" + +import argparse +import asyncio +from config import Config + +async def main(): + parser = argparse.ArgumentParser(description="Ollama Notification System Configuration") + parser.add_argument("--set-ollama-endpoint", help="Set Ollama endpoint URL") + parser.add_argument("--set-ollama-model", help="Set Ollama model name") + parser.add_argument("--set-silent-time", nargs=2, metavar=("START", "END"), + help="Set silent time range (e.g., 20:00 12:00)") + parser.add_argument("--set-interval-range", nargs=2, type=int, metavar=("MIN", "MAX"), + help="Set notification interval range in minutes") + parser.add_argument("--set-bark-url", help="Set Bark API URL") + parser.add_argument("--set-bark-key", help="Set Bark device key") + parser.add_argument("--set-ntfy-url", help="Set Ntfy API URL") + parser.add_argument("--set-ntfy-topic", help="Set Ntfy topic") + parser.add_argument("--set-ntfy-token", help="Set Ntfy access token") + parser.add_argument("--show", action="store_true", help="Show current configuration") + + args = parser.parse_args() + + config = Config() + + if args.set_ollama_endpoint: + config.ollama_endpoint = args.set_ollama_endpoint + + if args.set_ollama_model: + config.ollama_model = args.set_ollama_model + + if args.set_silent_time: + config.silent_start = args.set_silent_time[0] + config.silent_end = args.set_silent_time[1] + + if args.set_interval_range: + config.min_interval = args.set_interval_range[0] + config.max_interval = args.set_interval_range[1] + + if args.set_bark_url: + config.bark_api_url = args.set_bark_url + + if args.set_bark_key: + config.bark_device_key = args.set_bark_key + + if args.set_ntfy_url: + config.ntfy_api_url = args.set_ntfy_url + + if args.set_ntfy_topic: + config.ntfy_topic = args.set_ntfy_topic + + if args.set_ntfy_token: + config.ntfy_access_token = args.set_ntfy_token + + if any([args.set_ollama_endpoint, args.set_ollama_model, args.set_silent_time, + args.set_interval_range, args.set_bark_url, args.set_bark_key, + args.set_ntfy_url, args.set_ntfy_topic, args.set_ntfy_token]): + config.save() + print("Configuration saved successfully!") + + if args.show or not any(vars(args).values()): + print("Current Configuration:") + print(f" Ollama Endpoint: {config.ollama_endpoint}") + print(f" Ollama Model: {config.ollama_model}") + print(f" Silent Time: {config.silent_start} - {config.silent_end}") + print(f" Interval Range: {config.min_interval} - {config.max_interval} minutes") + print(f" Bark API URL: {config.bark_api_url or 'Not set'}") + print(f" Bark Device Key: {'***' if config.bark_device_key else 'Not set'}") + print(f" Ntfy API URL: {config.ntfy_api_url or 'Not set'}") + print(f" Ntfy Topic: {config.ntfy_topic or 'Not set'}") + print(f" Ntfy Token: {'***' if config.ntfy_access_token else 'Not set'}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..080f803 --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +import asyncio +import logging +from pathlib import Path +from scheduler import NotificationScheduler +from config import Config + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +async def main(): + """Main entry point for the notification system.""" + config = Config() + scheduler = NotificationScheduler(config) + + try: + await scheduler.start() + except KeyboardInterrupt: + logger.info("Shutting down...") + except Exception as e: + logger.error(f"Error in main: {e}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/notification_client.py b/notification_client.py new file mode 100644 index 0000000..6382aa1 --- /dev/null +++ b/notification_client.py @@ -0,0 +1,119 @@ +import aiohttp +import logging +from typing import Dict, Any, Optional, List +from config import Config + +logger = logging.getLogger(__name__) + +class NotificationClient: + """Client for sending notifications to both Bark and Ntfy services.""" + + def __init__(self, config: Config): + self.config = config + self.session = None + + async def __aenter__(self): + self.session = aiohttp.ClientSession() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.session: + await self.session.close() + + async def send_notification(self, message: str, title: str = "AI Thought") -> bool: + """Send notification to both Bark and Ntfy services.""" + results = [] + + # Send to Bark if configured + if self.config.bark_api_url and self.config.bark_device_key: + bark_success = await self._send_bark_notification(message, title) + results.append(bark_success) + + # Send to Ntfy if configured + if self.config.ntfy_api_url and self.config.ntfy_topic: + ntfy_success = await self._send_ntfy_notification(message, title) + results.append(ntfy_success) + + # Return True if at least one service succeeded + return any(results) if results else True + + async def _send_bark_notification(self, message: str, title: str) -> bool: + """Send notification to Bark service.""" + if not self.session: + self.session = aiohttp.ClientSession() + + # Bark API format: https://api.day.app/{device_key}/{title}/{body} + url = f"{self.config.bark_api_url}/{self.config.bark_device_key}/{title}/{message}" + + try: + async with self.session.get(url) as response: + if response.status == 200: + data = await response.json() + if data.get("code") == 200: + logger.info("Bark notification sent successfully") + return True + else: + logger.error(f"Bark API error: {data}") + return False + else: + logger.error(f"Bark HTTP error: {response.status}") + return False + + except Exception as e: + logger.error(f"Error sending Bark notification: {e}") + return False + + async def _send_ntfy_notification(self, message: str, title: str) -> bool: + """Send notification to Ntfy service.""" + if not self.session: + self.session = aiohttp.ClientSession() + + url = f"{self.config.ntfy_api_url}/{self.config.ntfy_topic}" + + headers = { + "Title": title, + "Content-Type": "text/plain", + "User-Agent": "Ollama-Notification-System/1.0" + } + + if self.config.ntfy_access_token: + headers["Authorization"] = f"Bearer {self.config.ntfy_access_token}" + + try: + async with self.session.post(url, data=message, headers=headers) as response: + if response.status == 200: + logger.info("Ntfy notification sent successfully") + return True + else: + logger.error(f"Ntfy HTTP error: {response.status}") + return False + + except Exception as e: + logger.error(f"Error sending Ntfy notification: {e}") + return False + + async def test_connections(self) -> Dict[str, bool]: + """Test connections to both notification services.""" + results = {} + + # Test Bark + if self.config.bark_api_url: + try: + async with self.session.get(self.config.bark_api_url, timeout=aiohttp.ClientTimeout(total=10)) as response: + results["bark"] = response.status < 400 + except Exception: + results["bark"] = False + else: + results["bark"] = None + + # Test Ntfy + if self.config.ntfy_api_url: + try: + async with self.session.get(self.config.ntfy_api_url, timeout=aiohttp.ClientTimeout(total=10)) as response: + results["ntfy"] = response.status < 400 + except Exception: + results["ntfy"] = False + else: + results["ntfy"] = None + + return results \ No newline at end of file diff --git a/ollama_client.py b/ollama_client.py new file mode 100644 index 0000000..fe6fc2b --- /dev/null +++ b/ollama_client.py @@ -0,0 +1,58 @@ +import aiohttp +import json +import logging +from typing import Dict, Any, Optional +from config import Config + +logger = logging.getLogger(__name__) + +class OllamaClient: + """Client for interacting with Ollama API.""" + + def __init__(self, config: Config): + self.config = config + self.session = None + + async def __aenter__(self): + self.session = aiohttp.ClientSession() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.session: + await self.session.close() + + async def generate_response(self, prompt: str) -> Optional[str]: + """Generate a response from Ollama for the given prompt.""" + if not self.session: + self.session = aiohttp.ClientSession() + + url = f"{self.config.ollama_endpoint}/api/generate" + payload = { + "model": self.config.ollama_model, + "prompt": prompt, + "stream": False + } + + try: + async with self.session.post(url, json=payload) as response: + if response.status == 200: + data = await response.json() + return data.get("response", "").strip() + else: + logger.error(f"Ollama API error: {response.status}") + return None + except Exception as e: + logger.error(f"Error calling Ollama API: {e}") + return None + + async def check_health(self) -> bool: + """Check if Ollama service is available.""" + if not self.session: + self.session = aiohttp.ClientSession() + + try: + async with self.session.get(f"{self.config.ollama_endpoint}/api/tags") as response: + return response.status == 200 + except Exception as e: + logger.error(f"Ollama health check failed: {e}") + return False \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5998238 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=3.8.0 +asyncio \ No newline at end of file diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..23c8cf6 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,108 @@ +import asyncio +import random +import logging +from datetime import datetime, time, timedelta +from typing import Optional +from config import Config +from ollama_client import OllamaClient +from template_manager import TemplateManager +from notification_client import NotificationClient + +logger = logging.getLogger(__name__) + +class NotificationScheduler: + """Handles scheduling of notifications with random intervals and silent time.""" + + def __init__(self, config: Config): + self.config = config + self.template_manager = TemplateManager(config.templates_dir) + self.notification_client = NotificationClient(config) + self.running = False + + def _is_silent_time(self) -> bool: + """Check if current time is within silent hours.""" + current_time = datetime.now().time() + + # Parse silent time configuration + silent_start = datetime.strptime(self.config.silent_start, "%H:%M").time() + silent_end = datetime.strptime(self.config.silent_end, "%H:%M").time() + + # Handle overnight silent period (e.g., 20:00 to 12:00) + if silent_start >= silent_end: + return current_time >= silent_start or current_time <= silent_end + else: + return silent_start <= current_time <= silent_end + + def _get_next_interval(self) -> int: + """Get random interval in seconds between min and max.""" + minutes = random.randint(self.config.min_interval, self.config.max_interval) + return minutes * 60 + + async def _send_notification(self) -> bool: + """Send a notification with Ollama response.""" + try: + async with OllamaClient(self.config) as client: + # Check if Ollama is available + if not await client.check_health(): + logger.error("Ollama service is not available") + return False + + # Get random prompt + prompt = self.template_manager.get_random_prompt() + logger.info(f"Using prompt: {prompt}") + + # Get response from Ollama + response = await client.generate_response(prompt) + if not response: + logger.error("Failed to get response from Ollama") + return False + + # Send notification + success = await self.notification_client.send_notification(response) + if success: + logger.info("Notification sent successfully") + else: + logger.error("Failed to send notification") + + return success + + except Exception as e: + logger.error(f"Error sending notification: {e}") + return False + + async def start(self): + """Start the notification scheduler.""" + self.running = True + logger.info("Starting notification scheduler...") + + while self.running: + try: + # Check if it's silent time + if self._is_silent_time(): + logger.info("Silent time - skipping notification") + # Wait 30 minutes before checking again + await asyncio.sleep(1800) + continue + + # Send notification + await self._send_notification() + + # Calculate next interval + interval = self._get_next_interval() + next_time = datetime.now() + timedelta(seconds=interval) + logger.info(f"Next notification scheduled for: {next_time.strftime('%Y-%m-%d %H:%M:%S')}") + + # Wait for next interval + await asyncio.sleep(interval) + + except asyncio.CancelledError: + logger.info("Scheduler cancelled") + break + except Exception as e: + logger.error(f"Error in scheduler: {e}") + # Wait 5 minutes on error + await asyncio.sleep(300) + + def stop(self): + """Stop the notification scheduler.""" + self.running = False \ No newline at end of file diff --git a/template_manager.py b/template_manager.py new file mode 100644 index 0000000..131a492 --- /dev/null +++ b/template_manager.py @@ -0,0 +1,122 @@ +import json +import random +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any +import logging + +logger = logging.getLogger(__name__) + +class TemplateManager: + """Manages prompt templates with placeholders and random selection.""" + + def __init__(self, templates_dir: str = "templates"): + self.templates_dir = Path(templates_dir) + self.templates_dir.mkdir(exist_ok=True) + self.templates = [] + self._load_templates() + + def _load_templates(self): + """Load templates from JSON files in the templates directory.""" + if not self.templates_dir.exists(): + self._create_default_templates() + + for template_file in self.templates_dir.glob("*.json"): + try: + with open(template_file, 'r') as f: + template_data = json.load(f) + if isinstance(template_data, list): + self.templates.extend(template_data) + else: + self.templates.append(template_data) + except Exception as e: + logger.error(f"Error loading template {template_file}: {e}") + + if not self.templates: + self._create_default_templates() + + def _create_default_templates(self): + """Create default templates if none exist.""" + default_templates = [ + { + "name": "Daily Reflection", + "prompt": "What are your thoughts about {date} at {time}?", + "description": "A simple daily reflection prompt" + }, + { + "name": "Mindfulness Check", + "prompt": "How are you feeling right now on this {day} at {time}?", + "description": "A mindfulness check-in prompt" + }, + { + "name": "Gratitude Prompt", + "prompt": "What are three things you're grateful for on {date} during this {time_of_day}?", + "description": "A gratitude reflection prompt" + } + ] + + template_file = self.templates_dir / "default.json" + with open(template_file, 'w') as f: + json.dump(default_templates, f, indent=2) + + self.templates.extend(default_templates) + + def _get_time_of_day(self, hour: int) -> str: + """Return time of day based on hour.""" + if 5 <= hour < 12: + return "morning" + elif 12 <= hour < 17: + return "afternoon" + elif 17 <= hour < 21: + return "evening" + else: + return "night" + + def _fill_placeholders(self, template: str) -> str: + """Fill placeholders in template with current values.""" + now = datetime.now() + placeholders = { + "date": now.strftime("%Y-%m-%d"), + "time": now.strftime("%H:%M"), + "day": now.strftime("%A"), + "time_of_day": self._get_time_of_day(now.hour), + "weekday": now.strftime("%A"), + "month": now.strftime("%B"), + "day_of_month": str(now.day) + } + + for placeholder, value in placeholders.items(): + template = template.replace(f"{{{placeholder}}}", value) + + return template + + def get_random_prompt(self) -> str: + """Get a random prompt template with placeholders filled.""" + if not self.templates: + logger.warning("No templates available") + return "What are your thoughts right now?" + + template = random.choice(self.templates) + prompt = template.get("prompt", "") + return self._fill_placeholders(prompt) + + def add_template(self, name: str, prompt: str, description: str = ""): + """Add a new template to the system.""" + new_template = { + "name": name, + "prompt": prompt, + "description": description + } + self.templates.append(new_template) + + # Save to file + template_file = self.templates_dir / "custom.json" + custom_templates = [] + if template_file.exists(): + with open(template_file, 'r') as f: + custom_templates = json.load(f) + + custom_templates.append(new_template) + + with open(template_file, 'w') as f: + json.dump(custom_templates, f, indent=2) \ No newline at end of file