first commit
This commit is contained in:
commit
7d3770dba3
|
|
@ -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
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
aiohttp>=3.8.0
|
||||||
|
asyncio
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue