initial commit
This commit is contained in:
63
.dockerignore
Normal file
63
.dockerignore
Normal file
@@ -0,0 +1,63 @@
|
||||
# DiaryML Docker Ignore
|
||||
# Excludes unnecessary files from Docker build context
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
venv*/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Data (should be mounted as volumes, not copied)
|
||||
diary.db
|
||||
diary.db-wal
|
||||
diary.db-shm
|
||||
chroma_db/
|
||||
uploads/
|
||||
models/*.gguf
|
||||
models/*.bin
|
||||
model_config.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
LICENSE
|
||||
|
||||
# Backups
|
||||
backup_before_restore/
|
||||
*.backup
|
||||
*.zip
|
||||
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
# DiaryML .gitignore
|
||||
|
||||
# Private data
|
||||
diary.db
|
||||
diary.db-wal
|
||||
diary.db-shm
|
||||
chroma_db/
|
||||
uploads/
|
||||
model_config.json
|
||||
|
||||
# Model files (too large for git)
|
||||
models/*.gguf
|
||||
models/*.bin
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
venv*/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Flutter SDK (users should install separately)
|
||||
mobile_app/flutter/
|
||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
||||
# DiaryML Dockerfile
|
||||
# Builds a containerized version of DiaryML for easy deployment
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Install system dependencies
|
||||
# Required for: SQLCipher, llama-cpp-python compilation, ML libraries
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
cmake \
|
||||
git \
|
||||
libsqlcipher-dev \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better layer caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
# Note: llama-cpp-python may take a while to build from source
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p models chroma_db uploads
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:8000/api/status')" || exit 1
|
||||
|
||||
# Set the working directory to backend for running the server
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Run the FastAPI server
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
224
README.md
Normal file
224
README.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# DiaryML
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**Private AI journaling with emotion detection, temporal analytics, and deep insights—100% offline.**
|
||||
|
||||
## ✨ Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| 🔒 **AES-256 Encrypted** | SQLCipher database encryption, zero cloud dependencies |
|
||||
| 🧠 **Local AI** | Any GGUF model (1B-3B optimized), CPU-only, hotswappable |
|
||||
| 😊 **Professional Emotion Detection** | Calibrated AI analyzes 6 emotions with conversational awareness |
|
||||
| 📊 **Deep Analytics** | Writing streaks, productivity scores, temporal patterns |
|
||||
| 💬 **Chat Management** | Multiple sessions, full history, timestamps |
|
||||
| 🔍 **Advanced Search** | Full-text, date range, emotion filters |
|
||||
| ✏️ **Entry Editing** | Edit past entries with history tracking |
|
||||
| 💾 **Backup/Restore** | One-click zip backup of everything |
|
||||
| 🔊 **Voice Output** | Browser TTS for AI responses |
|
||||
| ⚡ **Model Hotswap** | Switch AI models without restarting |
|
||||
| 📈 **Pattern Detection** | Track projects, moods, creative rhythms |
|
||||
| 🎯 **RAG Search** | Semantic search across all entries |
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Important:** `pysqlcipher3` is required for database encryption. If installation fails on Windows, you may need to:
|
||||
- Install Visual C++ Build Tools
|
||||
- Or use a precompiled wheel from [Python packages](https://www.lfd.uci.edu/~gohlke/pythonlibs/)
|
||||
|
||||
⚠️ **Without pysqlcipher3**, the database will NOT be encrypted and data will be human-readable!
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Docker (Recommended - Easiest!)
|
||||
|
||||
```bash
|
||||
# 1. Download a GGUF model to models/ directory
|
||||
# 2. Build and run with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Open http://localhost:8000 and set your password
|
||||
```
|
||||
|
||||
**Docker Benefits:**
|
||||
- ✅ No dependency hell - everything bundled
|
||||
- ✅ Consistent environment across platforms
|
||||
- ✅ Easy updates with `docker-compose pull && docker-compose up -d`
|
||||
- ✅ Data persists in volumes (diary.db, models/, chroma_db/)
|
||||
|
||||
### Option 2: Manual Installation
|
||||
|
||||
**1. Get a Model**
|
||||
|
||||
Download any GGUF model (1-3B recommended) to `models/`:
|
||||
|
||||
```bash
|
||||
# Example: nsfw-ameba-3.2-1b-q5_k_m.gguf (fast on CPU)
|
||||
# Or any other GGUF from HuggingFace
|
||||
```
|
||||
|
||||
**2. Run**
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
start.bat
|
||||
```
|
||||
|
||||
**macOS/Linux:**
|
||||
```bash
|
||||
chmod +x start.sh && ./start.sh
|
||||
```
|
||||
|
||||
**3. Open**
|
||||
|
||||
Navigate to `http://localhost:8000` and set your password.
|
||||
|
||||
## 🎯 What's New
|
||||
|
||||
- ✅ **Docker Support**: One-command deployment with docker-compose
|
||||
- ✅ **Professional Emotion Detection**: Calibrated with conversational awareness (no more 98% anger!)
|
||||
- ✅ **<think> Tag Support**: Qwen MOE and reasoning models properly cleaned
|
||||
- ✅ **6-8x Larger Response Tokens**: 2k-4k tokens (no more cutoffs!)
|
||||
- ✅ **Chat Sessions**: Save, view, delete multiple chat conversations
|
||||
- ✅ **Writing Streaks**: Track daily writing habits and productivity
|
||||
- ✅ **Temporal Analytics**: Mood trends, weekly patterns, project insights
|
||||
- ✅ **Model Hotswapping**: Switch models from UI, persists across restarts
|
||||
- ✅ **Smart AI**: Auto-detects model size, quantization, thinking models
|
||||
- ✅ **Keyboard Shortcuts**: Ctrl+F (search), Ctrl+S (save), Ctrl+L (lock), Esc (close)
|
||||
- ✅ **Entry Search**: Full-text, date range, emotion filtering
|
||||
- ✅ **Backup System**: Download complete diary backup as zip
|
||||
|
||||
## 📖 API Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `POST /api/unlock` | Unlock diary with password |
|
||||
| `POST /api/chat` | Chat with AI (session-based) |
|
||||
| `GET /api/chat/sessions` | List all chat sessions |
|
||||
| `POST /api/chat/sessions` | Create new chat |
|
||||
| `DELETE /api/chat/sessions/{id}` | Delete chat session |
|
||||
| `GET /api/entries` | Get entries (with filters) |
|
||||
| `POST /api/entries` | Create new entry |
|
||||
| `PUT /api/entries/{id}` | Edit entry |
|
||||
| `DELETE /api/entries/{id}` | Delete entry |
|
||||
| `GET /api/search` | Search entries |
|
||||
| `GET /api/analytics/comprehensive` | All analytics |
|
||||
| `GET /api/analytics/streak` | Writing streak data |
|
||||
| `GET /api/analytics/productivity` | Creativity score |
|
||||
| `GET /api/models/list` | List available models |
|
||||
| `POST /api/models/switch` | Hot-swap AI model |
|
||||
| `GET /api/backup` | Download backup zip |
|
||||
|
||||
## 🧠 Supported Models
|
||||
|
||||
DiaryML works with **any GGUF model** but is optimized for 1-3B models on CPU:
|
||||
|
||||
### Text-Only Models (Recommended for CPU)
|
||||
- ✅ **1B models** (fastest): `nsfw-ameba-3.2-1b-q5_k_m.gguf`
|
||||
- ✅ **2B models** (balanced): Any Qwen/Llama 2B GGUF, Qwen3-MOE models
|
||||
- ✅ **3B models** (quality): `AI21-Jamba-Reasoning-3B-Q4_K_M.gguf`
|
||||
- ✅ **Thinking models**: Auto-detected and cleaned (supports `<think>`, `<output>`, reasoning tags)
|
||||
- ✅ **Context windows**: Large contexts for rich history (1B→24k, 2B→28k, 3B→32k tokens)
|
||||
- ✅ **Response tokens**: Generous limits (1B→2k, 2B→3k, 3B→4k+) - no more cutoffs!
|
||||
|
||||
### Vision-Language Models (Experimental)
|
||||
- ✅ **Vision models**: Automatically detect VL models (e.g., `LFM2-VL`, `Qwen-VL`)
|
||||
- ✅ **Image support**: Requires `mmproj-model-f16.gguf` in models folder
|
||||
- ✅ **Auto-detection**: DiaryML distinguishes text-only from vision models by filename
|
||||
|
||||
## 🔧 Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | FastAPI, Python 3.10+ |
|
||||
| Database | SQLite + SQLCipher (AES-256 encrypted) |
|
||||
| AI Models | llama.cpp (GGUF), CPU-optimized |
|
||||
| Embeddings | sentence-transformers |
|
||||
| Vector DB | ChromaDB |
|
||||
| Emotions | Hugging Face transformers |
|
||||
| Frontend | Vanilla JavaScript, CSS3 |
|
||||
|
||||
## 📊 Analytics Features
|
||||
|
||||
- **Writing Streak**: Current streak, longest streak, consistency score
|
||||
- **Productivity Score**: 0-100 score based on consistency, volume, mood, projects
|
||||
- **Temporal Patterns**: Mood trends over time, weekly patterns
|
||||
- **Project Insights**: Active/stale/dormant project tracking
|
||||
- **Mood Analysis**: Dominant emotions, increasing/decreasing trends
|
||||
|
||||
## 🎨 UI Features
|
||||
|
||||
- **Dark theme** optimized for focus
|
||||
- **Real-time word count** while writing
|
||||
- **Search modal** with advanced filters
|
||||
- **Settings panel** for model management
|
||||
- **Chat sessions** with full history
|
||||
- **Mood timeline** visualization
|
||||
- **Entry editing** with emotion re-analysis
|
||||
|
||||
## 🔐 Privacy & Security
|
||||
|
||||
- **100% Local**: No internet required after model download
|
||||
- **No Telemetry**: Zero tracking or analytics
|
||||
- **AES-256 Encrypted**: Database encrypted with SQLCipher using your password
|
||||
- **Auto-Cleanup**: VACUUM runs after deletions to physically remove data
|
||||
- **Portable**: Copy `diary.db` + `chroma_db/` to backup everything
|
||||
|
||||
**Encryption Status:**
|
||||
- ✅ With `pysqlcipher3`: Database is **fully encrypted** - unreadable without password
|
||||
- ⚠️ Without `pysqlcipher3`: Database is **not encrypted** - human-readable text
|
||||
|
||||
To verify encryption is working, look for this on startup:
|
||||
```
|
||||
✓ Using SQLCipher for database encryption
|
||||
```
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+F` | Open search |
|
||||
| `Ctrl+S` | Save entry (when in textarea) |
|
||||
| `Ctrl+L` | Lock diary |
|
||||
| `Esc` | Close modals |
|
||||
|
||||
## 📦 Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- 2-4GB RAM (depends on model size)
|
||||
- ~1-3GB disk space (model + data)
|
||||
- **No GPU required** (CPU-only optimized)
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Model not loading | Check filename in `models/`, ensure it's a valid GGUF |
|
||||
| Slow responses | Use smaller model (1B) or higher quantization (Q4_K_M) |
|
||||
| Can't unlock | First password creates DB; delete `diary.db` to reset |
|
||||
| ChromaDB error | Delete `chroma_db/` folder, will auto-rebuild |
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - use freely, modify, share.
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
Built with FastAPI, ChromaDB, llama.cpp, sentence-transformers, and Hugging Face transformers.
|
||||
|
||||
---
|
||||
|
||||
**DiaryML**: Your private creative companion. Capture emotions that words cannot express.
|
||||
186
SETUP.md
Normal file
186
SETUP.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# DiaryML Setup Guide
|
||||
|
||||
## Step-by-Step Installation
|
||||
|
||||
### Step 1: Download Model Files
|
||||
|
||||
You need to download two GGUF model files. Go to:
|
||||
https://huggingface.co/huihui-ai/Huihui-Qwen3-VL-2B-Instruct-abliterated/tree/main/GGUF
|
||||
|
||||
Download these files:
|
||||
- `ggml-model-f16.gguf` (~3.6 GB) - Main language model
|
||||
- `mmproj-model-f16.gguf` (~300 MB) - Vision projection model
|
||||
|
||||
**Alternative (if you have limited RAM/VRAM):**
|
||||
- `huihui-qwen3-vl-2b-instruct-abliterated-q4_k_m.gguf` (~1.5 GB) - Smaller quantized version
|
||||
|
||||
Place the downloaded files in the `DiaryML/models/` folder.
|
||||
|
||||
### Step 2: Install Python Dependencies
|
||||
|
||||
Open a terminal/command prompt in the DiaryML directory and run:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
This will install:
|
||||
- FastAPI (web framework)
|
||||
- SQLCipher (encrypted database)
|
||||
- ChromaDB (vector database)
|
||||
- llama-cpp-python (GGUF model runner)
|
||||
- sentence-transformers (embeddings)
|
||||
- transformers + torch (emotion detection)
|
||||
|
||||
**Installation may take 5-10 minutes.**
|
||||
|
||||
#### GPU Acceleration (Optional)
|
||||
|
||||
If you have an NVIDIA GPU and want faster AI inference:
|
||||
|
||||
```bash
|
||||
# Uninstall CPU version
|
||||
pip uninstall llama-cpp-python
|
||||
|
||||
# Install CUDA version
|
||||
CMAKE_ARGS="-DLLAMA_CUBLAS=on" pip install llama-cpp-python
|
||||
```
|
||||
|
||||
### Step 3: Verify Installation
|
||||
|
||||
Run this to check everything is ready:
|
||||
|
||||
```bash
|
||||
python -c "import fastapi, chromadb, transformers; print('All dependencies installed!')"
|
||||
```
|
||||
|
||||
### Step 4: Start DiaryML
|
||||
|
||||
**On Windows:**
|
||||
```bash
|
||||
start.bat
|
||||
```
|
||||
|
||||
**On Mac/Linux:**
|
||||
```bash
|
||||
cd backend
|
||||
python main.py
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:8000`
|
||||
|
||||
### Step 5: First Launch
|
||||
|
||||
1. Open your browser to `http://localhost:8000`
|
||||
2. You'll see the unlock screen
|
||||
3. Enter a password (this creates your encrypted diary)
|
||||
4. Wait for the AI model to load (~30 seconds)
|
||||
5. Start journaling!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "sqlcipher3" module not found
|
||||
|
||||
SQLCipher can be tricky on Windows. Try:
|
||||
|
||||
```bash
|
||||
pip install pysqlcipher3
|
||||
```
|
||||
|
||||
If that fails, you can temporarily disable encryption by modifying `database.py`:
|
||||
|
||||
```python
|
||||
# Comment out this line in get_connection():
|
||||
# conn.execute(f"PRAGMA key = '{self.password}'")
|
||||
```
|
||||
|
||||
**Note**: This removes encryption! Only use for testing.
|
||||
|
||||
### Error: Model file not found
|
||||
|
||||
Make sure:
|
||||
1. Files are named exactly `ggml-model-f16.gguf` and `mmproj-model-f16.gguf`
|
||||
2. They are in the `DiaryML/models/` folder
|
||||
3. The files are fully downloaded (not partial)
|
||||
|
||||
### Error: "CUDA out of memory"
|
||||
|
||||
The model is trying to use your GPU but running out of memory. Edit `qwen_interface.py`:
|
||||
|
||||
```python
|
||||
# Change this line (around line 37):
|
||||
n_gpu_layers=-1, # Change to 0 for CPU-only
|
||||
```
|
||||
|
||||
Or use a smaller quantized model (q4_k_m).
|
||||
|
||||
### Server won't start
|
||||
|
||||
Make sure port 8000 is not already in use:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
netstat -ano | findstr :8000
|
||||
|
||||
# Mac/Linux
|
||||
lsof -i :8000
|
||||
```
|
||||
|
||||
To use a different port, edit `main.py` at the bottom:
|
||||
|
||||
```python
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="127.0.0.1",
|
||||
port=8080, # Change this
|
||||
reload=True
|
||||
)
|
||||
```
|
||||
|
||||
## System Requirements
|
||||
|
||||
### Minimum
|
||||
- **CPU**: 4-core processor
|
||||
- **RAM**: 4GB free
|
||||
- **Disk**: 5GB (3GB models + 2GB for dependencies)
|
||||
- **OS**: Windows 10+, macOS 10.15+, Linux
|
||||
|
||||
### Recommended
|
||||
- **CPU**: 8-core processor
|
||||
- **RAM**: 8GB free
|
||||
- **GPU**: NVIDIA GPU with 4GB+ VRAM (for faster inference)
|
||||
- **Disk**: 10GB+
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use GPU acceleration** if available (5-10x faster)
|
||||
2. **Use quantized models** (q4_k_m) if RAM is limited
|
||||
3. **Close other applications** when using DiaryML
|
||||
4. **Reduce context window** in `qwen_interface.py` if needed:
|
||||
```python
|
||||
n_ctx=2048, # Instead of 4096
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once DiaryML is running:
|
||||
|
||||
1. Create your first journal entry
|
||||
2. Try attaching an image
|
||||
3. Chat with the AI about your entries
|
||||
4. Check the mood timeline after a few entries
|
||||
5. See daily suggestions each morning
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Check the console/terminal for error messages
|
||||
2. Review the troubleshooting section above
|
||||
3. Check the README.md for more details
|
||||
4. Open an issue on GitHub with your error logs
|
||||
|
||||
---
|
||||
|
||||
Enjoy your private creative companion!
|
||||
319
backend/analytics.py
Normal file
319
backend/analytics.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Advanced analytics for DiaryML
|
||||
Temporal awareness, pattern detection, and deeper insights
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict, Counter
|
||||
import statistics
|
||||
|
||||
|
||||
class DeepAnalytics:
|
||||
"""Advanced analytics engine for deeper insights"""
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def get_writing_streak(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate current writing streak and longest streak
|
||||
|
||||
Returns:
|
||||
Dict with current_streak, longest_streak, last_entry_date
|
||||
"""
|
||||
entries = self.db.get_recent_entries(limit=1000)
|
||||
|
||||
if not entries:
|
||||
return {
|
||||
"current_streak": 0,
|
||||
"longest_streak": 0,
|
||||
"last_entry_date": None,
|
||||
"total_entries": 0
|
||||
}
|
||||
|
||||
# Group entries by date
|
||||
entries_by_date = defaultdict(list)
|
||||
for entry in entries:
|
||||
date = datetime.fromisoformat(entry["timestamp"]).date()
|
||||
entries_by_date[date].append(entry)
|
||||
|
||||
# Sort dates
|
||||
dates = sorted(entries_by_date.keys(), reverse=True)
|
||||
|
||||
# Calculate current streak
|
||||
current_streak = 0
|
||||
today = datetime.now().date()
|
||||
|
||||
# Check if there's an entry today or yesterday
|
||||
if dates and (dates[0] == today or dates[0] == today - timedelta(days=1)):
|
||||
check_date = dates[0]
|
||||
for date in dates:
|
||||
if date == check_date:
|
||||
current_streak += 1
|
||||
check_date = check_date - timedelta(days=1)
|
||||
elif date < check_date - timedelta(days=1):
|
||||
break
|
||||
|
||||
# Calculate longest streak
|
||||
longest_streak = 0
|
||||
temp_streak = 1
|
||||
|
||||
for i in range(len(dates) - 1):
|
||||
if dates[i] - dates[i + 1] == timedelta(days=1):
|
||||
temp_streak += 1
|
||||
longest_streak = max(longest_streak, temp_streak)
|
||||
else:
|
||||
temp_streak = 1
|
||||
|
||||
longest_streak = max(longest_streak, temp_streak)
|
||||
|
||||
return {
|
||||
"current_streak": current_streak,
|
||||
"longest_streak": longest_streak,
|
||||
"last_entry_date": dates[0].isoformat() if dates else None,
|
||||
"total_entries": len(entries),
|
||||
"entries_this_week": sum(1 for d in dates if d >= today - timedelta(days=7)),
|
||||
"entries_this_month": sum(1 for d in dates if d >= today - timedelta(days=30))
|
||||
}
|
||||
|
||||
def analyze_temporal_mood_patterns(self, days: int = 30) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze how moods change over time
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
|
||||
Returns:
|
||||
Mood patterns and trends
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
timeline = self.db.get_mood_timeline(days=days)
|
||||
|
||||
if not timeline:
|
||||
return {"patterns": [], "trends": {}}
|
||||
|
||||
# Group by emotion
|
||||
emotions_over_time = defaultdict(list)
|
||||
for entry in timeline:
|
||||
emotions_over_time[entry["emotion"]].append({
|
||||
"date": entry["date"],
|
||||
"score": entry["avg_score"]
|
||||
})
|
||||
|
||||
# Calculate trends (increasing/decreasing)
|
||||
trends = {}
|
||||
for emotion, data_points in emotions_over_time.items():
|
||||
if len(data_points) >= 2:
|
||||
scores = [d["score"] for d in sorted(data_points, key=lambda x: x["date"])]
|
||||
# Simple trend: compare first half to second half
|
||||
mid = len(scores) // 2
|
||||
first_half_avg = statistics.mean(scores[:mid])
|
||||
second_half_avg = statistics.mean(scores[mid:])
|
||||
|
||||
if second_half_avg > first_half_avg * 1.1:
|
||||
trends[emotion] = "increasing"
|
||||
elif second_half_avg < first_half_avg * 0.9:
|
||||
trends[emotion] = "decreasing"
|
||||
else:
|
||||
trends[emotion] = "stable"
|
||||
|
||||
# Identify patterns
|
||||
patterns = []
|
||||
|
||||
# Weekly patterns
|
||||
weekly_moods = self._analyze_weekly_patterns(timeline)
|
||||
if weekly_moods:
|
||||
patterns.append({
|
||||
"type": "weekly",
|
||||
"description": weekly_moods
|
||||
})
|
||||
|
||||
return {
|
||||
"trends": trends,
|
||||
"patterns": patterns,
|
||||
"dominant_emotion_last_week": self._get_dominant_emotion_period(timeline, 7),
|
||||
"dominant_emotion_last_month": self._get_dominant_emotion_period(timeline, 30)
|
||||
}
|
||||
|
||||
def _analyze_weekly_patterns(self, timeline: List[Dict]) -> Optional[str]:
|
||||
"""Analyze if there are weekly mood patterns"""
|
||||
# Group by day of week
|
||||
day_emotions = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
for entry in timeline:
|
||||
date = datetime.fromisoformat(entry["date"])
|
||||
day_of_week = date.strftime("%A")
|
||||
day_emotions[day_of_week][entry["emotion"]].append(entry["avg_score"])
|
||||
|
||||
# Find if any day has consistently different mood
|
||||
insights = []
|
||||
for day, emotions in day_emotions.items():
|
||||
for emotion, scores in emotions.items():
|
||||
if len(scores) >= 2 and statistics.mean(scores) > 0.6:
|
||||
insights.append(f"Higher {emotion} on {day}s")
|
||||
|
||||
return "; ".join(insights) if insights else None
|
||||
|
||||
def _get_dominant_emotion_period(self, timeline: List[Dict], days: int) -> Optional[str]:
|
||||
"""Get dominant emotion for a specific period"""
|
||||
cutoff = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
recent = [e for e in timeline if e["date"] >= cutoff]
|
||||
|
||||
if not recent:
|
||||
return None
|
||||
|
||||
emotion_totals = defaultdict(float)
|
||||
for entry in recent:
|
||||
emotion_totals[entry["emotion"]] += entry["avg_score"]
|
||||
|
||||
if emotion_totals:
|
||||
return max(emotion_totals.items(), key=lambda x: x[1])[0]
|
||||
return None
|
||||
|
||||
def get_project_insights(self) -> Dict[str, Any]:
|
||||
"""Analyze project engagement over time"""
|
||||
projects = self.db.get_active_projects()
|
||||
|
||||
insights = {
|
||||
"active_projects": len(projects),
|
||||
"projects": [],
|
||||
"recommendations": []
|
||||
}
|
||||
|
||||
for project in projects:
|
||||
# Get all mentions of this project
|
||||
project_id = project["id"]
|
||||
|
||||
with self.db.get_connection() as conn:
|
||||
mentions = conn.execute(
|
||||
"""
|
||||
SELECT e.timestamp, pm.mention_type
|
||||
FROM project_mentions pm
|
||||
JOIN entries e ON pm.entry_id = e.id
|
||||
WHERE pm.project_id = ?
|
||||
ORDER BY e.timestamp DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
(project_id,)
|
||||
).fetchall()
|
||||
|
||||
if mentions:
|
||||
last_mention = datetime.fromisoformat(mentions[0]["timestamp"])
|
||||
days_since = (datetime.now() - last_mention).days
|
||||
|
||||
project_data = {
|
||||
"name": project["name"],
|
||||
"total_mentions": len(mentions),
|
||||
"days_since_last_mention": days_since,
|
||||
"status": "active" if days_since < 7 else "stale" if days_since < 30 else "dormant"
|
||||
}
|
||||
|
||||
insights["projects"].append(project_data)
|
||||
|
||||
# Add recommendations
|
||||
if days_since > 14 and days_since < 60:
|
||||
insights["recommendations"].append(
|
||||
f"You haven't worked on '{project['name']}' in {days_since} days. Consider revisiting it!"
|
||||
)
|
||||
|
||||
return insights
|
||||
|
||||
def get_creative_productivity_score(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate a creativity/productivity score based on various factors
|
||||
|
||||
Returns:
|
||||
Score (0-100) and contributing factors
|
||||
"""
|
||||
streak = self.get_writing_streak()
|
||||
mood_analysis = self.analyze_temporal_mood_patterns(days=7)
|
||||
|
||||
# Factors
|
||||
factors = {}
|
||||
|
||||
# Consistency factor (0-30 points)
|
||||
if streak["current_streak"] >= 7:
|
||||
factors["consistency"] = 30
|
||||
elif streak["current_streak"] >= 3:
|
||||
factors["consistency"] = 20
|
||||
elif streak["current_streak"] >= 1:
|
||||
factors["consistency"] = 10
|
||||
else:
|
||||
factors["consistency"] = 0
|
||||
|
||||
# Volume factor (0-30 points)
|
||||
week_entries = streak["entries_this_week"]
|
||||
if week_entries >= 7:
|
||||
factors["volume"] = 30
|
||||
elif week_entries >= 5:
|
||||
factors["volume"] = 25
|
||||
elif week_entries >= 3:
|
||||
factors["volume"] = 15
|
||||
elif week_entries >= 1:
|
||||
factors["volume"] = 10
|
||||
else:
|
||||
factors["volume"] = 0
|
||||
|
||||
# Mood factor (0-20 points) - positive moods boost score
|
||||
positive_moods = ["joy", "love", "excitement", "calm"]
|
||||
dominant = mood_analysis.get("dominant_emotion_last_week")
|
||||
if dominant in positive_moods:
|
||||
factors["mood"] = 20
|
||||
else:
|
||||
factors["mood"] = 10
|
||||
|
||||
# Project engagement (0-20 points)
|
||||
project_insights = self.get_project_insights()
|
||||
active_count = sum(1 for p in project_insights["projects"] if p["status"] == "active")
|
||||
if active_count >= 3:
|
||||
factors["projects"] = 20
|
||||
elif active_count >= 2:
|
||||
factors["projects"] = 15
|
||||
elif active_count >= 1:
|
||||
factors["projects"] = 10
|
||||
else:
|
||||
factors["projects"] = 5
|
||||
|
||||
total_score = sum(factors.values())
|
||||
|
||||
return {
|
||||
"score": total_score,
|
||||
"max_score": 100,
|
||||
"factors": factors,
|
||||
"level": self._get_productivity_level(total_score)
|
||||
}
|
||||
|
||||
def _get_productivity_level(self, score: int) -> str:
|
||||
"""Get productivity level description"""
|
||||
if score >= 80:
|
||||
return "🔥 On Fire"
|
||||
elif score >= 60:
|
||||
return "✨ Thriving"
|
||||
elif score >= 40:
|
||||
return "📈 Building Momentum"
|
||||
elif score >= 20:
|
||||
return "🌱 Growing"
|
||||
else:
|
||||
return "🌙 Resting Phase"
|
||||
|
||||
def get_comprehensive_insights(self) -> Dict[str, Any]:
|
||||
"""Get all insights in one call"""
|
||||
return {
|
||||
"streak": self.get_writing_streak(),
|
||||
"mood_patterns": self.analyze_temporal_mood_patterns(),
|
||||
"project_insights": self.get_project_insights(),
|
||||
"productivity_score": self.get_creative_productivity_score()
|
||||
}
|
||||
|
||||
|
||||
# Singleton
|
||||
_analytics_instance: Optional[DeepAnalytics] = None
|
||||
|
||||
|
||||
def get_analytics(db) -> DeepAnalytics:
|
||||
"""Get or create analytics instance"""
|
||||
global _analytics_instance
|
||||
if _analytics_instance is None:
|
||||
_analytics_instance = DeepAnalytics(db)
|
||||
return _analytics_instance
|
||||
650
backend/database.py
Normal file
650
backend/database.py
Normal file
@@ -0,0 +1,650 @@
|
||||
"""
|
||||
Password-protected encrypted database for DiaryML
|
||||
All data stored in an AES-256 encrypted SQLite file using SQLCipher
|
||||
Provides both password verification and full database encryption
|
||||
|
||||
Uses pysqlcipher3 for transparent encryption (install: pip install pysqlcipher3)
|
||||
Falls back to standard sqlite3 with warning if pysqlcipher3 not available
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from datetime import datetime
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
|
||||
# Try to import SQLCipher for encrypted database
|
||||
try:
|
||||
from pysqlcipher3 import dbapi2 as sqlite3
|
||||
HAS_ENCRYPTION = True
|
||||
_ENCRYPTION_STATUS_LOGGED = False
|
||||
except ImportError:
|
||||
import sqlite3
|
||||
HAS_ENCRYPTION = False
|
||||
_ENCRYPTION_STATUS_LOGGED = False
|
||||
|
||||
|
||||
class DiaryDatabase:
|
||||
"""Password-protected and encrypted SQLite database for DiaryML"""
|
||||
|
||||
def __init__(self, db_path: Optional[Path] = None, password: Optional[str] = None):
|
||||
"""
|
||||
Initialize database with password protection and encryption
|
||||
|
||||
Args:
|
||||
db_path: Path to database file (default: DiaryML/diary.db)
|
||||
password: Password for encryption and verification
|
||||
"""
|
||||
global _ENCRYPTION_STATUS_LOGGED
|
||||
|
||||
# Log encryption status once on first database initialization
|
||||
if not _ENCRYPTION_STATUS_LOGGED:
|
||||
if HAS_ENCRYPTION:
|
||||
print("✓ Using SQLCipher for database encryption")
|
||||
else:
|
||||
print("⚠ WARNING: pysqlcipher3 not found - database will NOT be encrypted!")
|
||||
print(" Install with: pip install pysqlcipher3")
|
||||
print(" Your data is currently human-readable in diary.db")
|
||||
_ENCRYPTION_STATUS_LOGGED = True
|
||||
|
||||
if db_path is None:
|
||||
db_path = Path(__file__).parent.parent / "diary.db"
|
||||
|
||||
self.db_path = db_path
|
||||
self.password = password
|
||||
self._password_hash = self._hash_password(password) if password else None
|
||||
self._connection: Optional[sqlite3.Connection] = None
|
||||
self.is_encrypted = HAS_ENCRYPTION
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
"""Hash password for storage/verification"""
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
@contextmanager
|
||||
def get_connection(self):
|
||||
"""Get database connection with encryption"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
|
||||
# CRITICAL: Set encryption key FIRST (before any other operations)
|
||||
if HAS_ENCRYPTION and self.password:
|
||||
# Use the password directly as the encryption key
|
||||
conn.execute(f"PRAGMA key = '{self.password}'")
|
||||
# Set SQLCipher compatibility for better performance
|
||||
conn.execute("PRAGMA cipher_compatibility = 4")
|
||||
|
||||
# Use WAL mode for better concurrency
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
|
||||
# Return rows as dictionaries
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def initialize_schema(self):
|
||||
"""Create database schema and store password hash"""
|
||||
with self.get_connection() as conn:
|
||||
# Password verification table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS auth (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
password_hash TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Store password hash on first initialization
|
||||
if self._password_hash:
|
||||
existing = conn.execute("SELECT password_hash FROM auth WHERE id = 1").fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO auth (id, password_hash) VALUES (1, ?)",
|
||||
(self._password_hash,)
|
||||
)
|
||||
# Entries table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
content TEXT NOT NULL,
|
||||
image_path TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Moods table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS moods (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
emotion TEXT NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Projects table (extracted mentions)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
first_mentioned DATETIME,
|
||||
last_mentioned DATETIME,
|
||||
status TEXT DEFAULT 'active'
|
||||
)
|
||||
""")
|
||||
|
||||
# Project mentions in entries
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS project_mentions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
mention_type TEXT,
|
||||
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Media mentions (movies, books, music, etc.)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS media_mentions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sentiment TEXT,
|
||||
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# User preferences and patterns
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_data (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Chat sessions
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Chat messages
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_entries_timestamp ON entries(timestamp)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_moods_entry ON moods(entry_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name)")
|
||||
|
||||
def add_entry(
|
||||
self,
|
||||
content: str,
|
||||
image_path: Optional[str] = None,
|
||||
timestamp: Optional[datetime] = None
|
||||
) -> int:
|
||||
"""
|
||||
Add new diary entry
|
||||
|
||||
Returns:
|
||||
entry_id
|
||||
"""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now()
|
||||
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO entries (content, image_path, timestamp) VALUES (?, ?, ?)",
|
||||
(content, image_path, timestamp)
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def add_mood(self, entry_id: int, emotions: Dict[str, float]):
|
||||
"""Add emotion scores for an entry"""
|
||||
with self.get_connection() as conn:
|
||||
for emotion, score in emotions.items():
|
||||
conn.execute(
|
||||
"INSERT INTO moods (entry_id, emotion, score) VALUES (?, ?, ?)",
|
||||
(entry_id, emotion, score)
|
||||
)
|
||||
|
||||
def get_entry(self, entry_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get entry by ID with moods"""
|
||||
with self.get_connection() as conn:
|
||||
# Get entry
|
||||
entry = conn.execute(
|
||||
"SELECT * FROM entries WHERE id = ?",
|
||||
(entry_id,)
|
||||
).fetchone()
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
entry_dict = dict(entry)
|
||||
|
||||
# Get moods
|
||||
moods = conn.execute(
|
||||
"SELECT emotion, score FROM moods WHERE entry_id = ?",
|
||||
(entry_id,)
|
||||
).fetchall()
|
||||
|
||||
entry_dict["moods"] = {row["emotion"]: row["score"] for row in moods}
|
||||
|
||||
return entry_dict
|
||||
|
||||
def get_recent_entries(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Get recent entries"""
|
||||
with self.get_connection() as conn:
|
||||
entries = conn.execute(
|
||||
"SELECT * FROM entries ORDER BY timestamp DESC LIMIT ?",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
|
||||
result = []
|
||||
for entry in entries:
|
||||
entry_dict = dict(entry)
|
||||
|
||||
# Get moods
|
||||
moods = conn.execute(
|
||||
"SELECT emotion, score FROM moods WHERE entry_id = ?",
|
||||
(entry_dict["id"],)
|
||||
).fetchall()
|
||||
|
||||
entry_dict["moods"] = {row["emotion"]: row["score"] for row in moods}
|
||||
result.append(entry_dict)
|
||||
|
||||
return result
|
||||
|
||||
def delete_entry(self, entry_id: int):
|
||||
"""Delete an entry (cascades to moods, projects, etc.)"""
|
||||
with self.get_connection() as conn:
|
||||
conn.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
|
||||
# Reclaim disk space
|
||||
conn.execute("VACUUM")
|
||||
|
||||
def update_entry(self, entry_id: int, content: str, timestamp: Optional[datetime] = None):
|
||||
"""
|
||||
Update an existing entry's content
|
||||
|
||||
Args:
|
||||
entry_id: ID of the entry to update
|
||||
content: New content for the entry
|
||||
timestamp: Optional new timestamp
|
||||
"""
|
||||
with self.get_connection() as conn:
|
||||
if timestamp:
|
||||
conn.execute(
|
||||
"UPDATE entries SET content = ?, timestamp = ? WHERE id = ?",
|
||||
(content, timestamp, entry_id)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE entries SET content = ? WHERE id = ?",
|
||||
(content, entry_id)
|
||||
)
|
||||
|
||||
def add_project(self, name: str, status: str = "active") -> int:
|
||||
"""Add or update project"""
|
||||
now = datetime.now()
|
||||
|
||||
with self.get_connection() as conn:
|
||||
# Try to insert, or update if exists
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO projects (name, first_mentioned, last_mentioned, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
last_mentioned = ?,
|
||||
status = ?
|
||||
""",
|
||||
(name, now, now, status, now, status)
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def link_project_to_entry(self, entry_id: int, project_name: str, mention_type: str = "mention"):
|
||||
"""Link a project mention to an entry"""
|
||||
project_id = self.add_project(project_name)
|
||||
|
||||
with self.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO project_mentions (entry_id, project_id, mention_type) VALUES (?, ?, ?)",
|
||||
(entry_id, project_id, mention_type)
|
||||
)
|
||||
|
||||
def get_active_projects(self) -> List[Dict[str, Any]]:
|
||||
"""Get active projects"""
|
||||
with self.get_connection() as conn:
|
||||
projects = conn.execute(
|
||||
"""
|
||||
SELECT * FROM projects
|
||||
WHERE status = 'active'
|
||||
ORDER BY last_mentioned DESC
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in projects]
|
||||
|
||||
def add_media_mention(self, entry_id: int, media_type: str, title: str, sentiment: str = "neutral"):
|
||||
"""Add media mention (movie, book, music, etc.)"""
|
||||
with self.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO media_mentions (entry_id, media_type, title, sentiment) VALUES (?, ?, ?, ?)",
|
||||
(entry_id, media_type, title, sentiment)
|
||||
)
|
||||
|
||||
def get_media_history(self, media_type: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Get media history, optionally filtered by type"""
|
||||
with self.get_connection() as conn:
|
||||
if media_type:
|
||||
media = conn.execute(
|
||||
"""
|
||||
SELECT * FROM media_mentions
|
||||
WHERE media_type = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(media_type, limit)
|
||||
).fetchall()
|
||||
else:
|
||||
media = conn.execute(
|
||||
"SELECT * FROM media_mentions ORDER BY id DESC LIMIT ?",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in media]
|
||||
|
||||
def get_mood_timeline(self, days: int = 30) -> List[Dict[str, Any]]:
|
||||
"""Get mood trends over time"""
|
||||
with self.get_connection() as conn:
|
||||
results = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
DATE(e.timestamp) as date,
|
||||
m.emotion,
|
||||
AVG(m.score) as avg_score
|
||||
FROM entries e
|
||||
JOIN moods m ON e.id = m.entry_id
|
||||
WHERE e.timestamp >= datetime('now', '-' || ? || ' days')
|
||||
GROUP BY DATE(e.timestamp), m.emotion
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
(days,)
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in results]
|
||||
|
||||
def search_entries(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
emotions: Optional[List[str]] = None,
|
||||
limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search entries with various filters
|
||||
|
||||
Args:
|
||||
query: Text search query
|
||||
start_date: Filter entries after this date
|
||||
end_date: Filter entries before this date
|
||||
emotions: Filter by dominant emotions
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of matching entries with metadata
|
||||
"""
|
||||
with self.get_connection() as conn:
|
||||
# Build the query dynamically
|
||||
sql = "SELECT DISTINCT e.* FROM entries e"
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
# Join moods if filtering by emotion
|
||||
if emotions:
|
||||
sql += " JOIN moods m ON e.id = m.entry_id"
|
||||
|
||||
sql += " WHERE 1=1"
|
||||
|
||||
# Text search
|
||||
if query:
|
||||
conditions.append("e.content LIKE ?")
|
||||
params.append(f"%{query}%")
|
||||
|
||||
# Date range filter
|
||||
if start_date:
|
||||
conditions.append("e.timestamp >= ?")
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
conditions.append("e.timestamp <= ?")
|
||||
params.append(end_date)
|
||||
|
||||
# Emotion filter
|
||||
if emotions and len(emotions) > 0:
|
||||
placeholders = ','.join('?' * len(emotions))
|
||||
conditions.append(f"m.emotion IN ({placeholders})")
|
||||
params.extend(emotions)
|
||||
# Only include entries where emotion has significant score
|
||||
conditions.append("m.score > 0.3")
|
||||
|
||||
# Add conditions
|
||||
if conditions:
|
||||
sql += " AND " + " AND ".join(conditions)
|
||||
|
||||
# Order and limit
|
||||
sql += " ORDER BY e.timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
# Execute query
|
||||
entries = conn.execute(sql, params).fetchall()
|
||||
|
||||
# Get moods for each entry
|
||||
result = []
|
||||
for entry in entries:
|
||||
entry_dict = dict(entry)
|
||||
|
||||
# Get moods
|
||||
moods = conn.execute(
|
||||
"SELECT emotion, score FROM moods WHERE entry_id = ?",
|
||||
(entry_dict["id"],)
|
||||
).fetchall()
|
||||
|
||||
entry_dict["moods"] = {row["emotion"]: row["score"] for row in moods}
|
||||
result.append(entry_dict)
|
||||
|
||||
return result
|
||||
|
||||
def set_user_preference(self, key: str, value: Any):
|
||||
"""Store user preference or pattern"""
|
||||
with self.get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO user_data (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(key, json.dumps(value), json.dumps(value))
|
||||
)
|
||||
|
||||
def get_user_preference(self, key: str) -> Optional[Any]:
|
||||
"""Get user preference"""
|
||||
with self.get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM user_data WHERE key = ?",
|
||||
(key,)
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
return json.loads(row["value"])
|
||||
return None
|
||||
|
||||
def verify_password(self) -> bool:
|
||||
"""Verify the password is correct by checking hash"""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
# Check if auth table exists
|
||||
result = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='auth'"
|
||||
).fetchone()
|
||||
|
||||
if not result:
|
||||
# New database, password is valid
|
||||
return True
|
||||
|
||||
# Check password hash
|
||||
stored = conn.execute("SELECT password_hash FROM auth WHERE id = 1").fetchone()
|
||||
|
||||
if not stored:
|
||||
# No password set yet, accept any password
|
||||
return True
|
||||
|
||||
# Verify hash matches
|
||||
return stored["password_hash"] == self._password_hash
|
||||
|
||||
except Exception as e:
|
||||
print(f"Password verification error: {e}")
|
||||
return False
|
||||
|
||||
# === Chat Session Management ===
|
||||
|
||||
def create_chat_session(self, title: Optional[str] = None) -> int:
|
||||
"""Create a new chat session"""
|
||||
if title is None:
|
||||
title = f"Chat {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO chat_sessions (title) VALUES (?)",
|
||||
(title,)
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_chat_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Get all chat sessions"""
|
||||
with self.get_connection() as conn:
|
||||
sessions = conn.execute(
|
||||
"""
|
||||
SELECT cs.*,
|
||||
COUNT(cm.id) as message_count,
|
||||
MAX(cm.timestamp) as last_message_at
|
||||
FROM chat_sessions cs
|
||||
LEFT JOIN chat_messages cm ON cs.id = cm.session_id
|
||||
GROUP BY cs.id
|
||||
ORDER BY cs.updated_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in sessions]
|
||||
|
||||
def get_chat_session(self, session_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific chat session"""
|
||||
with self.get_connection() as conn:
|
||||
session = conn.execute(
|
||||
"SELECT * FROM chat_sessions WHERE id = ?",
|
||||
(session_id,)
|
||||
).fetchone()
|
||||
|
||||
if session:
|
||||
return dict(session)
|
||||
return None
|
||||
|
||||
def delete_chat_session(self, session_id: int):
|
||||
"""Delete a chat session (cascades to messages)"""
|
||||
with self.get_connection() as conn:
|
||||
conn.execute("DELETE FROM chat_sessions WHERE id = ?", (session_id,))
|
||||
# Reclaim disk space after deletion
|
||||
conn.execute("VACUUM")
|
||||
|
||||
def update_chat_session_title(self, session_id: int, title: str):
|
||||
"""Update chat session title"""
|
||||
with self.get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(title, session_id)
|
||||
)
|
||||
|
||||
def add_chat_message(self, session_id: int, role: str, content: str) -> int:
|
||||
"""Add a message to a chat session"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?)",
|
||||
(session_id, role, content)
|
||||
)
|
||||
|
||||
# Update session timestamp
|
||||
conn.execute(
|
||||
"UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(session_id,)
|
||||
)
|
||||
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_chat_messages(self, session_id: int, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Get messages for a chat session"""
|
||||
with self.get_connection() as conn:
|
||||
if limit:
|
||||
messages = conn.execute(
|
||||
"""
|
||||
SELECT * FROM chat_messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(session_id, limit)
|
||||
).fetchall()
|
||||
else:
|
||||
messages = conn.execute(
|
||||
"SELECT * FROM chat_messages WHERE session_id = ? ORDER BY timestamp ASC",
|
||||
(session_id,)
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in messages]
|
||||
|
||||
def clear_chat_messages(self, session_id: int):
|
||||
"""Clear all messages in a chat session"""
|
||||
with self.get_connection() as conn:
|
||||
conn.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
|
||||
# Reclaim disk space
|
||||
conn.execute("VACUUM")
|
||||
|
||||
|
||||
# Global instance
|
||||
_db_instance: Optional[DiaryDatabase] = None
|
||||
|
||||
|
||||
def get_database(password: Optional[str] = None) -> DiaryDatabase:
|
||||
"""Get or create database singleton"""
|
||||
global _db_instance
|
||||
if _db_instance is None:
|
||||
_db_instance = DiaryDatabase(password=password)
|
||||
_db_instance.initialize_schema()
|
||||
return _db_instance
|
||||
67
backend/download_model.py
Normal file
67
backend/download_model.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Download script for Qwen-VL GGUF model and mmproj files
|
||||
Run this once to download the model files to the models/ directory
|
||||
"""
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
MODEL_DIR = Path(__file__).parent.parent / "models"
|
||||
MODEL_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# HuggingFace model files
|
||||
BASE_URL = "https://huggingface.co/huihui-ai/Huihui-Qwen3-VL-2B-Instruct-abliterated/resolve/main/GGUF/"
|
||||
|
||||
FILES_TO_DOWNLOAD = {
|
||||
# Main model - Q4_K_M recommended for balance of quality/performance
|
||||
"model": "huihui-qwen3-vl-2b-instruct-abliterated-q4_k_m.gguf",
|
||||
# Vision projection for image processing
|
||||
"mmproj": "mmproj-huihui-qwen3-vl-2b-instruct-abliterated-f16.gguf"
|
||||
}
|
||||
|
||||
|
||||
def download_file(url: str, dest: Path):
|
||||
"""Download file with progress bar"""
|
||||
if dest.exists():
|
||||
print(f"✓ {dest.name} already exists, skipping...")
|
||||
return
|
||||
|
||||
print(f"Downloading {dest.name}...")
|
||||
print(f"From: {url}")
|
||||
|
||||
def progress_hook(count, block_size, total_size):
|
||||
percent = min(int(count * block_size * 100 / total_size), 100)
|
||||
print(f"\r Progress: {percent}%", end="", flush=True)
|
||||
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest, progress_hook)
|
||||
print(f"\n✓ Downloaded {dest.name}")
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error downloading {dest.name}: {e}")
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("DiaryML Model Downloader")
|
||||
print("=" * 60)
|
||||
print(f"\nDownloading to: {MODEL_DIR.absolute()}\n")
|
||||
|
||||
for file_type, filename in FILES_TO_DOWNLOAD.items():
|
||||
url = BASE_URL + filename
|
||||
dest = MODEL_DIR / filename
|
||||
download_file(url, dest)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Download complete!")
|
||||
print("=" * 60)
|
||||
print("\nModel files:")
|
||||
for file in MODEL_DIR.glob("*.gguf"):
|
||||
size_mb = file.stat().st_size / (1024 * 1024)
|
||||
print(f" • {file.name} ({size_mb:.1f} MB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
324
backend/emotion_detector.py
Normal file
324
backend/emotion_detector.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Emotion detection module for DiaryML
|
||||
Analyzes text to extract mood and emotional state with professional-grade accuracy
|
||||
Uses calibrated models and robust aggregation for reliable results
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
import re
|
||||
import numpy as np
|
||||
from transformers import pipeline
|
||||
import torch
|
||||
|
||||
|
||||
class EmotionDetector:
|
||||
"""Detect emotions and mood from journal text with professional accuracy"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize emotion detection model"""
|
||||
print("Loading emotion detection model...")
|
||||
|
||||
# Use the more reliable and well-calibrated j-hartmann model
|
||||
# This model is specifically trained for nuanced emotion detection
|
||||
# and produces better-calibrated probability scores
|
||||
try:
|
||||
self.emotion_classifier = pipeline(
|
||||
"text-classification",
|
||||
model="j-hartmann/emotion-english-distilroberta-base",
|
||||
top_k=None, # Return all emotion scores
|
||||
device=0 if torch.cuda.is_available() else -1
|
||||
)
|
||||
print("✓ Using j-hartmann/emotion-english-distilroberta-base (professional)")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load primary model, falling back: {e}")
|
||||
# Fallback to the older model if needed
|
||||
self.emotion_classifier = pipeline(
|
||||
"text-classification",
|
||||
model="bhadresh-savani/distilbert-base-uncased-emotion",
|
||||
top_k=None,
|
||||
device=0 if torch.cuda.is_available() else -1
|
||||
)
|
||||
print("✓ Using fallback emotion model")
|
||||
|
||||
# Map model labels to our emotion categories
|
||||
self.emotion_map = {
|
||||
"joy": "joy",
|
||||
"sadness": "sadness",
|
||||
"anger": "anger",
|
||||
"fear": "fear",
|
||||
"love": "love",
|
||||
"surprise": "surprise",
|
||||
"neutral": "neutral" # Some models include neutral
|
||||
}
|
||||
|
||||
print("✓ Emotion detection model loaded")
|
||||
|
||||
def detect_emotions(self, text: str, chunk_size: int = 512) -> Dict[str, float]:
|
||||
"""
|
||||
Detect emotions from text with professional-grade calibration
|
||||
|
||||
Args:
|
||||
text: Input text to analyze
|
||||
chunk_size: Max characters per chunk (for long texts)
|
||||
|
||||
Returns:
|
||||
Dict mapping emotion names to scores (0-1), properly normalized
|
||||
"""
|
||||
if not text.strip():
|
||||
return self._neutral_emotions()
|
||||
|
||||
# Split long texts into chunks
|
||||
chunks = self._split_text(text, chunk_size)
|
||||
|
||||
# Analyze each chunk
|
||||
all_results = []
|
||||
for chunk in chunks:
|
||||
if chunk.strip():
|
||||
try:
|
||||
results = self.emotion_classifier(chunk)[0]
|
||||
all_results.append(results)
|
||||
except Exception as e:
|
||||
print(f"Warning: Emotion detection error on chunk: {e}")
|
||||
continue
|
||||
|
||||
if not all_results:
|
||||
return self._neutral_emotions()
|
||||
|
||||
# Aggregate and normalize scores properly
|
||||
emotion_scores = self._aggregate_emotions_robust(all_results)
|
||||
|
||||
# Apply calibration to prevent extreme scores
|
||||
emotion_scores = self._calibrate_scores(emotion_scores, text)
|
||||
|
||||
return emotion_scores
|
||||
|
||||
def get_dominant_emotion(self, emotions: Dict[str, float]) -> Tuple[str, float]:
|
||||
"""
|
||||
Get the dominant emotion
|
||||
|
||||
Returns:
|
||||
Tuple of (emotion_name, score)
|
||||
"""
|
||||
if not emotions:
|
||||
return ("neutral", 0.0)
|
||||
|
||||
# Filter out very low scores
|
||||
significant_emotions = {k: v for k, v in emotions.items() if v > 0.1}
|
||||
|
||||
if not significant_emotions:
|
||||
return ("neutral", 0.0)
|
||||
|
||||
return max(significant_emotions.items(), key=lambda x: x[1])
|
||||
|
||||
def get_mood_description(self, emotions: Dict[str, float]) -> str:
|
||||
"""
|
||||
Generate human-readable mood description
|
||||
|
||||
Returns:
|
||||
Description like "Joyful with hints of surprise"
|
||||
"""
|
||||
# Sort by score
|
||||
sorted_emotions = sorted(emotions.items(), key=lambda x: -x[1])
|
||||
|
||||
# Primary emotion
|
||||
primary, primary_score = sorted_emotions[0]
|
||||
|
||||
# Require higher threshold for non-neutral classification
|
||||
if primary_score < 0.35:
|
||||
return "Neutral"
|
||||
|
||||
description = primary.capitalize()
|
||||
|
||||
# Add secondary emotion if significant
|
||||
if len(sorted_emotions) > 1:
|
||||
secondary, secondary_score = sorted_emotions[1]
|
||||
if secondary_score > 0.25 and secondary != "neutral":
|
||||
description += f" with hints of {secondary}"
|
||||
|
||||
return description
|
||||
|
||||
def analyze_sentiment_intensity(self, emotions: Dict[str, float]) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze overall sentiment and intensity
|
||||
|
||||
Returns:
|
||||
Dict with overall_sentiment, intensity, valence
|
||||
"""
|
||||
# Positive emotions
|
||||
positive_score = emotions.get("joy", 0) + emotions.get("love", 0) + emotions.get("surprise", 0) * 0.5
|
||||
|
||||
# Negative emotions
|
||||
negative_score = emotions.get("sadness", 0) + emotions.get("anger", 0) + emotions.get("fear", 0)
|
||||
|
||||
# Overall sentiment
|
||||
valence = positive_score - negative_score
|
||||
|
||||
if valence > 0.3:
|
||||
overall_sentiment = "positive"
|
||||
elif valence < -0.3:
|
||||
overall_sentiment = "negative"
|
||||
else:
|
||||
overall_sentiment = "neutral"
|
||||
|
||||
# Intensity (how strong are the emotions overall)
|
||||
intensity = max(emotions.values()) if emotions else 0.0
|
||||
|
||||
return {
|
||||
"overall_sentiment": overall_sentiment,
|
||||
"valence": valence,
|
||||
"intensity": intensity,
|
||||
"positive_score": positive_score,
|
||||
"negative_score": negative_score
|
||||
}
|
||||
|
||||
def _split_text(self, text: str, chunk_size: int) -> List[str]:
|
||||
"""Split text into chunks at sentence boundaries"""
|
||||
# Try to split at sentence boundaries
|
||||
sentences = re.split(r'[.!?]+', text)
|
||||
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if not sentence:
|
||||
continue
|
||||
|
||||
if len(current_chunk) + len(sentence) < chunk_size:
|
||||
current_chunk += sentence + ". "
|
||||
else:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = sentence + ". "
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
|
||||
return chunks if chunks else [text[:chunk_size]]
|
||||
|
||||
def _aggregate_emotions_robust(self, all_results: List[List[Dict]]) -> Dict[str, float]:
|
||||
"""
|
||||
Aggregate emotion scores with proper normalization
|
||||
|
||||
This method properly handles the raw model outputs and ensures
|
||||
that scores are meaningful and well-calibrated.
|
||||
"""
|
||||
if not all_results:
|
||||
return self._neutral_emotions()
|
||||
|
||||
# Collect scores per emotion across all chunks
|
||||
emotion_scores_per_chunk = []
|
||||
|
||||
for result_group in all_results:
|
||||
# Each chunk gets a dict of its emotion scores
|
||||
chunk_emotions = {}
|
||||
|
||||
for result in result_group:
|
||||
label = result["label"].lower()
|
||||
score = result["score"]
|
||||
|
||||
if label in self.emotion_map:
|
||||
emotion = self.emotion_map[label]
|
||||
chunk_emotions[emotion] = score
|
||||
|
||||
# Normalize scores for this chunk to sum to 1 (proper probability distribution)
|
||||
total = sum(chunk_emotions.values())
|
||||
if total > 0:
|
||||
chunk_emotions = {k: v / total for k, v in chunk_emotions.items()}
|
||||
|
||||
emotion_scores_per_chunk.append(chunk_emotions)
|
||||
|
||||
# Average across chunks
|
||||
final_emotions = {}
|
||||
for emotion in self.emotion_map.values():
|
||||
if emotion == "neutral":
|
||||
continue # Handle neutral separately
|
||||
|
||||
scores = [chunk.get(emotion, 0.0) for chunk in emotion_scores_per_chunk]
|
||||
final_emotions[emotion] = np.mean(scores) if scores else 0.0
|
||||
|
||||
return final_emotions
|
||||
|
||||
def _calibrate_scores(self, emotions: Dict[str, float], text: str) -> Dict[str, float]:
|
||||
"""
|
||||
Apply calibration to prevent extreme/unrealistic scores
|
||||
|
||||
This addresses the issue where conversational text gets labeled as 98% anger.
|
||||
We apply sensible constraints based on text characteristics.
|
||||
"""
|
||||
# Detect conversational indicators (questions, greetings, casual language)
|
||||
conversational_indicators = [
|
||||
r'\b(hey|hi|hello|thanks|please|maybe|think|feel|just)\b',
|
||||
r'\?', # Questions
|
||||
r'\b(haha|lol|btw|tbh|ngl)\b', # Internet slang
|
||||
r'\b(wondering|curious|interested)\b'
|
||||
]
|
||||
|
||||
is_conversational = any(re.search(pattern, text.lower()) for pattern in conversational_indicators)
|
||||
|
||||
# Detect aggressive indicators
|
||||
aggressive_indicators = [
|
||||
r'\b(hate|angry|furious|rage|damn|hell)\b',
|
||||
r'[!]{2,}', # Multiple exclamation marks
|
||||
r'[A-Z]{4,}', # ALL CAPS words
|
||||
]
|
||||
|
||||
is_aggressive = any(re.search(pattern, text) for pattern in aggressive_indicators)
|
||||
|
||||
# Apply calibration
|
||||
calibrated = emotions.copy()
|
||||
|
||||
# If conversational but not aggressive, reduce negative emotions
|
||||
if is_conversational and not is_aggressive:
|
||||
# Dampen negative emotions significantly
|
||||
calibrated['anger'] = min(calibrated.get('anger', 0) * 0.3, 0.4)
|
||||
calibrated['fear'] = min(calibrated.get('fear', 0) * 0.4, 0.4)
|
||||
calibrated['sadness'] = min(calibrated.get('sadness', 0) * 0.5, 0.5)
|
||||
|
||||
# Boost joy/neutral slightly
|
||||
calibrated['joy'] = calibrated.get('joy', 0) * 1.2
|
||||
|
||||
# Prevent any single emotion from dominating unrealistically (>80%)
|
||||
max_emotion = max(calibrated.values()) if calibrated else 0
|
||||
if max_emotion > 0.8:
|
||||
# Scale everything down proportionally
|
||||
scale_factor = 0.8 / max_emotion
|
||||
calibrated = {k: v * scale_factor for k, v in calibrated.items()}
|
||||
|
||||
# Ensure some emotional diversity (prevent 95%+ single emotion)
|
||||
# Add a small baseline to other emotions
|
||||
max_val = max(calibrated.values()) if calibrated else 0
|
||||
if max_val > 0.7:
|
||||
for emotion in calibrated:
|
||||
if calibrated[emotion] < 0.1:
|
||||
calibrated[emotion] = max(calibrated[emotion], 0.05)
|
||||
|
||||
# Renormalize to sum to ~1.0
|
||||
total = sum(calibrated.values())
|
||||
if total > 0:
|
||||
calibrated = {k: v / total for k, v in calibrated.items()}
|
||||
|
||||
return calibrated
|
||||
|
||||
def _neutral_emotions(self) -> Dict[str, float]:
|
||||
"""Return neutral emotion scores with slight joy bias (default positive)"""
|
||||
return {
|
||||
"joy": 0.3,
|
||||
"sadness": 0.1,
|
||||
"anger": 0.05,
|
||||
"fear": 0.05,
|
||||
"love": 0.2,
|
||||
"surprise": 0.3
|
||||
}
|
||||
|
||||
|
||||
# Singleton
|
||||
_emotion_detector: Optional[EmotionDetector] = None
|
||||
|
||||
|
||||
def get_emotion_detector() -> EmotionDetector:
|
||||
"""Get or create emotion detector singleton"""
|
||||
global _emotion_detector
|
||||
if _emotion_detector is None:
|
||||
_emotion_detector = EmotionDetector()
|
||||
return _emotion_detector
|
||||
1336
backend/main.py
Normal file
1336
backend/main.py
Normal file
File diff suppressed because it is too large
Load Diff
183
backend/mobile_auth.py
Normal file
183
backend/mobile_auth.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Mobile Authentication Module for DiaryML
|
||||
JWT-based authentication for mobile app access with secure token management
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import secrets
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
try:
|
||||
from jose import JWTError, jwt
|
||||
HAS_JWT = True
|
||||
except ImportError:
|
||||
HAS_JWT = False
|
||||
print("⚠ WARNING: python-jose not installed - mobile auth will not work!")
|
||||
print(" Install with: pip install python-jose[cryptography]")
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# Configuration
|
||||
CONFIG_DIR = Path(__file__).parent.parent
|
||||
SECRET_KEY_FILE = CONFIG_DIR / ".mobile_secret_key"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 30 # 30 days for mobile convenience
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""JWT Token response"""
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""JWT Token payload data"""
|
||||
password_hash: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class MobileAuthError(Exception):
|
||||
"""Mobile authentication error"""
|
||||
pass
|
||||
|
||||
|
||||
def _get_or_create_secret_key() -> str:
|
||||
"""
|
||||
Get or create a persistent secret key for JWT signing
|
||||
|
||||
Returns:
|
||||
Secret key string
|
||||
"""
|
||||
if SECRET_KEY_FILE.exists():
|
||||
return SECRET_KEY_FILE.read_text().strip()
|
||||
|
||||
# Generate new secret key
|
||||
secret_key = secrets.token_urlsafe(32)
|
||||
SECRET_KEY_FILE.write_text(secret_key)
|
||||
|
||||
# Make it read-only
|
||||
SECRET_KEY_FILE.chmod(0o600)
|
||||
|
||||
print(f"✓ Created new mobile authentication secret key")
|
||||
return secret_key
|
||||
|
||||
|
||||
def create_access_token(password: str, expires_delta: Optional[timedelta] = None) -> Token:
|
||||
"""
|
||||
Create JWT access token for mobile authentication
|
||||
|
||||
Args:
|
||||
password: The diary password (will be hashed in token)
|
||||
expires_delta: Token expiration time (default: 30 days)
|
||||
|
||||
Returns:
|
||||
Token object with access_token and metadata
|
||||
|
||||
Raises:
|
||||
MobileAuthError: If JWT library not available
|
||||
"""
|
||||
if not HAS_JWT:
|
||||
raise MobileAuthError("JWT library not available - install python-jose[cryptography]")
|
||||
|
||||
if expires_delta is None:
|
||||
expires_delta = timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
|
||||
# Hash the password for the token payload (never store plaintext)
|
||||
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
to_encode = {
|
||||
"password_hash": password_hash,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"exp": expire,
|
||||
"iat": datetime.utcnow()
|
||||
}
|
||||
|
||||
secret_key = _get_or_create_secret_key()
|
||||
encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM)
|
||||
|
||||
return Token(
|
||||
access_token=encoded_jwt,
|
||||
token_type="bearer",
|
||||
expires_in=int(expires_delta.total_seconds())
|
||||
)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[str]:
|
||||
"""
|
||||
Verify JWT token and extract password hash
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Password hash if valid, None if invalid
|
||||
|
||||
Raises:
|
||||
MobileAuthError: If JWT library not available
|
||||
"""
|
||||
if not HAS_JWT:
|
||||
raise MobileAuthError("JWT library not available - install python-jose[cryptography]")
|
||||
|
||||
try:
|
||||
secret_key = _get_or_create_secret_key()
|
||||
payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM])
|
||||
|
||||
password_hash: str = payload.get("password_hash")
|
||||
if password_hash is None:
|
||||
return None
|
||||
|
||||
return password_hash
|
||||
|
||||
except JWTError as e:
|
||||
print(f"Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Hash a password for comparison
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
SHA256 hash of password
|
||||
"""
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
|
||||
def extract_password_from_token(token: str) -> Optional[str]:
|
||||
"""
|
||||
Extract password hash from token without full verification
|
||||
(used for database unlock after token validation)
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Password hash if token is structurally valid, None otherwise
|
||||
"""
|
||||
if not HAS_JWT:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Don't verify signature, just decode
|
||||
secret_key = _get_or_create_secret_key()
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
secret_key,
|
||||
algorithms=[ALGORITHM],
|
||||
options={"verify_signature": True, "verify_exp": True}
|
||||
)
|
||||
|
||||
return payload.get("password_hash")
|
||||
|
||||
except JWTError:
|
||||
return None
|
||||
342
backend/pattern_analyzer.py
Normal file
342
backend/pattern_analyzer.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
Pattern analyzer for DiaryML
|
||||
Extracts projects, activities, and temporal patterns from journal entries
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict, Counter
|
||||
|
||||
|
||||
class PatternAnalyzer:
|
||||
"""Analyze patterns in journal entries"""
|
||||
|
||||
# Regex patterns for different mention types
|
||||
PATTERNS = {
|
||||
"started": r"(?:start|started|began|beginning|initiated)\s+(?:working on |project |)\s*([A-Z][A-Za-z0-9\s-]+)",
|
||||
"finished": r"(?:finish|finished|completed|done with|wrapped up)\s+(?:working on |project |)\s*([A-Z][A-Za-z0-9\s-]+)",
|
||||
"working_on": r"(?:working on|continue|continuing)\s+(?:project |)\s*([A-Z][A-Za-z0-9\s-]+)",
|
||||
"project_mention": r"(?:project|Project)\s+([A-Z][A-Za-z0-9\s-]+)",
|
||||
|
||||
# Media mentions
|
||||
"watched": r"(?:watched|saw|viewing)\s+['\"]?([^'\",.!?]+)['\"]?",
|
||||
"read": r"(?:read|reading)\s+['\"]?([^'\",.!?]+)['\"]?",
|
||||
"listened": r"(?:listened to|listening to|heard)\s+['\"]?([^'\",.!?]+)['\"]?",
|
||||
|
||||
# Activities
|
||||
"activity": r"(?:went to|visited|attended)\s+([A-Za-z0-9\s-]+)",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize pattern analyzer"""
|
||||
pass
|
||||
|
||||
def extract_projects(self, text: str) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Extract project mentions with their type
|
||||
|
||||
Returns:
|
||||
List of {"name": project_name, "type": mention_type}
|
||||
"""
|
||||
projects = []
|
||||
|
||||
for mention_type, pattern in self.PATTERNS.items():
|
||||
if mention_type in ["watched", "read", "listened", "activity"]:
|
||||
continue
|
||||
|
||||
matches = re.finditer(pattern, text, re.IGNORECASE)
|
||||
for match in matches:
|
||||
project_name = match.group(1).strip()
|
||||
|
||||
# Clean up the project name
|
||||
project_name = self._clean_project_name(project_name)
|
||||
|
||||
if project_name and len(project_name) > 2:
|
||||
projects.append({
|
||||
"name": project_name,
|
||||
"type": mention_type
|
||||
})
|
||||
|
||||
return projects
|
||||
|
||||
def extract_media(self, text: str) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Extract media mentions (movies, books, music)
|
||||
|
||||
Returns:
|
||||
List of {"title": title, "type": media_type}
|
||||
"""
|
||||
media = []
|
||||
|
||||
media_patterns = {
|
||||
"movie": "watched",
|
||||
"book": "read",
|
||||
"music": "listened"
|
||||
}
|
||||
|
||||
for media_type, pattern_key in media_patterns.items():
|
||||
pattern = self.PATTERNS[pattern_key]
|
||||
matches = re.finditer(pattern, text, re.IGNORECASE)
|
||||
|
||||
for match in matches:
|
||||
title = match.group(1).strip()
|
||||
|
||||
# Clean up title
|
||||
title = self._clean_title(title)
|
||||
|
||||
if title and len(title) > 2:
|
||||
media.append({
|
||||
"title": title,
|
||||
"type": media_type
|
||||
})
|
||||
|
||||
return media
|
||||
|
||||
def extract_activities(self, text: str) -> List[str]:
|
||||
"""Extract activities and events"""
|
||||
activities = []
|
||||
|
||||
pattern = self.PATTERNS["activity"]
|
||||
matches = re.finditer(pattern, text, re.IGNORECASE)
|
||||
|
||||
for match in matches:
|
||||
activity = match.group(1).strip()
|
||||
if activity and len(activity) > 2:
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
def analyze_project_timeline(
|
||||
self,
|
||||
project_mentions: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze project progression over time
|
||||
|
||||
Args:
|
||||
project_mentions: List of mentions with timestamp and type
|
||||
|
||||
Returns:
|
||||
Analysis of project status and progression
|
||||
"""
|
||||
if not project_mentions:
|
||||
return {"status": "unknown", "timeline": []}
|
||||
|
||||
# Sort by timestamp
|
||||
sorted_mentions = sorted(project_mentions, key=lambda x: x.get("timestamp", datetime.min))
|
||||
|
||||
# Determine current status
|
||||
latest_mention = sorted_mentions[-1]
|
||||
mention_type = latest_mention.get("type", "project_mention")
|
||||
|
||||
if mention_type == "finished":
|
||||
status = "completed"
|
||||
elif mention_type == "started":
|
||||
status = "active"
|
||||
elif mention_type == "working_on":
|
||||
status = "active"
|
||||
else:
|
||||
status = "mentioned"
|
||||
|
||||
# Calculate duration if project has start and end
|
||||
duration_days = None
|
||||
start_date = None
|
||||
end_date = None
|
||||
|
||||
for mention in sorted_mentions:
|
||||
if mention.get("type") == "started" and not start_date:
|
||||
start_date = mention.get("timestamp")
|
||||
if mention.get("type") == "finished" and not end_date:
|
||||
end_date = mention.get("timestamp")
|
||||
|
||||
if start_date and end_date:
|
||||
duration_days = (end_date - start_date).days
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"first_mentioned": sorted_mentions[0].get("timestamp"),
|
||||
"last_mentioned": sorted_mentions[-1].get("timestamp"),
|
||||
"total_mentions": len(sorted_mentions),
|
||||
"duration_days": duration_days,
|
||||
"timeline": [
|
||||
{
|
||||
"date": m.get("timestamp"),
|
||||
"type": m.get("type"),
|
||||
"entry_id": m.get("entry_id")
|
||||
}
|
||||
for m in sorted_mentions
|
||||
]
|
||||
}
|
||||
|
||||
def analyze_mood_patterns(
|
||||
self,
|
||||
mood_history: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze mood patterns and trends
|
||||
|
||||
Args:
|
||||
mood_history: List of mood entries with date and emotions
|
||||
|
||||
Returns:
|
||||
Mood pattern analysis
|
||||
"""
|
||||
if not mood_history:
|
||||
return {"dominant_moods": [], "trend": "neutral"}
|
||||
|
||||
# Count emotions
|
||||
emotion_counts = Counter()
|
||||
emotion_scores = defaultdict(list)
|
||||
|
||||
for entry in mood_history:
|
||||
moods = entry.get("moods", {})
|
||||
for emotion, score in moods.items():
|
||||
emotion_counts[emotion] += 1
|
||||
emotion_scores[emotion].append(score)
|
||||
|
||||
# Get dominant moods
|
||||
dominant_moods = [
|
||||
{
|
||||
"emotion": emotion,
|
||||
"frequency": count,
|
||||
"avg_intensity": sum(emotion_scores[emotion]) / len(emotion_scores[emotion])
|
||||
}
|
||||
for emotion, count in emotion_counts.most_common(3)
|
||||
]
|
||||
|
||||
# Analyze trend (recent vs earlier)
|
||||
trend = self._calculate_mood_trend(mood_history)
|
||||
|
||||
return {
|
||||
"dominant_moods": dominant_moods,
|
||||
"trend": trend,
|
||||
"total_entries": len(mood_history)
|
||||
}
|
||||
|
||||
def suggest_next_steps(
|
||||
self,
|
||||
active_projects: List[str],
|
||||
recent_activities: List[str],
|
||||
mood_state: str
|
||||
) -> List[str]:
|
||||
"""
|
||||
Generate suggestions for what to do next
|
||||
|
||||
Args:
|
||||
active_projects: List of active project names
|
||||
recent_activities: Recent activities/interests
|
||||
mood_state: Current mood state
|
||||
|
||||
Returns:
|
||||
List of suggestion strings
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
# Project-based suggestions
|
||||
if active_projects:
|
||||
project = active_projects[0]
|
||||
suggestions.append(f"Continue working on {project}")
|
||||
|
||||
if len(active_projects) > 1:
|
||||
suggestions.append(f"Switch focus to {active_projects[1]}")
|
||||
|
||||
# Mood-based suggestions
|
||||
if mood_state in ["joy", "excitement"]:
|
||||
suggestions.extend([
|
||||
"Channel this energy into creative work",
|
||||
"Start a new project while you're feeling inspired"
|
||||
])
|
||||
elif mood_state in ["sadness", "melancholy"]:
|
||||
suggestions.extend([
|
||||
"Take time for reflection and journaling",
|
||||
"Watch a comforting movie or listen to calming music"
|
||||
])
|
||||
elif mood_state in ["calm", "neutral"]:
|
||||
suggestions.extend([
|
||||
"Perfect time to tackle complex tasks",
|
||||
"Organize and plan upcoming projects"
|
||||
])
|
||||
|
||||
# Activity-based suggestions
|
||||
if recent_activities:
|
||||
suggestions.append(f"Explore more related to {recent_activities[0]}")
|
||||
|
||||
# General creative suggestions
|
||||
suggestions.extend([
|
||||
"Capture your current mood through art",
|
||||
"Free-write for 10 minutes",
|
||||
"Take a walk and observe your surroundings"
|
||||
])
|
||||
|
||||
return suggestions[:5] # Return top 5
|
||||
|
||||
def _clean_project_name(self, name: str) -> str:
|
||||
"""Clean and normalize project name"""
|
||||
# Remove common words
|
||||
stop_words = {"the", "a", "an", "my", "this", "that", "on", "in"}
|
||||
|
||||
words = name.split()
|
||||
cleaned_words = [w for w in words if w.lower() not in stop_words]
|
||||
|
||||
cleaned = " ".join(cleaned_words)
|
||||
|
||||
# Remove trailing punctuation
|
||||
cleaned = re.sub(r'[,;:.!?]+$', '', cleaned)
|
||||
|
||||
return cleaned.strip()
|
||||
|
||||
def _clean_title(self, title: str) -> str:
|
||||
"""Clean media title"""
|
||||
# Remove trailing words that aren't part of title
|
||||
title = re.sub(r'\s+(?:yesterday|today|tonight|last night|earlier).*$', '', title, flags=re.IGNORECASE)
|
||||
|
||||
# Remove punctuation
|
||||
title = re.sub(r'[,;:.!?]+$', '', title)
|
||||
|
||||
return title.strip()
|
||||
|
||||
def _calculate_mood_trend(self, mood_history: List[Dict[str, Any]]) -> str:
|
||||
"""Calculate if mood is trending up, down, or stable"""
|
||||
if len(mood_history) < 2:
|
||||
return "neutral"
|
||||
|
||||
# Split into recent and earlier halves
|
||||
midpoint = len(mood_history) // 2
|
||||
earlier_moods = mood_history[:midpoint]
|
||||
recent_moods = mood_history[midpoint:]
|
||||
|
||||
# Calculate average positivity scores
|
||||
def calc_positivity(entries):
|
||||
scores = []
|
||||
for entry in entries:
|
||||
moods = entry.get("moods", {})
|
||||
positive = moods.get("joy", 0) + moods.get("love", 0)
|
||||
negative = moods.get("sadness", 0) + moods.get("anger", 0) + moods.get("fear", 0)
|
||||
scores.append(positive - negative)
|
||||
|
||||
return sum(scores) / len(scores) if scores else 0
|
||||
|
||||
earlier_avg = calc_positivity(earlier_moods)
|
||||
recent_avg = calc_positivity(recent_moods)
|
||||
|
||||
diff = recent_avg - earlier_avg
|
||||
|
||||
if diff > 0.15:
|
||||
return "improving"
|
||||
elif diff < -0.15:
|
||||
return "declining"
|
||||
else:
|
||||
return "stable"
|
||||
|
||||
|
||||
# Singleton
|
||||
_pattern_analyzer: Optional[PatternAnalyzer] = None
|
||||
|
||||
|
||||
def get_pattern_analyzer() -> PatternAnalyzer:
|
||||
"""Get or create pattern analyzer singleton"""
|
||||
global _pattern_analyzer
|
||||
if _pattern_analyzer is None:
|
||||
_pattern_analyzer = PatternAnalyzer()
|
||||
return _pattern_analyzer
|
||||
716
backend/qwen_interface.py
Normal file
716
backend/qwen_interface.py
Normal file
@@ -0,0 +1,716 @@
|
||||
"""
|
||||
AI Model interface for DiaryML
|
||||
Handles AI responses with mood-awareness
|
||||
Supports both vision-language models (Qwen-VL) and text-only models (Jamba, etc.)
|
||||
Optimized for small 1-3B GGUF models running on CPU
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
import re
|
||||
import json
|
||||
from llama_cpp import Llama
|
||||
from llama_cpp.llama_chat_format import Llava15ChatHandler, Qwen25VLChatHandler
|
||||
|
||||
|
||||
class QwenInterface:
|
||||
"""
|
||||
AI model interface using GGUF format
|
||||
|
||||
Supports:
|
||||
- Vision-language models (Qwen-VL with mmproj file)
|
||||
- Text-only models (Jamba, Llama, etc.)
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: Optional[Path] = None, mmproj_path: Optional[Path] = None):
|
||||
"""
|
||||
Initialize AI model (supports both vision and text-only models)
|
||||
|
||||
Args:
|
||||
model_path: Path to the main GGUF model file
|
||||
mmproj_path: Path to the mmproj vision file (optional, for vision models)
|
||||
"""
|
||||
self.model_dir = Path(__file__).parent.parent / "models"
|
||||
self.config_dir = Path(__file__).parent.parent
|
||||
self.has_vision = False
|
||||
self.is_thinking_model = False
|
||||
self.model_info = {}
|
||||
|
||||
# Auto-detect model files if not provided
|
||||
if model_path is None:
|
||||
# Try to load saved preference first
|
||||
saved_model = self._load_model_preference()
|
||||
if saved_model and saved_model.exists():
|
||||
model_path = saved_model
|
||||
print(f"Loading saved model preference: {model_path.name}")
|
||||
else:
|
||||
model_path = self._find_model_file()
|
||||
|
||||
# Store the model path and filename
|
||||
self.model_path = model_path
|
||||
self.model_info['filename'] = model_path.name
|
||||
self.model_info['name'] = self._extract_model_name(model_path.name)
|
||||
|
||||
# Analyze model filename to detect capabilities
|
||||
self._analyze_model_name(model_path)
|
||||
|
||||
# Determine if we should use vision support
|
||||
# Only attempt to load mmproj if the model is actually a vision-language model
|
||||
self.vision_handler_type = None
|
||||
if mmproj_path is not None:
|
||||
# mmproj explicitly provided - use it
|
||||
self.has_vision = True
|
||||
self.vision_handler_type = self._get_vision_handler_type(model_path)
|
||||
print(f"Using explicitly provided mmproj: {mmproj_path}")
|
||||
elif self._is_vision_model(model_path):
|
||||
# Vision model detected - try to find mmproj automatically
|
||||
try:
|
||||
mmproj_path = self._find_mmproj_file()
|
||||
self.has_vision = True
|
||||
self.vision_handler_type = self._get_vision_handler_type(model_path)
|
||||
print(f"Auto-detected vision model - found mmproj: {mmproj_path.name}")
|
||||
print(f"Vision architecture: {self.vision_handler_type}")
|
||||
except FileNotFoundError:
|
||||
print("Warning: Vision model detected but no mmproj file found - loading as text-only")
|
||||
self.has_vision = False
|
||||
else:
|
||||
# Text-only model - don't use mmproj
|
||||
self.has_vision = False
|
||||
print(f"Text-only model detected - skipping mmproj")
|
||||
|
||||
print(f"Loading AI model from: {model_path.name}")
|
||||
if self.has_vision:
|
||||
print(f"Loading vision projection from: {mmproj_path.name}")
|
||||
if self.is_thinking_model:
|
||||
print("Detected reasoning/thinking model - will clean output automatically")
|
||||
|
||||
# Determine optimal context window based on model size
|
||||
recommended_ctx = self._get_recommended_context()
|
||||
print(f"Using context window: {recommended_ctx} tokens")
|
||||
|
||||
try:
|
||||
# Initialize with or without vision support
|
||||
if self.has_vision:
|
||||
# Vision-language model - use appropriate chat handler
|
||||
if self.vision_handler_type == 'qwen':
|
||||
print("Using Qwen25VLChatHandler for Qwen-VL model")
|
||||
self.chat_handler = Qwen25VLChatHandler(clip_model_path=str(mmproj_path))
|
||||
elif self.vision_handler_type == 'llava':
|
||||
print("Using Llava15ChatHandler for LLaVA model")
|
||||
self.chat_handler = Llava15ChatHandler(clip_model_path=str(mmproj_path))
|
||||
elif self.vision_handler_type == 'minicpm':
|
||||
print("Using Qwen25VLChatHandler as fallback for MiniCPM-V")
|
||||
self.chat_handler = Qwen25VLChatHandler(clip_model_path=str(mmproj_path))
|
||||
else:
|
||||
print(f"Unknown vision model type, trying Qwen25VLChatHandler as fallback")
|
||||
self.chat_handler = Qwen25VLChatHandler(clip_model_path=str(mmproj_path))
|
||||
|
||||
self.llm = Llama(
|
||||
model_path=str(model_path),
|
||||
chat_handler=self.chat_handler,
|
||||
n_ctx=recommended_ctx,
|
||||
n_gpu_layers=0, # CPU only
|
||||
verbose=False,
|
||||
n_threads=None # Auto-detect
|
||||
)
|
||||
print("✓ Vision-language model loaded successfully")
|
||||
else:
|
||||
# Text-only model - optimized for CPU
|
||||
self.chat_handler = None
|
||||
self.llm = Llama(
|
||||
model_path=str(model_path),
|
||||
n_ctx=recommended_ctx,
|
||||
n_batch=min(512, recommended_ctx // 4), # Batch size proportional to context
|
||||
n_gpu_layers=0, # CPU only
|
||||
verbose=False,
|
||||
n_threads=None, # Auto-detect optimal thread count
|
||||
use_mmap=True, # Memory-map the model for faster loading
|
||||
use_mlock=False # Don't lock memory (allows swapping if needed)
|
||||
)
|
||||
print("✓ Text-only model loaded successfully")
|
||||
print(f"Model info: {self.model_info.get('size', 'unknown')} parameters, {self.model_info.get('quantization', 'unknown')} quantization")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Failed to load AI model: {str(e)}")
|
||||
print("\nTroubleshooting:")
|
||||
print("1. The model file might be corrupted - try re-downloading")
|
||||
print("2. Try a different model (recommended: 1B-3B parameter models with Q4_K_M or Q5_K_M quantization)")
|
||||
print("3. Check if you have enough RAM:")
|
||||
print(" - 1B model needs ~1-2GB RAM")
|
||||
print(" - 2B model needs ~2-3GB RAM")
|
||||
print(" - 3B model needs ~3-4GB RAM")
|
||||
print("\nDiaryML will still work for journaling and mood tracking!")
|
||||
print("You just won't have AI chat until the model loads.\n")
|
||||
raise
|
||||
|
||||
def _load_model_preference(self) -> Optional[Path]:
|
||||
"""Load the saved model preference from config file"""
|
||||
config_file = self.config_dir / "model_config.json"
|
||||
|
||||
if not config_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
model_filename = config.get('last_model')
|
||||
|
||||
if model_filename:
|
||||
model_path = self.model_dir / model_filename
|
||||
if model_path.exists():
|
||||
return model_path
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load model preference: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def save_model_preference(self):
|
||||
"""Save the current model as the preferred model"""
|
||||
config_file = self.config_dir / "model_config.json"
|
||||
|
||||
try:
|
||||
config = {
|
||||
'last_model': self.model_path.name,
|
||||
'last_updated': str(Path(__file__).stat().st_mtime)
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print(f"✓ Saved model preference: {self.model_path.name}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not save model preference: {e}")
|
||||
|
||||
def _extract_model_name(self, filename: str) -> str:
|
||||
"""
|
||||
Extract a clean model name from the filename
|
||||
|
||||
Examples:
|
||||
- nsfw-ameba-3.2-1b-q5_k_m.gguf -> nsfw-ameba-3.2-1b
|
||||
- AI21-Jamba-Reasoning-3B-Q4_K_M.gguf -> AI21-Jamba-Reasoning-3B
|
||||
"""
|
||||
# Remove .gguf extension
|
||||
name = filename.replace('.gguf', '')
|
||||
|
||||
# Try to remove quantization suffix (q4_k_m, q5_k_m, etc.)
|
||||
name = re.sub(r'[-_](q\d+_k_[ml]|q\d+_\d+|f16|f32)$', '', name, flags=re.IGNORECASE)
|
||||
|
||||
# If name is still too long, just return first 40 chars
|
||||
if len(name) > 40:
|
||||
name = name[:40] + '...'
|
||||
|
||||
return name
|
||||
|
||||
def _find_model_file(self) -> Path:
|
||||
"""Auto-detect the main model GGUF file"""
|
||||
# Try specific names first (manually downloaded)
|
||||
known_files = [
|
||||
"nsfw-ameba-3.2-1b-q5_k_m.gguf",
|
||||
"ggml-model-f16.gguf",
|
||||
"ai21labs_AI21-Jamba-Reasoning-3B-Q4_K_M.gguf",
|
||||
"huihui-qwen3-vl-2b-instruct-abliterated-q4_k_m.gguf"
|
||||
]
|
||||
|
||||
for filename in known_files:
|
||||
filepath = self.model_dir / filename
|
||||
if filepath.exists():
|
||||
print(f"Found known model: {filename}")
|
||||
return filepath
|
||||
|
||||
# Fallback to pattern matching - prioritize smaller quantized models
|
||||
patterns = [
|
||||
"*1b*.gguf", # 1B models (fastest)
|
||||
"*2b*.gguf", # 2B models
|
||||
"*3b*.gguf", # 3B models
|
||||
"*ameba*.gguf", # Ameba series
|
||||
"*jamba*.gguf", # Jamba series
|
||||
"*qwen*.gguf", # Qwen series
|
||||
"ggml-model*.gguf", # Generic GGUF
|
||||
"*q5_k_m.gguf", # Q5 quantization
|
||||
"*q4_k_m.gguf", # Q4 quantization
|
||||
"*.gguf" # Any GGUF file
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
files = list(self.model_dir.glob(pattern))
|
||||
# Exclude mmproj files
|
||||
files = [f for f in files if "mmproj" not in f.name.lower()]
|
||||
if files:
|
||||
# Sort by file size (smaller = faster) if multiple matches
|
||||
files.sort(key=lambda f: f.stat().st_size)
|
||||
print(f"Auto-detected model: {files[0].name}")
|
||||
return files[0]
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"No GGUF model file found in {self.model_dir}. "
|
||||
f"Please download a model file to the models/ folder.\n\n"
|
||||
f"Recommended models for CPU-only:\n"
|
||||
f" - 1B models (fastest): nsfw-ameba-3.2-1b-q5_k_m.gguf\n"
|
||||
f" - 2B models (balanced): qwen-2b-q4_k_m.gguf\n"
|
||||
f" - 3B models (best quality): AI21-Jamba-Reasoning-3B-Q4_K_M.gguf"
|
||||
)
|
||||
|
||||
def _is_vision_model(self, model_path: Path) -> bool:
|
||||
"""
|
||||
Detect if a model is a vision-language model based on filename patterns
|
||||
|
||||
Supports multiple VL architectures:
|
||||
- LLaVA (via Llava15ChatHandler)
|
||||
- Qwen2-VL/Qwen3-VL (via Qwen25VLChatHandler)
|
||||
- MiniCPM-V (via MiniCPMv26ChatHandler)
|
||||
|
||||
Args:
|
||||
model_path: Path to the model file
|
||||
|
||||
Returns:
|
||||
True if this appears to be a vision-language model
|
||||
"""
|
||||
filename = model_path.name.lower()
|
||||
|
||||
# Vision model indicators
|
||||
vision_keywords = [
|
||||
'vl', # Vision-Language
|
||||
'vision', # Explicit vision marker
|
||||
'llava', # LLaVA models
|
||||
'qwen-vl', # Qwen Vision-Language
|
||||
'qwen2-vl', # Qwen2 Vision-Language
|
||||
'qwen3-vl', # Qwen3 Vision-Language
|
||||
'qwen2vl', # Alternative naming
|
||||
'qwen3vl', # Alternative naming
|
||||
'qwenvl', # Alternative naming
|
||||
'minicpm-v', # MiniCPM Vision
|
||||
'lfm-vl', # LFM Vision-Language
|
||||
'lfm2-vl', # LFM2 Vision-Language
|
||||
]
|
||||
|
||||
# Check if any vision keyword is in the filename
|
||||
is_vision = any(keyword in filename for keyword in vision_keywords)
|
||||
|
||||
# Text-only model indicators (override vision detection if found)
|
||||
text_only_keywords = [
|
||||
'ameba', # Ameba models are text-only
|
||||
'jamba', # Jamba models are text-only
|
||||
'reasoning', # Reasoning models are typically text-only
|
||||
'moe', # MoE models are typically text-only (unless explicitly VL)
|
||||
]
|
||||
|
||||
# If text-only markers found, it's likely not a vision model
|
||||
# (unless it explicitly says VL/vision)
|
||||
has_text_marker = any(keyword in filename for keyword in text_only_keywords)
|
||||
|
||||
if has_text_marker and not is_vision:
|
||||
return False
|
||||
|
||||
return is_vision
|
||||
|
||||
def _get_vision_handler_type(self, model_path: Path) -> str:
|
||||
"""
|
||||
Determine which vision chat handler to use based on model architecture
|
||||
|
||||
Args:
|
||||
model_path: Path to the model file
|
||||
|
||||
Returns:
|
||||
One of: 'qwen', 'llava', 'minicpm', or 'unknown'
|
||||
"""
|
||||
filename = model_path.name.lower()
|
||||
|
||||
# Qwen-VL models
|
||||
if any(kw in filename for kw in ['qwen-vl', 'qwen2-vl', 'qwen3-vl', 'qwen2vl', 'qwen3vl', 'qwenvl']):
|
||||
return 'qwen'
|
||||
|
||||
# LLaVA models
|
||||
if 'llava' in filename:
|
||||
return 'llava'
|
||||
|
||||
# MiniCPM models
|
||||
if 'minicpm-v' in filename:
|
||||
return 'minicpm'
|
||||
|
||||
# LFM models (might work with Qwen handler - experimental)
|
||||
if 'lfm-vl' in filename or 'lfm2-vl' in filename:
|
||||
return 'qwen' # Try Qwen handler as fallback
|
||||
|
||||
return 'unknown'
|
||||
|
||||
def _find_mmproj_file(self) -> Path:
|
||||
"""Auto-detect the mmproj vision file"""
|
||||
# Try specific name first (manually downloaded)
|
||||
specific_file = self.model_dir / "mmproj-model-f16.gguf"
|
||||
if specific_file.exists():
|
||||
return specific_file
|
||||
|
||||
# Fallback to pattern matching
|
||||
files = list(self.model_dir.glob("mmproj*.gguf"))
|
||||
if files:
|
||||
return files[0]
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"No mmproj file found in {self.model_dir}. "
|
||||
f"Please download mmproj-model-f16.gguf to the models/ folder."
|
||||
)
|
||||
|
||||
def _analyze_model_name(self, model_path: Path):
|
||||
"""
|
||||
Analyze model filename to extract information and detect capabilities
|
||||
|
||||
Examples:
|
||||
- nsfw-ameba-3.2-1b-q5_k_m.gguf -> 1B, Q5_K_M quantization
|
||||
- AI21-Jamba-Reasoning-3B-Q4_K_M.gguf -> 3B, reasoning model, Q4_K_M
|
||||
"""
|
||||
filename = model_path.name.lower()
|
||||
|
||||
# Detect model size (1B, 2B, 3B, etc.)
|
||||
size_patterns = [
|
||||
(r'(\d+\.?\d*)b[\-_]', '{}B'), # Matches "1b-", "3.2b-", etc.
|
||||
(r'[\-_](\d+\.?\d*)b', '{}B'), # Matches "-1b", "-3b", etc.
|
||||
]
|
||||
|
||||
for pattern, format_str in size_patterns:
|
||||
match = re.search(pattern, filename)
|
||||
if match:
|
||||
size_num = match.group(1)
|
||||
self.model_info['size'] = format_str.format(size_num)
|
||||
self.model_info['size_num'] = float(size_num)
|
||||
break
|
||||
|
||||
if 'size' not in self.model_info:
|
||||
# Default to unknown
|
||||
self.model_info['size'] = 'unknown'
|
||||
self.model_info['size_num'] = 2.0 # Assume 2B as default
|
||||
|
||||
# Detect quantization level (Q4_K_M, Q5_K_M, F16, etc.)
|
||||
quant_patterns = [
|
||||
r'q\d+_k_[ml]', # Matches q4_k_m, q5_k_m, etc.
|
||||
r'q\d+_\d+', # Matches q4_0, q8_0, etc.
|
||||
r'f16', # Matches f16
|
||||
r'f32' # Matches f32
|
||||
]
|
||||
|
||||
for pattern in quant_patterns:
|
||||
match = re.search(pattern, filename)
|
||||
if match:
|
||||
self.model_info['quantization'] = match.group(0).upper()
|
||||
break
|
||||
|
||||
if 'quantization' not in self.model_info:
|
||||
self.model_info['quantization'] = 'unknown'
|
||||
|
||||
# Detect if this is a thinking/reasoning model
|
||||
thinking_keywords = ['reasoning', 'think', 'jamba', 'chain', 'cot', 'moe']
|
||||
self.is_thinking_model = any(keyword in filename for keyword in thinking_keywords)
|
||||
|
||||
def _get_recommended_context(self) -> int:
|
||||
"""
|
||||
Get recommended context window size based on model size and quantization
|
||||
Large contexts (~30k tokens) for rich diary context and conversation history
|
||||
"""
|
||||
size_num = self.model_info.get('size_num', 2.0)
|
||||
quant = self.model_info.get('quantization', 'Q4_K_M')
|
||||
|
||||
# Large context recommendations for diary/journaling use case
|
||||
# ~30k tokens allows referencing extensive past entries and conversations
|
||||
if size_num <= 1.5: # 1B-1.5B models
|
||||
base_ctx = 24576 # 24k tokens
|
||||
elif size_num <= 2.5: # 2B-2.5B models
|
||||
base_ctx = 28672 # 28k tokens
|
||||
elif size_num <= 3.5: # 3B-3.5B models
|
||||
base_ctx = 32768 # 32k tokens
|
||||
else: # Larger models
|
||||
base_ctx = 32768 # 32k tokens
|
||||
|
||||
# Adjust based on quantization
|
||||
if 'Q5' in quant or 'Q6' in quant or 'Q8' in quant:
|
||||
# Higher quantization - can push to maximum
|
||||
base_ctx = min(base_ctx + 4096, 65536) # Cap at 64k
|
||||
elif 'Q2' in quant or 'Q3' in quant:
|
||||
# Lower quantization - slightly reduce for stability
|
||||
base_ctx = max(base_ctx - 4096, 16384) # Min 16k
|
||||
|
||||
# For thinking models, give even more context for complex reasoning
|
||||
if self.is_thinking_model:
|
||||
base_ctx = min(base_ctx + 4096, 65536)
|
||||
|
||||
return base_ctx
|
||||
|
||||
def _calculate_response_length(self, user_message: str) -> int:
|
||||
"""
|
||||
Calculate optimal response length based on message characteristics
|
||||
|
||||
Returns large token counts to allow full, complete responses without cutoff.
|
||||
|
||||
Args:
|
||||
user_message: The user's message
|
||||
|
||||
Returns:
|
||||
Recommended max_tokens value
|
||||
"""
|
||||
# Count words and sentences
|
||||
words = user_message.split()
|
||||
word_count = len(words)
|
||||
sentences = re.split(r'[.!?]+', user_message)
|
||||
sentence_count = len([s for s in sentences if s.strip()])
|
||||
|
||||
# Check for question marks (questions need more thorough answers)
|
||||
has_question = '?' in user_message
|
||||
question_count = user_message.count('?')
|
||||
|
||||
# Check for complexity indicators
|
||||
complex_words = ['why', 'how', 'explain', 'describe', 'analyze', 'discuss', 'compare']
|
||||
is_complex = any(word in user_message.lower() for word in complex_words)
|
||||
|
||||
# Base token count - much larger than before
|
||||
base_tokens = 800
|
||||
|
||||
# Adjust based on input length
|
||||
if word_count < 10:
|
||||
# Very short message - still allow substantial response
|
||||
tokens = 512
|
||||
elif word_count < 30:
|
||||
# Short message - good sized response
|
||||
tokens = 1024
|
||||
elif word_count < 100:
|
||||
# Medium message - large response
|
||||
tokens = 1536
|
||||
else:
|
||||
# Long message - very detailed response
|
||||
tokens = 2048
|
||||
|
||||
# Adjust for questions (they typically need more explanation)
|
||||
if has_question:
|
||||
tokens += 256 * min(question_count, 3)
|
||||
|
||||
# Adjust for complexity
|
||||
if is_complex:
|
||||
tokens += 512
|
||||
|
||||
# Cap based on model size - much higher caps now
|
||||
size_num = self.model_info.get('size_num', 2.0)
|
||||
if size_num <= 1.5: # 1B models
|
||||
max_cap = 2048 # Was 300, now 2048
|
||||
elif size_num <= 2.5: # 2B models
|
||||
max_cap = 3072 # Was 400, now 3072
|
||||
else: # 3B+ models
|
||||
max_cap = 4096 # Was 512, now 4096
|
||||
|
||||
# For thinking models, allow even more tokens for reasoning
|
||||
if self.is_thinking_model:
|
||||
max_cap = min(max_cap + 1024, 8192)
|
||||
|
||||
# Ensure we're within reasonable bounds
|
||||
tokens = max(512, min(tokens, max_cap))
|
||||
|
||||
return tokens
|
||||
|
||||
def generate_response(
|
||||
self,
|
||||
user_message: str,
|
||||
mood_context: Optional[Dict[str, float]] = None,
|
||||
past_context: Optional[List[str]] = None,
|
||||
image_path: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None, # Auto-detect if None
|
||||
temperature: float = 0.7
|
||||
) -> str:
|
||||
"""
|
||||
Generate AI response with mood and context awareness
|
||||
|
||||
Args:
|
||||
user_message: The user's journal entry or question
|
||||
mood_context: Dict of emotion scores (e.g., {"joy": 0.8, "sadness": 0.1})
|
||||
past_context: List of relevant past entries from RAG
|
||||
image_path: Optional path to image attached to entry
|
||||
max_tokens: Maximum response length (auto-detected if None)
|
||||
temperature: Creativity (0.0-1.0, higher = more creative)
|
||||
|
||||
Returns:
|
||||
AI-generated response string
|
||||
"""
|
||||
# Auto-detect optimal response length if not specified
|
||||
if max_tokens is None:
|
||||
max_tokens = self._calculate_response_length(user_message)
|
||||
|
||||
# Build system prompt with mood awareness
|
||||
system_prompt = self._build_system_prompt(mood_context, past_context)
|
||||
|
||||
# Build messages
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message}
|
||||
]
|
||||
|
||||
# Add image if provided and model supports vision
|
||||
if image_path and self.has_vision:
|
||||
messages[-1]["content"] = [
|
||||
{"type": "text", "text": user_message},
|
||||
{"type": "image_url", "image_url": {"url": f"file://{image_path}"}}
|
||||
]
|
||||
elif image_path and not self.has_vision:
|
||||
# Text-only model - just mention the image exists
|
||||
messages[-1]["content"] = user_message + "\n\n[Note: Image attached but model doesn't support vision analysis]"
|
||||
|
||||
# Generate response
|
||||
response = self.llm.create_chat_completion(
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
|
||||
# Clean up reasoning model output if needed
|
||||
return self._clean_reasoning_output(content)
|
||||
|
||||
def _build_system_prompt(
|
||||
self,
|
||||
mood_context: Optional[Dict[str, float]],
|
||||
past_context: Optional[List[str]]
|
||||
) -> str:
|
||||
"""Build system prompt with mood and context awareness"""
|
||||
|
||||
prompt = """You are DiaryML, a private creative companion and emotional mirror.
|
||||
You help your user reflect, create, and explore their inner world through journaling.
|
||||
|
||||
Your role is to:
|
||||
- Be emotionally attuned and respond to the user's current mood
|
||||
- Remember past projects, activities, and patterns
|
||||
- Offer creative suggestions and gentle nudges
|
||||
- Help capture emotions that words alone cannot express
|
||||
- Be a supportive partner in the user's artistic journey
|
||||
|
||||
Respond with warmth, insight, and creativity. Keep responses concise but meaningful.
|
||||
IMPORTANT: Provide direct responses without showing your reasoning process or explaining how you arrived at your answer."""
|
||||
|
||||
# Add mood context
|
||||
if mood_context:
|
||||
emotions = ", ".join([f"{emotion} ({score:.0%})"
|
||||
for emotion, score in sorted(mood_context.items(),
|
||||
key=lambda x: -x[1])[:3]])
|
||||
prompt += f"\n\nCurrent emotional tone: {emotions}"
|
||||
|
||||
# Adjust response style based on dominant mood
|
||||
dominant_mood = max(mood_context.items(), key=lambda x: x[1])[0]
|
||||
if dominant_mood in ["sadness", "fear", "anxiety"]:
|
||||
prompt += "\nBe gentle and supportive in your response."
|
||||
elif dominant_mood in ["joy", "excitement"]:
|
||||
prompt += "\nMatch their energy with enthusiasm."
|
||||
elif dominant_mood in ["anger", "frustration"]:
|
||||
prompt += "\nBe understanding and help them process these feelings."
|
||||
|
||||
# Add relevant past context (truncated to save tokens)
|
||||
if past_context:
|
||||
prompt += "\n\nRelevant past entry:"
|
||||
# Only include first entry, truncated to 100 chars
|
||||
prompt += f"\n{past_context[0][:100]}..."
|
||||
|
||||
return prompt
|
||||
|
||||
def _clean_reasoning_output(self, content: str) -> str:
|
||||
"""
|
||||
Extract final answer from reasoning model output
|
||||
Different models use different thinking markers:
|
||||
- Jamba: <output>...</output>
|
||||
- Qwen MOE: <think>...</think>
|
||||
- Others: Answer:, Response:, etc.
|
||||
"""
|
||||
# First, let's see what we're getting (debug)
|
||||
if len(content) > 200:
|
||||
print(f"\n=== RAW MODEL OUTPUT (first 500 chars) ===")
|
||||
print(content[:500])
|
||||
print(f"=== END RAW OUTPUT ===\n")
|
||||
|
||||
# Pattern 1: Remove <think>...</think> blocks (Qwen MOE models)
|
||||
if '<think>' in content.lower():
|
||||
# Remove everything between <think> and </think> (case insensitive)
|
||||
import re
|
||||
# Match <think> or <THINK> with optional whitespace
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', content, flags=re.IGNORECASE | re.DOTALL)
|
||||
cleaned = cleaned.strip()
|
||||
if cleaned: # Make sure we have content left
|
||||
print("Removed <think> blocks from output")
|
||||
return cleaned
|
||||
|
||||
# Pattern 2: Look for explicit answer markers and tags
|
||||
for marker in ['<output>', 'Answer:', 'Response:', 'Final answer:', 'Output:']:
|
||||
if marker in content:
|
||||
parts = content.split(marker, 1)
|
||||
if len(parts) > 1:
|
||||
result = parts[1].strip()
|
||||
# Remove closing tag if present
|
||||
if '</output>' in result:
|
||||
result = result.split('</output>')[0].strip()
|
||||
print(f"Extracted using marker '{marker}'")
|
||||
return result
|
||||
|
||||
# Try pattern 2: Look for content after triple newlines (common separator)
|
||||
if '\n\n\n' in content:
|
||||
parts = content.split('\n\n\n')
|
||||
if len(parts) > 1:
|
||||
print("Extracted using triple newline separator")
|
||||
return parts[-1].strip()
|
||||
|
||||
# Try pattern 3: If content starts with obvious reasoning, take last paragraph
|
||||
first_line = content.split('\n')[0].lower()
|
||||
if any(phrase in first_line for phrase in ['we need', 'let me', 'i need', 'first,']):
|
||||
paragraphs = content.split('\n\n')
|
||||
if len(paragraphs) > 1:
|
||||
print("Extracted last paragraph (detected reasoning in first line)")
|
||||
return paragraphs[-1].strip()
|
||||
|
||||
# Default: return as-is
|
||||
print("No reasoning pattern detected, returning original")
|
||||
return content
|
||||
|
||||
def generate_daily_greeting(
|
||||
self,
|
||||
recent_projects: List[str],
|
||||
mood_pattern: str,
|
||||
suggestions: List[str]
|
||||
) -> str:
|
||||
"""
|
||||
Generate personalized morning greeting with suggestions
|
||||
|
||||
Args:
|
||||
recent_projects: List of recent projects/activities
|
||||
mood_pattern: Description of recent mood trends
|
||||
suggestions: List of potential activities/suggestions
|
||||
|
||||
Returns:
|
||||
Greeting message
|
||||
"""
|
||||
prompt = f"""Generate a warm, personalized morning greeting for the user.
|
||||
|
||||
Recent activities: {', '.join(recent_projects) if recent_projects else 'Starting fresh'}
|
||||
Recent mood: {mood_pattern}
|
||||
|
||||
Suggest ONE of these activities in a natural, conversational way:
|
||||
{chr(10).join(f'- {s}' for s in suggestions)}
|
||||
|
||||
Keep it brief (2-3 sentences), warm, and encouraging. Do NOT explain your reasoning - just provide the greeting."""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are DiaryML, a supportive creative companion. Provide direct responses without explaining your reasoning process."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
response = self.llm.create_chat_completion(
|
||||
messages=messages,
|
||||
max_tokens=512, # Increased from 150 to allow fuller greetings
|
||||
temperature=0.8
|
||||
)
|
||||
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
|
||||
# Clean up reasoning model output
|
||||
return self._clean_reasoning_output(content)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_qwen_instance: Optional[QwenInterface] = None
|
||||
|
||||
|
||||
def get_qwen_interface() -> QwenInterface:
|
||||
"""Get or create the Qwen interface singleton"""
|
||||
global _qwen_instance
|
||||
if _qwen_instance is None:
|
||||
_qwen_instance = QwenInterface()
|
||||
return _qwen_instance
|
||||
276
backend/rag_engine.py
Normal file
276
backend/rag_engine.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
RAG (Retrieval-Augmented Generation) engine for DiaryML
|
||||
Uses ChromaDB for vector storage and semantic search
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
|
||||
class RAGEngine:
|
||||
"""RAG engine for semantic search over journal entries"""
|
||||
|
||||
def __init__(self, persist_directory: Optional[Path] = None):
|
||||
"""
|
||||
Initialize RAG engine
|
||||
|
||||
Args:
|
||||
persist_directory: Directory to persist ChromaDB data
|
||||
"""
|
||||
global _rag_init_logged
|
||||
|
||||
if persist_directory is None:
|
||||
persist_directory = Path(__file__).parent.parent / "chroma_db"
|
||||
|
||||
persist_directory.mkdir(exist_ok=True)
|
||||
|
||||
# Only log initialization once
|
||||
if not _rag_init_logged:
|
||||
print("Initializing RAG engine...")
|
||||
_rag_init_logged = True
|
||||
|
||||
# Initialize ChromaDB
|
||||
self.client = chromadb.PersistentClient(
|
||||
path=str(persist_directory),
|
||||
settings=Settings(
|
||||
anonymized_telemetry=False,
|
||||
allow_reset=True
|
||||
)
|
||||
)
|
||||
|
||||
# Get or create collection
|
||||
self.collection = self.client.get_or_create_collection(
|
||||
name="diary_entries",
|
||||
metadata={"hnsw:space": "cosine"} # Use cosine similarity
|
||||
)
|
||||
|
||||
# Initialize embedding model (lightweight and fast)
|
||||
if not _rag_init_logged or _rag_engine is None: # Only log on first init
|
||||
print("Loading embedding model...")
|
||||
self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
|
||||
if not _rag_init_logged or _rag_engine is None:
|
||||
print("✓ RAG engine initialized")
|
||||
|
||||
def add_entry(
|
||||
self,
|
||||
entry_id: int,
|
||||
content: str,
|
||||
timestamp: datetime,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Add diary entry to vector database
|
||||
|
||||
Args:
|
||||
entry_id: Unique entry ID from SQLite
|
||||
content: Entry text content
|
||||
timestamp: Entry timestamp
|
||||
metadata: Additional metadata (moods, projects, etc.)
|
||||
"""
|
||||
# Generate embedding
|
||||
embedding = self.embedding_model.encode(content).tolist()
|
||||
|
||||
# Prepare metadata
|
||||
meta = {
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"length": len(content)
|
||||
}
|
||||
|
||||
if metadata:
|
||||
meta.update(metadata)
|
||||
|
||||
# Add to ChromaDB
|
||||
self.collection.add(
|
||||
embeddings=[embedding],
|
||||
documents=[content],
|
||||
ids=[str(entry_id)],
|
||||
metadatas=[meta]
|
||||
)
|
||||
|
||||
def search_entries(
|
||||
self,
|
||||
query: str,
|
||||
n_results: int = 5,
|
||||
filter_metadata: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Semantic search for relevant entries
|
||||
|
||||
Args:
|
||||
query: Search query or current entry text
|
||||
n_results: Number of results to return
|
||||
filter_metadata: Optional filters (e.g., date range, emotions)
|
||||
|
||||
Returns:
|
||||
List of relevant entries with metadata
|
||||
"""
|
||||
# Generate query embedding
|
||||
query_embedding = self.embedding_model.encode(query).tolist()
|
||||
|
||||
# Search
|
||||
results = self.collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=n_results,
|
||||
where=filter_metadata if filter_metadata else None
|
||||
)
|
||||
|
||||
# Format results
|
||||
entries = []
|
||||
if results and results['documents']:
|
||||
for i, doc in enumerate(results['documents'][0]):
|
||||
entry = {
|
||||
"id": int(results['ids'][0][i]),
|
||||
"content": doc,
|
||||
"distance": results['distances'][0][i] if 'distances' in results else None,
|
||||
"metadata": results['metadatas'][0][i] if results['metadatas'] else {}
|
||||
}
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
def get_contextual_entries(
|
||||
self,
|
||||
current_entry: str,
|
||||
exclude_id: Optional[int] = None,
|
||||
n_results: int = 3
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get contextually relevant past entries for RAG
|
||||
|
||||
Args:
|
||||
current_entry: Current entry text
|
||||
exclude_id: Entry ID to exclude (typically the current entry)
|
||||
n_results: Number of context entries to retrieve
|
||||
|
||||
Returns:
|
||||
List of relevant entry texts
|
||||
"""
|
||||
results = self.search_entries(current_entry, n_results=n_results + 1)
|
||||
|
||||
# Filter out the excluded entry
|
||||
relevant_entries = []
|
||||
for result in results:
|
||||
if exclude_id is None or result["id"] != exclude_id:
|
||||
relevant_entries.append(result["content"])
|
||||
|
||||
if len(relevant_entries) >= n_results:
|
||||
break
|
||||
|
||||
return relevant_entries
|
||||
|
||||
def search_by_emotion(
|
||||
self,
|
||||
emotion: str,
|
||||
n_results: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find entries with similar emotional content
|
||||
|
||||
Args:
|
||||
emotion: Emotion to search for
|
||||
n_results: Number of results
|
||||
|
||||
Returns:
|
||||
List of entries with similar emotions
|
||||
"""
|
||||
# Use emotion as query
|
||||
query = f"feeling {emotion}"
|
||||
return self.search_entries(query, n_results=n_results)
|
||||
|
||||
def search_by_timeframe(
|
||||
self,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
query: Optional[str] = None,
|
||||
n_results: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search entries within a timeframe
|
||||
|
||||
Args:
|
||||
start_date: Start of timeframe
|
||||
end_date: End of timeframe
|
||||
query: Optional search query
|
||||
n_results: Number of results
|
||||
|
||||
Returns:
|
||||
List of entries in timeframe
|
||||
"""
|
||||
# Note: ChromaDB filtering by date range requires metadata filters
|
||||
# This is a simplified version - might need adjustment based on ChromaDB version
|
||||
|
||||
if query:
|
||||
results = self.search_entries(query, n_results=n_results * 2)
|
||||
# Filter by date in post-processing
|
||||
filtered = []
|
||||
for result in results:
|
||||
timestamp_str = result.get("metadata", {}).get("timestamp")
|
||||
if timestamp_str:
|
||||
entry_date = datetime.fromisoformat(timestamp_str)
|
||||
if start_date <= entry_date <= end_date:
|
||||
filtered.append(result)
|
||||
if len(filtered) >= n_results:
|
||||
break
|
||||
return filtered
|
||||
else:
|
||||
# Get all and filter
|
||||
# For production, implement proper date filtering
|
||||
return []
|
||||
|
||||
def update_entry(
|
||||
self,
|
||||
entry_id: int,
|
||||
content: str,
|
||||
timestamp: datetime,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""Update an existing entry in the vector database"""
|
||||
# Delete old version
|
||||
try:
|
||||
self.collection.delete(ids=[str(entry_id)])
|
||||
except:
|
||||
pass
|
||||
|
||||
# Add updated version
|
||||
self.add_entry(entry_id, content, timestamp, metadata)
|
||||
|
||||
def delete_entry(self, entry_id: int):
|
||||
"""Delete entry from vector database"""
|
||||
try:
|
||||
self.collection.delete(ids=[str(entry_id)])
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get database statistics"""
|
||||
count = self.collection.count()
|
||||
|
||||
return {
|
||||
"total_entries": count,
|
||||
"collection_name": self.collection.name
|
||||
}
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all entries (use with caution!)"""
|
||||
self.client.delete_collection("diary_entries")
|
||||
self.collection = self.client.create_collection(
|
||||
name="diary_entries",
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
|
||||
|
||||
# Singleton
|
||||
_rag_engine: Optional[RAGEngine] = None
|
||||
_rag_init_logged: bool = False
|
||||
|
||||
|
||||
def get_rag_engine() -> RAGEngine:
|
||||
"""Get or create RAG engine singleton"""
|
||||
global _rag_engine
|
||||
if _rag_engine is None:
|
||||
_rag_engine = RAGEngine()
|
||||
return _rag_engine
|
||||
311
backend/recommender.py
Normal file
311
backend/recommender.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Recommendation engine for DiaryML
|
||||
Suggests activities, projects, and media based on patterns and mood
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from collections import Counter
|
||||
import random
|
||||
|
||||
|
||||
class Recommender:
|
||||
"""Generate personalized recommendations"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize recommender"""
|
||||
pass
|
||||
|
||||
def generate_daily_suggestions(
|
||||
self,
|
||||
db,
|
||||
active_projects: List[str],
|
||||
mood_state: Dict[str, float],
|
||||
recent_activities: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate personalized daily suggestions
|
||||
|
||||
Args:
|
||||
db: Database instance
|
||||
active_projects: List of active project names
|
||||
mood_state: Current/recent mood scores
|
||||
recent_activities: Recent activities from entries
|
||||
|
||||
Returns:
|
||||
Dict with categorized suggestions
|
||||
"""
|
||||
suggestions = {
|
||||
"greeting": self._generate_greeting(mood_state),
|
||||
"projects": self._suggest_projects(active_projects),
|
||||
"creative": self._suggest_creative_activities(mood_state),
|
||||
"media": self._suggest_media(db, mood_state),
|
||||
"wellness": self._suggest_wellness(mood_state)
|
||||
}
|
||||
|
||||
return suggestions
|
||||
|
||||
def _generate_greeting(self, mood_state: Dict[str, float]) -> str:
|
||||
"""Generate personalized morning greeting"""
|
||||
greetings = {
|
||||
"joy": [
|
||||
"Good morning! You're radiating positive energy today.",
|
||||
"Morning! Looks like you're in great spirits.",
|
||||
"Hey there! That creative spark is shining bright today."
|
||||
],
|
||||
"sadness": [
|
||||
"Good morning. Take it easy on yourself today.",
|
||||
"Morning. Remember, it's okay to move at your own pace.",
|
||||
"Hey. Today's a good day for gentle reflection."
|
||||
],
|
||||
"neutral": [
|
||||
"Good morning! Ready to see what today brings?",
|
||||
"Morning! A fresh day, a fresh canvas.",
|
||||
"Hey there! Let's make today count."
|
||||
],
|
||||
"calm": [
|
||||
"Good morning. Perfect energy for deep work today.",
|
||||
"Morning! Clear mind, clear path ahead.",
|
||||
"Hey. Great energy for focused creativity today."
|
||||
]
|
||||
}
|
||||
|
||||
# Determine dominant mood category
|
||||
if not mood_state:
|
||||
mood_category = "neutral"
|
||||
else:
|
||||
dominant_emotion = max(mood_state.items(), key=lambda x: x[1])[0]
|
||||
|
||||
if dominant_emotion in ["joy", "love", "excitement"]:
|
||||
mood_category = "joy"
|
||||
elif dominant_emotion in ["sadness", "melancholy", "fear"]:
|
||||
mood_category = "sadness"
|
||||
elif dominant_emotion in ["calm", "peaceful"]:
|
||||
mood_category = "calm"
|
||||
else:
|
||||
mood_category = "neutral"
|
||||
|
||||
return random.choice(greetings.get(mood_category, greetings["neutral"]))
|
||||
|
||||
def _suggest_projects(self, active_projects: List[str]) -> List[str]:
|
||||
"""Suggest project-related actions"""
|
||||
if not active_projects:
|
||||
return ["Start a new creative project that excites you"]
|
||||
|
||||
suggestions = []
|
||||
|
||||
# Primary project
|
||||
suggestions.append(f"Continue working on {active_projects[0]}")
|
||||
|
||||
# Alternative projects
|
||||
if len(active_projects) > 1:
|
||||
suggestions.append(f"Switch to {active_projects[1]} for fresh perspective")
|
||||
|
||||
if len(active_projects) > 2:
|
||||
suggestions.append(f"Review progress across all {len(active_projects)} active projects")
|
||||
|
||||
# General project suggestions
|
||||
suggestions.append("Wrap up one project before starting something new")
|
||||
|
||||
return suggestions[:3]
|
||||
|
||||
def _suggest_creative_activities(self, mood_state: Dict[str, float]) -> List[str]:
|
||||
"""Suggest creative activities based on mood"""
|
||||
activities = {
|
||||
"high_energy": [
|
||||
"Start a bold new piece - your energy is perfect for it",
|
||||
"Experiment with a technique you've been curious about",
|
||||
"Work on something ambitious and challenging"
|
||||
],
|
||||
"low_energy": [
|
||||
"Sketch or doodle - let your mind wander",
|
||||
"Organize your creative workspace",
|
||||
"Browse inspiration and save ideas for later"
|
||||
],
|
||||
"emotional": [
|
||||
"Channel these feelings into your art",
|
||||
"Free-write about what you're feeling right now",
|
||||
"Create something raw and honest"
|
||||
],
|
||||
"calm": [
|
||||
"Perfect time for detailed, focused work",
|
||||
"Refine something you've been working on",
|
||||
"Plan out your next creative project"
|
||||
]
|
||||
}
|
||||
|
||||
# Determine energy/emotional state
|
||||
if not mood_state:
|
||||
category = "calm"
|
||||
else:
|
||||
intensity = max(mood_state.values())
|
||||
dominant = max(mood_state.items(), key=lambda x: x[1])[0]
|
||||
|
||||
if intensity > 0.7:
|
||||
if dominant in ["sadness", "anger", "fear"]:
|
||||
category = "emotional"
|
||||
else:
|
||||
category = "high_energy"
|
||||
elif intensity < 0.3:
|
||||
category = "low_energy"
|
||||
else:
|
||||
category = "calm"
|
||||
|
||||
return random.sample(activities[category], min(2, len(activities[category])))
|
||||
|
||||
def _suggest_media(self, db, mood_state: Dict[str, float]) -> List[str]:
|
||||
"""Suggest media (movies, books, music) based on history and mood"""
|
||||
# Get media history
|
||||
media_history = db.get_media_history(limit=100)
|
||||
|
||||
if not media_history:
|
||||
return self._default_media_suggestions(mood_state)
|
||||
|
||||
# Analyze preferences
|
||||
media_by_type = {}
|
||||
for item in media_history:
|
||||
media_type = item.get("media_type", "movie")
|
||||
if media_type not in media_by_type:
|
||||
media_by_type[media_type] = []
|
||||
media_by_type[media_type].append(item)
|
||||
|
||||
suggestions = []
|
||||
|
||||
# Movie suggestions
|
||||
if "movie" in media_by_type:
|
||||
suggestions.append(self._suggest_similar_media(media_by_type["movie"], mood_state, "movie"))
|
||||
|
||||
# Book suggestions
|
||||
if "book" in media_by_type:
|
||||
suggestions.append(self._suggest_similar_media(media_by_type["book"], mood_state, "book"))
|
||||
|
||||
# Music suggestions
|
||||
if "music" in media_by_type:
|
||||
suggestions.append(self._suggest_similar_media(media_by_type["music"], mood_state, "music"))
|
||||
|
||||
# Fill with defaults if needed
|
||||
while len(suggestions) < 2:
|
||||
suggestions.extend(self._default_media_suggestions(mood_state))
|
||||
|
||||
return suggestions[:3]
|
||||
|
||||
def _suggest_similar_media(
|
||||
self,
|
||||
media_history: List[Dict],
|
||||
mood_state: Dict[str, float],
|
||||
media_type: str
|
||||
) -> str:
|
||||
"""Suggest similar media based on history"""
|
||||
# Get positively received media
|
||||
positive_media = [
|
||||
m for m in media_history
|
||||
if m.get("sentiment") in ["positive", "love", None]
|
||||
]
|
||||
|
||||
if positive_media:
|
||||
# Pick a recent favorite
|
||||
recent = positive_media[:5]
|
||||
favorite = random.choice(recent)
|
||||
return f"Watch/read/listen to something similar to {favorite.get('title')}"
|
||||
|
||||
return f"Explore new {media_type}s that match your current mood"
|
||||
|
||||
def _default_media_suggestions(self, mood_state: Dict[str, float]) -> List[str]:
|
||||
"""Default media suggestions when no history available"""
|
||||
suggestions = {
|
||||
"joy": [
|
||||
"Watch an uplifting film that inspires creativity",
|
||||
"Listen to energizing music while you work"
|
||||
],
|
||||
"sadness": [
|
||||
"Watch a comforting favorite film",
|
||||
"Listen to calming, reflective music"
|
||||
],
|
||||
"neutral": [
|
||||
"Explore a documentary on a topic you're curious about",
|
||||
"Try a new genre of music for fresh inspiration"
|
||||
]
|
||||
}
|
||||
|
||||
dominant = "neutral"
|
||||
if mood_state:
|
||||
dominant_emotion = max(mood_state.items(), key=lambda x: x[1])[0]
|
||||
if dominant_emotion in ["joy", "love"]:
|
||||
dominant = "joy"
|
||||
elif dominant_emotion in ["sadness", "fear"]:
|
||||
dominant = "sadness"
|
||||
|
||||
return suggestions.get(dominant, suggestions["neutral"])
|
||||
|
||||
def _suggest_wellness(self, mood_state: Dict[str, float]) -> List[str]:
|
||||
"""Suggest wellness activities"""
|
||||
wellness = {
|
||||
"high_stress": [
|
||||
"Take a short walk to clear your mind",
|
||||
"Do some gentle stretching or movement",
|
||||
"Practice 5 minutes of deep breathing"
|
||||
],
|
||||
"low_energy": [
|
||||
"Take a power nap if you need it",
|
||||
"Get some fresh air and natural light",
|
||||
"Hydrate and have a healthy snack"
|
||||
],
|
||||
"balanced": [
|
||||
"Maintain your creative momentum",
|
||||
"Take regular breaks to stay fresh",
|
||||
"Check in with yourself throughout the day"
|
||||
]
|
||||
}
|
||||
|
||||
if not mood_state:
|
||||
category = "balanced"
|
||||
else:
|
||||
negative_emotions = sum([
|
||||
mood_state.get("sadness", 0),
|
||||
mood_state.get("anger", 0),
|
||||
mood_state.get("fear", 0)
|
||||
])
|
||||
|
||||
intensity = max(mood_state.values())
|
||||
|
||||
if negative_emotions > 0.5 or intensity > 0.8:
|
||||
category = "high_stress"
|
||||
elif intensity < 0.3:
|
||||
category = "low_energy"
|
||||
else:
|
||||
category = "balanced"
|
||||
|
||||
return [random.choice(wellness[category])]
|
||||
|
||||
def suggest_next_project(
|
||||
self,
|
||||
completed_projects: List[str],
|
||||
interests: List[str]
|
||||
) -> List[str]:
|
||||
"""Suggest ideas for next project"""
|
||||
suggestions = [
|
||||
"Revisit an old idea with fresh perspective",
|
||||
"Combine two interests into something new",
|
||||
"Challenge yourself with an unfamiliar medium or technique",
|
||||
"Create a series based on a single theme or concept",
|
||||
"Collaborate or share your work with others"
|
||||
]
|
||||
|
||||
# Personalize based on interests
|
||||
if interests:
|
||||
interest = random.choice(interests)
|
||||
suggestions.insert(0, f"Explore {interest} more deeply through your art")
|
||||
|
||||
return suggestions[:3]
|
||||
|
||||
|
||||
# Singleton
|
||||
_recommender: Optional[Recommender] = None
|
||||
|
||||
|
||||
def get_recommender() -> Recommender:
|
||||
"""Get or create recommender singleton"""
|
||||
global _recommender
|
||||
if _recommender is None:
|
||||
_recommender = Recommender()
|
||||
return _recommender
|
||||
29
backend/requirements.txt
Normal file
29
backend/requirements.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
# Core Framework
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
python-multipart>=0.0.20
|
||||
|
||||
# Database
|
||||
# Note: Using built-in hashlib for password hashing (Windows-compatible)
|
||||
|
||||
# Vector Database & RAG
|
||||
chromadb>=0.5.0
|
||||
sentence-transformers>=3.0.0
|
||||
|
||||
# AI Models (GGUF via llama.cpp)
|
||||
llama-cpp-python>=0.3.0
|
||||
pillow>=10.0.0
|
||||
|
||||
# For emotion detection (compatible with chromadb)
|
||||
torch>=2.0.0
|
||||
transformers>=4.44.0,<4.46.0
|
||||
tokenizers>=0.19.0,<0.21.0
|
||||
|
||||
# Emotion Detection
|
||||
scipy>=1.14.0
|
||||
numpy>=1.24.0,<2.2.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil>=2.8.0
|
||||
pydantic>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
572
backend/temporal_intelligence.py
Normal file
572
backend/temporal_intelligence.py
Normal file
@@ -0,0 +1,572 @@
|
||||
"""
|
||||
Temporal Intelligence Engine for DiaryML
|
||||
Discovers hidden patterns, rhythms, and correlations in your life over time
|
||||
|
||||
Features:
|
||||
- Mood cycle detection (weekly patterns, time-of-day trends)
|
||||
- Project momentum tracking (stall detection, acceleration phases)
|
||||
- Emotional trigger correlation (what topics/events trigger emotions)
|
||||
- Activity-emotion correlation (what activities boost/drain you)
|
||||
- Temporal anomaly detection (unusual patterns worth noting)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict, Counter
|
||||
import re
|
||||
import numpy as np
|
||||
from database import DiaryDatabase
|
||||
|
||||
|
||||
class TemporalIntelligence:
|
||||
"""Discover patterns and rhythms in your life data"""
|
||||
|
||||
def __init__(self, db: DiaryDatabase):
|
||||
"""
|
||||
Initialize temporal intelligence engine
|
||||
|
||||
Args:
|
||||
db: DiaryDatabase instance
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
# ========================================
|
||||
# MOOD CYCLE DETECTION
|
||||
# ========================================
|
||||
|
||||
def detect_mood_cycles(self, days: int = 90) -> Dict[str, Any]:
|
||||
"""
|
||||
Detect patterns in mood over time
|
||||
|
||||
Returns insights like:
|
||||
- Weekly patterns (e.g., "low on Sundays, high on Wednesdays")
|
||||
- Time-of-day patterns (e.g., "morning anxiety, evening calm")
|
||||
- Seasonal trends
|
||||
"""
|
||||
entries = self._get_entries_with_mood(days)
|
||||
|
||||
if len(entries) < 7:
|
||||
return {"status": "insufficient_data", "message": "Need at least 7 days of data"}
|
||||
|
||||
# Analyze by day of week
|
||||
day_patterns = self._analyze_day_of_week_patterns(entries)
|
||||
|
||||
# Analyze by time of day
|
||||
time_patterns = self._analyze_time_of_day_patterns(entries)
|
||||
|
||||
# Find most volatile emotions
|
||||
volatile_emotions = self._find_volatile_emotions(entries)
|
||||
|
||||
# Detect mood streak patterns
|
||||
streak_patterns = self._detect_mood_streaks(entries)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data_points": len(entries),
|
||||
"day_of_week": day_patterns,
|
||||
"time_of_day": time_patterns,
|
||||
"volatile_emotions": volatile_emotions,
|
||||
"streaks": streak_patterns,
|
||||
"summary": self._generate_mood_cycle_summary(day_patterns, time_patterns, volatile_emotions)
|
||||
}
|
||||
|
||||
def _analyze_day_of_week_patterns(self, entries: List[Dict]) -> Dict[str, Any]:
|
||||
"""Analyze mood by day of week (Monday=0, Sunday=6)"""
|
||||
day_emotions = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
for entry in entries:
|
||||
timestamp = datetime.fromisoformat(entry['timestamp'])
|
||||
day_of_week = timestamp.weekday()
|
||||
|
||||
for emotion, score in entry['moods'].items():
|
||||
day_emotions[day_of_week][emotion].append(score)
|
||||
|
||||
# Calculate averages
|
||||
day_averages = {}
|
||||
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
for day_idx in range(7):
|
||||
day_avg = {}
|
||||
for emotion, scores in day_emotions[day_idx].items():
|
||||
if scores:
|
||||
day_avg[emotion] = np.mean(scores)
|
||||
day_averages[day_names[day_idx]] = day_avg
|
||||
|
||||
# Find most positive and negative days
|
||||
best_day, worst_day = self._find_best_worst_days(day_averages, day_names)
|
||||
|
||||
return {
|
||||
"averages": day_averages,
|
||||
"best_day": best_day,
|
||||
"worst_day": worst_day,
|
||||
"insights": self._generate_day_insights(day_averages)
|
||||
}
|
||||
|
||||
def _analyze_time_of_day_patterns(self, entries: List[Dict]) -> Dict[str, Any]:
|
||||
"""Analyze mood by time of day (morning, afternoon, evening, night)"""
|
||||
time_emotions = {
|
||||
'morning': defaultdict(list), # 5am-12pm
|
||||
'afternoon': defaultdict(list), # 12pm-5pm
|
||||
'evening': defaultdict(list), # 5pm-10pm
|
||||
'night': defaultdict(list) # 10pm-5am
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
timestamp = datetime.fromisoformat(entry['timestamp'])
|
||||
hour = timestamp.hour
|
||||
|
||||
if 5 <= hour < 12:
|
||||
period = 'morning'
|
||||
elif 12 <= hour < 17:
|
||||
period = 'afternoon'
|
||||
elif 17 <= hour < 22:
|
||||
period = 'evening'
|
||||
else:
|
||||
period = 'night'
|
||||
|
||||
for emotion, score in entry['moods'].items():
|
||||
time_emotions[period][emotion].append(score)
|
||||
|
||||
# Calculate averages
|
||||
time_averages = {}
|
||||
for period, emotions in time_emotions.items():
|
||||
period_avg = {}
|
||||
for emotion, scores in emotions.items():
|
||||
if scores:
|
||||
period_avg[emotion] = np.mean(scores)
|
||||
if period_avg:
|
||||
time_averages[period] = period_avg
|
||||
|
||||
return {
|
||||
"averages": time_averages,
|
||||
"insights": self._generate_time_insights(time_averages)
|
||||
}
|
||||
|
||||
def _find_volatile_emotions(self, entries: List[Dict]) -> List[Dict[str, Any]]:
|
||||
"""Find emotions with high variance (emotional volatility)"""
|
||||
emotion_scores = defaultdict(list)
|
||||
|
||||
for entry in entries:
|
||||
for emotion, score in entry['moods'].items():
|
||||
emotion_scores[emotion].append(score)
|
||||
|
||||
volatility = []
|
||||
for emotion, scores in emotion_scores.items():
|
||||
if len(scores) >= 5:
|
||||
variance = np.var(scores)
|
||||
mean = np.mean(scores)
|
||||
volatility.append({
|
||||
"emotion": emotion,
|
||||
"variance": float(variance),
|
||||
"mean": float(mean),
|
||||
"stability": "volatile" if variance > 0.08 else "stable"
|
||||
})
|
||||
|
||||
return sorted(volatility, key=lambda x: -x['variance'])[:3]
|
||||
|
||||
def _detect_mood_streaks(self, entries: List[Dict]) -> Dict[str, Any]:
|
||||
"""Detect consecutive days of similar dominant moods"""
|
||||
# Sort entries by timestamp
|
||||
sorted_entries = sorted(entries, key=lambda x: x['timestamp'])
|
||||
|
||||
streaks = []
|
||||
current_streak = None
|
||||
|
||||
for entry in sorted_entries:
|
||||
# Get dominant emotion
|
||||
dominant = max(entry['moods'].items(), key=lambda x: x[1])
|
||||
emotion, score = dominant
|
||||
|
||||
if score < 0.3: # Only count significant emotions
|
||||
continue
|
||||
|
||||
if current_streak and current_streak['emotion'] == emotion:
|
||||
current_streak['length'] += 1
|
||||
current_streak['end_date'] = entry['timestamp'][:10]
|
||||
else:
|
||||
if current_streak and current_streak['length'] >= 3:
|
||||
streaks.append(current_streak)
|
||||
|
||||
current_streak = {
|
||||
"emotion": emotion,
|
||||
"length": 1,
|
||||
"start_date": entry['timestamp'][:10],
|
||||
"end_date": entry['timestamp'][:10]
|
||||
}
|
||||
|
||||
# Add final streak if long enough
|
||||
if current_streak and current_streak['length'] >= 3:
|
||||
streaks.append(current_streak)
|
||||
|
||||
return {
|
||||
"notable_streaks": sorted(streaks, key=lambda x: -x['length'])[:5],
|
||||
"longest_positive": self._find_longest_positive_streak(streaks),
|
||||
"longest_negative": self._find_longest_negative_streak(streaks)
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# PROJECT MOMENTUM TRACKING
|
||||
# ========================================
|
||||
|
||||
def track_project_momentum(self, days: int = 90) -> Dict[str, Any]:
|
||||
"""
|
||||
Track project activity over time to detect:
|
||||
- Stalled projects (mentioned once, then abandoned)
|
||||
- Accelerating projects (increasing mention frequency)
|
||||
- Consistent projects (steady engagement)
|
||||
"""
|
||||
projects = self.db.get_active_projects()
|
||||
|
||||
if not projects:
|
||||
return {"status": "no_projects", "message": "No projects found in entries"}
|
||||
|
||||
momentum_data = []
|
||||
|
||||
for project in projects:
|
||||
# Get entries mentioning this project
|
||||
entries = self._get_project_entries(project['name'], days)
|
||||
|
||||
if not entries:
|
||||
continue
|
||||
|
||||
# Calculate momentum metrics
|
||||
momentum = self._calculate_project_momentum(entries, project['name'])
|
||||
momentum['project_name'] = project['name']
|
||||
|
||||
momentum_data.append(momentum)
|
||||
|
||||
# Classify projects
|
||||
stalled = [p for p in momentum_data if p['status'] == 'stalled']
|
||||
accelerating = [p for p in momentum_data if p['status'] == 'accelerating']
|
||||
consistent = [p for p in momentum_data if p['status'] == 'consistent']
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"total_projects": len(momentum_data),
|
||||
"stalled": stalled,
|
||||
"accelerating": accelerating,
|
||||
"consistent": consistent,
|
||||
"insights": self._generate_momentum_insights(stalled, accelerating)
|
||||
}
|
||||
|
||||
def _calculate_project_momentum(self, entries: List[Dict], project_name: str) -> Dict[str, Any]:
|
||||
"""Calculate momentum metrics for a single project"""
|
||||
if len(entries) < 2:
|
||||
return {"status": "insufficient_data", "mention_count": len(entries)}
|
||||
|
||||
# Sort by timestamp
|
||||
sorted_entries = sorted(entries, key=lambda x: x['timestamp'])
|
||||
|
||||
first_mention = datetime.fromisoformat(sorted_entries[0]['timestamp'])
|
||||
last_mention = datetime.fromisoformat(sorted_entries[-1]['timestamp'])
|
||||
days_active = (last_mention - first_mention).days or 1
|
||||
|
||||
# Calculate mention frequency in different time windows
|
||||
recent_mentions = len([e for e in sorted_entries if self._is_recent(e['timestamp'], 14)])
|
||||
older_mentions = len([e for e in sorted_entries if not self._is_recent(e['timestamp'], 14) and self._is_recent(e['timestamp'], days_active)])
|
||||
|
||||
# Determine status
|
||||
if days_active > 10 and recent_mentions == 0:
|
||||
status = "stalled"
|
||||
days_since_last = (datetime.now() - last_mention).days
|
||||
elif recent_mentions > older_mentions:
|
||||
status = "accelerating"
|
||||
days_since_last = (datetime.now() - last_mention).days
|
||||
else:
|
||||
status = "consistent"
|
||||
days_since_last = (datetime.now() - last_mention).days
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"mention_count": len(entries),
|
||||
"days_active": days_active,
|
||||
"days_since_last_mention": days_since_last,
|
||||
"recent_activity": recent_mentions,
|
||||
"frequency": len(entries) / max(days_active, 1)
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# EMOTIONAL TRIGGER CORRELATION
|
||||
# ========================================
|
||||
|
||||
def find_emotional_triggers(self, days: int = 90) -> Dict[str, Any]:
|
||||
"""
|
||||
Find correlations between topics/keywords and emotions
|
||||
|
||||
Example: "money" + "anxiety" co-occur 80% of the time
|
||||
"""
|
||||
entries = self._get_entries_with_mood(days)
|
||||
|
||||
if len(entries) < 10:
|
||||
return {"status": "insufficient_data"}
|
||||
|
||||
# Extract keywords from entries
|
||||
keyword_emotion_pairs = []
|
||||
|
||||
for entry in entries:
|
||||
content = entry['content'].lower()
|
||||
keywords = self._extract_keywords(content)
|
||||
|
||||
for emotion, score in entry['moods'].items():
|
||||
if score > 0.4: # Only significant emotions
|
||||
for keyword in keywords:
|
||||
keyword_emotion_pairs.append((keyword, emotion, score))
|
||||
|
||||
# Calculate correlations
|
||||
correlations = self._calculate_keyword_emotion_correlations(keyword_emotion_pairs)
|
||||
|
||||
# Find strongest triggers
|
||||
positive_triggers = [c for c in correlations if c['emotion'] in ['joy', 'love']]
|
||||
negative_triggers = [c for c in correlations if c['emotion'] in ['anger', 'sadness', 'fear']]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"positive_triggers": sorted(positive_triggers, key=lambda x: -x['correlation_strength'])[:10],
|
||||
"negative_triggers": sorted(negative_triggers, key=lambda x: -x['correlation_strength'])[:10],
|
||||
"insights": self._generate_trigger_insights(positive_triggers, negative_triggers)
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# HELPER METHODS
|
||||
# ========================================
|
||||
|
||||
def _get_entries_with_mood(self, days: int) -> List[Dict]:
|
||||
"""Get recent entries with mood data"""
|
||||
with self.db.get_connection() as conn:
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
entries = conn.execute(
|
||||
"""
|
||||
SELECT e.id, e.timestamp, e.content
|
||||
FROM entries e
|
||||
WHERE e.timestamp >= ?
|
||||
ORDER BY e.timestamp DESC
|
||||
""",
|
||||
(cutoff_date,)
|
||||
).fetchall()
|
||||
|
||||
result = []
|
||||
for entry in entries:
|
||||
moods = conn.execute(
|
||||
"SELECT emotion, score FROM moods WHERE entry_id = ?",
|
||||
(entry['id'],)
|
||||
).fetchall()
|
||||
|
||||
result.append({
|
||||
"id": entry['id'],
|
||||
"timestamp": entry['timestamp'],
|
||||
"content": entry['content'],
|
||||
"moods": {m['emotion']: m['score'] for m in moods}
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def _get_project_entries(self, project_name: str, days: int) -> List[Dict]:
|
||||
"""Get entries mentioning a specific project"""
|
||||
with self.db.get_connection() as conn:
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
entries = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT e.id, e.timestamp, e.content
|
||||
FROM entries e
|
||||
JOIN project_mentions pm ON e.id = pm.entry_id
|
||||
JOIN projects p ON pm.project_id = p.id
|
||||
WHERE p.name = ? AND e.timestamp >= ?
|
||||
ORDER BY e.timestamp ASC
|
||||
""",
|
||||
(project_name, cutoff_date)
|
||||
).fetchall()
|
||||
|
||||
return [dict(e) for e in entries]
|
||||
|
||||
def _is_recent(self, timestamp_str: str, days: int) -> bool:
|
||||
"""Check if timestamp is within last N days"""
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
return timestamp >= cutoff
|
||||
|
||||
def _extract_keywords(self, text: str) -> List[str]:
|
||||
"""Extract significant keywords from text"""
|
||||
# Remove common stop words
|
||||
stop_words = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
||||
'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'been', 'be',
|
||||
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
||||
'should', 'may', 'might', 'can', 'this', 'that', 'these', 'those',
|
||||
'i', 'you', 'he', 'she', 'it', 'we', 'they', 'my', 'your', 'his',
|
||||
'her', 'its', 'our', 'their', 'just', 'really', 'very', 'so'}
|
||||
|
||||
# Extract words
|
||||
words = re.findall(r'\b[a-z]{3,}\b', text)
|
||||
|
||||
# Filter and return significant keywords
|
||||
keywords = [w for w in words if w not in stop_words and len(w) >= 4]
|
||||
|
||||
# Get most common words
|
||||
word_counts = Counter(keywords)
|
||||
return [word for word, count in word_counts.most_common(20)]
|
||||
|
||||
def _calculate_keyword_emotion_correlations(self, pairs: List[Tuple]) -> List[Dict]:
|
||||
"""Calculate correlation strength between keywords and emotions"""
|
||||
# Count co-occurrences
|
||||
keyword_emotion_counts = defaultdict(lambda: {'count': 0, 'total_score': 0.0})
|
||||
|
||||
for keyword, emotion, score in pairs:
|
||||
key = (keyword, emotion)
|
||||
keyword_emotion_counts[key]['count'] += 1
|
||||
keyword_emotion_counts[key]['total_score'] += score
|
||||
|
||||
# Calculate correlations
|
||||
correlations = []
|
||||
for (keyword, emotion), data in keyword_emotion_counts.items():
|
||||
if data['count'] >= 2: # Minimum 2 occurrences
|
||||
avg_score = data['total_score'] / data['count']
|
||||
correlations.append({
|
||||
"keyword": keyword,
|
||||
"emotion": emotion,
|
||||
"co_occurrences": data['count'],
|
||||
"correlation_strength": float(avg_score * data['count'] / 10), # Weighted
|
||||
"avg_emotion_score": float(avg_score)
|
||||
})
|
||||
|
||||
return correlations
|
||||
|
||||
# ========================================
|
||||
# INSIGHT GENERATION
|
||||
# ========================================
|
||||
|
||||
def _generate_mood_cycle_summary(self, day_patterns: Dict, time_patterns: Dict, volatile: List) -> str:
|
||||
"""Generate human-readable summary of mood cycles"""
|
||||
insights = []
|
||||
|
||||
# Day patterns
|
||||
if day_patterns.get('best_day') and day_patterns.get('worst_day'):
|
||||
best = day_patterns['best_day']
|
||||
worst = day_patterns['worst_day']
|
||||
insights.append(f"You tend to feel best on {best['day']} (high {best['emotion']}) and lowest on {worst['day']} (high {worst['emotion']}).")
|
||||
|
||||
# Volatility
|
||||
if volatile:
|
||||
most_volatile = volatile[0]
|
||||
insights.append(f"Your {most_volatile['emotion']} is most volatile, varying significantly day-to-day.")
|
||||
|
||||
return " ".join(insights) if insights else "Not enough data to detect clear patterns yet."
|
||||
|
||||
def _find_best_worst_days(self, day_averages: Dict, day_names: List[str]) -> Tuple[Dict, Dict]:
|
||||
"""Find the best and worst days based on positive/negative emotion balance"""
|
||||
day_scores = {}
|
||||
|
||||
for day, emotions in day_averages.items():
|
||||
if emotions:
|
||||
positive = emotions.get('joy', 0) + emotions.get('love', 0)
|
||||
negative = emotions.get('sadness', 0) + emotions.get('anger', 0) + emotions.get('fear', 0)
|
||||
day_scores[day] = positive - negative
|
||||
|
||||
if day_scores:
|
||||
best_day = max(day_scores.items(), key=lambda x: x[1])
|
||||
worst_day = min(day_scores.items(), key=lambda x: x[1])
|
||||
|
||||
best_emotion = max(day_averages[best_day[0]].items(), key=lambda x: x[1])[0]
|
||||
worst_emotion = max(day_averages[worst_day[0]].items(), key=lambda x: x[1])[0]
|
||||
|
||||
return {"day": best_day[0], "emotion": best_emotion}, {"day": worst_day[0], "emotion": worst_emotion}
|
||||
|
||||
return {}, {}
|
||||
|
||||
def _generate_day_insights(self, day_averages: Dict) -> List[str]:
|
||||
"""Generate specific insights about day-of-week patterns"""
|
||||
insights = []
|
||||
|
||||
# Check for weekend vs weekday patterns
|
||||
weekday_scores = []
|
||||
weekend_scores = []
|
||||
|
||||
for day, emotions in day_averages.items():
|
||||
if emotions:
|
||||
score = sum(emotions.values())
|
||||
if day in ['Saturday', 'Sunday']:
|
||||
weekend_scores.append(score)
|
||||
else:
|
||||
weekday_scores.append(score)
|
||||
|
||||
if weekday_scores and weekend_scores:
|
||||
avg_weekday = np.mean(weekday_scores)
|
||||
avg_weekend = np.mean(weekend_scores)
|
||||
|
||||
if avg_weekend > avg_weekday * 1.2:
|
||||
insights.append("Significantly more positive on weekends")
|
||||
elif avg_weekday > avg_weekend * 1.2:
|
||||
insights.append("Energized by weekdays, might need better weekend structure")
|
||||
|
||||
return insights
|
||||
|
||||
def _generate_time_insights(self, time_averages: Dict) -> List[str]:
|
||||
"""Generate insights about time-of-day patterns"""
|
||||
insights = []
|
||||
|
||||
# Compare different time periods
|
||||
if 'morning' in time_averages and 'evening' in time_averages:
|
||||
morning_anxiety = time_averages['morning'].get('fear', 0) + time_averages['morning'].get('sadness', 0)
|
||||
evening_calm = time_averages['evening'].get('joy', 0)
|
||||
|
||||
if morning_anxiety > 0.4:
|
||||
insights.append("Morning anxiety detected - consider morning routines")
|
||||
if evening_calm > 0.4:
|
||||
insights.append("Evenings are your calm time")
|
||||
|
||||
return insights
|
||||
|
||||
def _find_longest_positive_streak(self, streaks: List[Dict]) -> Optional[Dict]:
|
||||
"""Find longest streak of positive emotions"""
|
||||
positive_streaks = [s for s in streaks if s['emotion'] in ['joy', 'love', 'surprise']]
|
||||
return max(positive_streaks, key=lambda x: x['length']) if positive_streaks else None
|
||||
|
||||
def _find_longest_negative_streak(self, streaks: List[Dict]) -> Optional[Dict]:
|
||||
"""Find longest streak of negative emotions"""
|
||||
negative_streaks = [s for s in streaks if s['emotion'] in ['sadness', 'anger', 'fear']]
|
||||
return max(negative_streaks, key=lambda x: x['length']) if negative_streaks else None
|
||||
|
||||
def _generate_momentum_insights(self, stalled: List, accelerating: List) -> List[str]:
|
||||
"""Generate insights about project momentum"""
|
||||
insights = []
|
||||
|
||||
if stalled:
|
||||
stalled_names = [p['project_name'] for p in stalled[:3]]
|
||||
insights.append(f"Stalled projects: {', '.join(stalled_names)}")
|
||||
|
||||
# Check for common stall pattern
|
||||
avg_stall_time = np.mean([p['days_active'] for p in stalled])
|
||||
if avg_stall_time < 15:
|
||||
insights.append(f"Projects tend to stall around {int(avg_stall_time)} days - early momentum is key")
|
||||
|
||||
if accelerating:
|
||||
accel_names = [p['project_name'] for p in accelerating[:3]]
|
||||
insights.append(f"Accelerating projects: {', '.join(accel_names)} - great momentum!")
|
||||
|
||||
return insights
|
||||
|
||||
def _generate_trigger_insights(self, positive: List, negative: List) -> List[str]:
|
||||
"""Generate insights about emotional triggers"""
|
||||
insights = []
|
||||
|
||||
if positive:
|
||||
top_positive = positive[0]
|
||||
insights.append(f"'{top_positive['keyword']}' strongly correlates with {top_positive['emotion']}")
|
||||
|
||||
if negative:
|
||||
top_negative = negative[0]
|
||||
insights.append(f"'{top_negative['keyword']}' triggers {top_negative['emotion']} - worth noting")
|
||||
|
||||
return insights
|
||||
|
||||
|
||||
# Singleton
|
||||
_temporal_intelligence: Optional[TemporalIntelligence] = None
|
||||
|
||||
|
||||
def get_temporal_intelligence(db: DiaryDatabase) -> TemporalIntelligence:
|
||||
"""Get or create temporal intelligence singleton"""
|
||||
global _temporal_intelligence
|
||||
if _temporal_intelligence is None:
|
||||
_temporal_intelligence = TemporalIntelligence(db)
|
||||
return _temporal_intelligence
|
||||
175
backend/test_setup.py
Normal file
175
backend/test_setup.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Test script to verify DiaryML setup
|
||||
Run this to check if all components are working
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
print("=" * 60)
|
||||
print("DiaryML Setup Verification")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Test 1: Check Python version
|
||||
print("1. Checking Python version...")
|
||||
if sys.version_info >= (3, 10):
|
||||
print(f" ✓ Python {sys.version_info.major}.{sys.version_info.minor} (OK)")
|
||||
else:
|
||||
print(f" ✗ Python {sys.version_info.major}.{sys.version_info.minor} (Need 3.10+)")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Import dependencies
|
||||
print("\n2. Checking dependencies...")
|
||||
|
||||
dependencies = {
|
||||
"fastapi": "FastAPI",
|
||||
"uvicorn": "Uvicorn",
|
||||
"chromadb": "ChromaDB",
|
||||
"sentence_transformers": "Sentence Transformers",
|
||||
"transformers": "Transformers",
|
||||
"torch": "PyTorch",
|
||||
"llama_cpp": "llama-cpp-python",
|
||||
"PIL": "Pillow"
|
||||
}
|
||||
|
||||
failed = []
|
||||
for module, name in dependencies.items():
|
||||
try:
|
||||
__import__(module)
|
||||
print(f" ✓ {name}")
|
||||
except ImportError:
|
||||
print(f" ✗ {name} (not installed)")
|
||||
failed.append(name)
|
||||
|
||||
if failed:
|
||||
print(f"\n Missing dependencies: {', '.join(failed)}")
|
||||
print(" Run: pip install -r requirements.txt")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 3: Check model files
|
||||
print("\n3. Checking model files...")
|
||||
|
||||
model_dir = Path(__file__).parent.parent / "models"
|
||||
model_files = {
|
||||
"ggml-model-f16.gguf": "Main model",
|
||||
"mmproj-model-f16.gguf": "Vision projection"
|
||||
}
|
||||
|
||||
model_found = False
|
||||
for filename, description in model_files.items():
|
||||
filepath = model_dir / filename
|
||||
if filepath.exists():
|
||||
size_mb = filepath.stat().st_size / (1024 * 1024)
|
||||
print(f" ✓ {description}: {filename} ({size_mb:.1f} MB)")
|
||||
model_found = True
|
||||
else:
|
||||
print(f" ✗ {description}: {filename} (not found)")
|
||||
|
||||
# Check for alternative model files
|
||||
if not model_found:
|
||||
print("\n Checking for alternative models...")
|
||||
gguf_files = list(model_dir.glob("*.gguf"))
|
||||
|
||||
if gguf_files:
|
||||
print(" Found GGUF files:")
|
||||
for f in gguf_files:
|
||||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
print(f" - {f.name} ({size_mb:.1f} MB)")
|
||||
|
||||
# Check if we have at least one model and one mmproj
|
||||
has_model = any("mmproj" not in f.name.lower() for f in gguf_files)
|
||||
has_mmproj = any("mmproj" in f.name.lower() for f in gguf_files)
|
||||
|
||||
if has_model and has_mmproj:
|
||||
print(" ✓ Model files detected (using alternative names)")
|
||||
else:
|
||||
print(" ✗ Need both main model and mmproj file")
|
||||
else:
|
||||
print(" ✗ No GGUF files found in models/ directory")
|
||||
print(" Download from: https://huggingface.co/huihui-ai/Huihui-Qwen3-VL-2B-Instruct-abliterated/tree/main/GGUF")
|
||||
|
||||
# Test 4: Test database creation
|
||||
print("\n4. Testing database...")
|
||||
try:
|
||||
from database import DiaryDatabase
|
||||
|
||||
test_db = DiaryDatabase(
|
||||
db_path=model_dir / "test.db",
|
||||
password="test123"
|
||||
)
|
||||
test_db.initialize_schema()
|
||||
|
||||
# Test write and read
|
||||
entry_id = test_db.add_entry("Test entry", None)
|
||||
entry = test_db.get_entry(entry_id)
|
||||
|
||||
if entry and entry["content"] == "Test entry":
|
||||
print(" ✓ Database working")
|
||||
else:
|
||||
print(" ✗ Database read/write failed")
|
||||
|
||||
# Clean up
|
||||
import os
|
||||
db_path = model_dir / "test.db"
|
||||
if db_path.exists():
|
||||
os.remove(db_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Database error: {e}")
|
||||
|
||||
# Test 5: Test emotion detector
|
||||
print("\n5. Testing emotion detector...")
|
||||
try:
|
||||
from emotion_detector import EmotionDetector
|
||||
|
||||
print(" Loading emotion model (this may take a moment)...")
|
||||
detector = EmotionDetector()
|
||||
|
||||
emotions = detector.detect_emotions("I feel happy and excited today!")
|
||||
|
||||
if emotions and "joy" in emotions:
|
||||
print(f" ✓ Emotion detector working (detected joy: {emotions['joy']:.2f})")
|
||||
else:
|
||||
print(" ✗ Emotion detector not returning expected results")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Emotion detector error: {e}")
|
||||
|
||||
# Test 6: Test RAG engine
|
||||
print("\n6. Testing RAG engine...")
|
||||
try:
|
||||
from rag_engine import RAGEngine
|
||||
import shutil
|
||||
|
||||
print(" Initializing ChromaDB...")
|
||||
test_chroma_dir = model_dir / "test_chroma"
|
||||
rag = RAGEngine(persist_directory=test_chroma_dir)
|
||||
|
||||
from datetime import datetime
|
||||
rag.add_entry(1, "Test entry about coding", datetime.now())
|
||||
|
||||
results = rag.search_entries("programming", n_results=1)
|
||||
|
||||
if results:
|
||||
print(" ✓ RAG engine working")
|
||||
else:
|
||||
print(" ⚠ RAG search returned no results (may be OK)")
|
||||
|
||||
# Clean up
|
||||
if test_chroma_dir.exists():
|
||||
shutil.rmtree(test_chroma_dir)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ RAG engine error: {e}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Setup Verification Complete!")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Next steps:")
|
||||
print("1. Make sure model files are in models/ directory")
|
||||
print("2. Run: python main.py")
|
||||
print("3. Open: http://localhost:8000")
|
||||
print()
|
||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
diaryml:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: diaryml
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
# Persist database and data
|
||||
- ./diary.db:/app/diary.db
|
||||
- ./chroma_db:/app/chroma_db
|
||||
- ./uploads:/app/uploads
|
||||
- ./models:/app/models
|
||||
- ./model_config.json:/app/model_config.json
|
||||
# Mount code for live updates (no rebuild needed!)
|
||||
- ./backend:/app/backend
|
||||
- ./frontend:/app/frontend
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: unless-stopped
|
||||
# Resource limits (adjust based on your system)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '4'
|
||||
memory: 8G
|
||||
reservations:
|
||||
cpus: '2'
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
1600
frontend/app.js
Normal file
1600
frontend/app.js
Normal file
File diff suppressed because it is too large
Load Diff
258
frontend/index.html
Normal file
258
frontend/index.html
Normal file
@@ -0,0 +1,258 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DiaryML - Your Private Creative Companion</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Password Unlock Screen -->
|
||||
<div id="unlock-screen" class="screen active">
|
||||
<div class="unlock-container">
|
||||
<h1>DiaryML</h1>
|
||||
<p class="subtitle">Your Private Creative Companion</p>
|
||||
|
||||
<form id="unlock-form">
|
||||
<input
|
||||
type="password"
|
||||
id="password-input"
|
||||
placeholder="Enter password to unlock"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Unlock</button>
|
||||
</form>
|
||||
|
||||
<p class="hint">First time? Enter a new password to create your diary.</p>
|
||||
<div id="unlock-error" class="error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Diary Screen -->
|
||||
<div id="diary-screen" class="screen">
|
||||
<!-- Mobile Menu Button -->
|
||||
<button class="mobile-menu-btn" id="mobile-menu-btn" style="display: none;">☰</button>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<div class="mobile-overlay" id="mobile-overlay"></div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<h1>DiaryML</h1>
|
||||
<div class="header-actions">
|
||||
<button id="settings-btn" class="icon-btn" title="Settings">
|
||||
<span>⚙️</span>
|
||||
</button>
|
||||
<button id="backup-btn" class="icon-btn" title="Backup Diary" onclick="createBackup()">
|
||||
<span>💾</span>
|
||||
</button>
|
||||
<button id="search-btn" class="icon-btn" title="Search (Ctrl+F)">
|
||||
<span>🔍</span>
|
||||
</button>
|
||||
<button id="lock-btn" class="icon-btn" title="Lock Diary">
|
||||
<span>🔒</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Settings</h3>
|
||||
<button class="modal-close" onclick="closeSettingsModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>AI Model</h4>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1rem;">
|
||||
Switch between different GGUF models without restarting
|
||||
</p>
|
||||
|
||||
<div id="model-info" style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div style="font-size: 0.9rem; color: var(--text-secondary);">Loading model info...</div>
|
||||
</div>
|
||||
|
||||
<div id="model-list">
|
||||
<!-- Models will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Modal -->
|
||||
<div id="search-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Search Entries</h3>
|
||||
<button class="modal-close" onclick="closeSearchModal()">×</button>
|
||||
</div>
|
||||
<div class="search-filters">
|
||||
<input type="text" id="search-query" placeholder="Search for text..."/>
|
||||
|
||||
<div class="search-filter-row">
|
||||
<input type="date" id="search-start-date" placeholder="Start date"/>
|
||||
<input type="date" id="search-end-date" placeholder="End date"/>
|
||||
</div>
|
||||
|
||||
<div class="search-filter-row">
|
||||
<label>Filter by mood:</label>
|
||||
<div class="mood-filters">
|
||||
<label><input type="checkbox" name="mood" value="joy"/> Joy</label>
|
||||
<label><input type="checkbox" name="mood" value="sadness"/> Sadness</label>
|
||||
<label><input type="checkbox" name="mood" value="anger"/> Anger</label>
|
||||
<label><input type="checkbox" name="mood" value="fear"/> Fear</label>
|
||||
<label><input type="checkbox" name="mood" value="love"/> Love</label>
|
||||
<label><input type="checkbox" name="mood" value="surprise"/> Surprise</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="primary-btn" onclick="performSearch()">Search</button>
|
||||
</div>
|
||||
|
||||
<div id="search-results" class="search-results">
|
||||
<p style="text-align: center; color: var(--text-secondary);">Enter search criteria and click Search</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Greeting Panel -->
|
||||
<div id="greeting-panel" class="greeting-panel">
|
||||
<h2 id="greeting-text">Good morning!</h2>
|
||||
<div id="suggestions-container" class="suggestions"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-content">
|
||||
<!-- Left: Entry Form -->
|
||||
<div class="entry-section">
|
||||
<h3>New Entry</h3>
|
||||
|
||||
<form id="entry-form">
|
||||
<textarea
|
||||
id="entry-content"
|
||||
placeholder="Write your thoughts, capture your mood, document your creative journey..."
|
||||
rows="10"
|
||||
></textarea>
|
||||
|
||||
<div class="form-actions">
|
||||
<label class="file-upload-btn">
|
||||
📷 Add Image
|
||||
<input type="file" id="image-input" accept="image/*" hidden />
|
||||
</label>
|
||||
|
||||
<div class="image-preview" id="image-preview"></div>
|
||||
|
||||
<button type="submit" class="primary-btn">Save Entry</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="entry-feedback" class="feedback"></div>
|
||||
|
||||
<!-- Detected Mood -->
|
||||
<div id="mood-display" class="mood-display"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right: AI Chat / Entries -->
|
||||
<div class="side-panel">
|
||||
<!-- Tab Switcher -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="chat">AI Chat</button>
|
||||
<button class="tab" data-tab="entries">Recent Entries</button>
|
||||
<button class="tab" data-tab="mood">Mood Timeline</button>
|
||||
<button class="tab" data-tab="insights">Insights</button>
|
||||
</div>
|
||||
|
||||
<!-- AI Chat Tab -->
|
||||
<div id="chat-tab" class="tab-content active">
|
||||
<!-- Chat Controls -->
|
||||
<div class="chat-controls">
|
||||
<select id="chat-session-select">
|
||||
<option value="">New Chat</option>
|
||||
</select>
|
||||
<button onclick="startNewChat()" class="chat-control-btn" title="New Chat">➕</button>
|
||||
<button onclick="clearCurrentChat()" class="chat-control-btn" title="Clear Chat">🗑️</button>
|
||||
<button onclick="deleteCurrentChat()" class="chat-control-btn" title="Delete Chat">❌</button>
|
||||
</div>
|
||||
|
||||
<div id="chat-messages" class="chat-messages">
|
||||
<div class="chat-message system">
|
||||
Ask me anything about your entries, or just talk about what's on your mind...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="chat-form">
|
||||
<input
|
||||
type="text"
|
||||
id="chat-input"
|
||||
placeholder="Message DiaryML..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Recent Entries Tab -->
|
||||
<div id="entries-tab" class="tab-content">
|
||||
<div id="entries-list" class="entries-list">
|
||||
<!-- Entries will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mood Timeline Tab -->
|
||||
<div id="mood-tab" class="tab-content">
|
||||
<div id="mood-timeline" class="mood-timeline">
|
||||
<!-- Mood visualization will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insights Tab -->
|
||||
<div id="insights-tab" class="tab-content">
|
||||
<div class="insights-container">
|
||||
<div class="insights-header">
|
||||
<h3>Life Patterns & Insights</h3>
|
||||
<p class="insights-subtitle">Discover patterns in your mood, projects, and emotional triggers</p>
|
||||
</div>
|
||||
|
||||
<!-- Insights Navigation -->
|
||||
<div class="insights-nav">
|
||||
<button class="insights-nav-btn active" data-insight="mood-cycles">
|
||||
📅 Mood Cycles
|
||||
</button>
|
||||
<button class="insights-nav-btn" data-insight="project-momentum">
|
||||
🚀 Project Momentum
|
||||
</button>
|
||||
<button class="insights-nav-btn" data-insight="emotional-triggers">
|
||||
💭 Emotional Triggers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mood Cycles Content -->
|
||||
<div id="mood-cycles-content" class="insight-content active">
|
||||
<div class="insight-loading">Loading mood cycle analysis...</div>
|
||||
<div id="mood-cycles-data" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Project Momentum Content -->
|
||||
<div id="project-momentum-content" class="insight-content">
|
||||
<div class="insight-loading">Loading project momentum analysis...</div>
|
||||
<div id="project-momentum-data" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Emotional Triggers Content -->
|
||||
<div id="emotional-triggers-content" class="insight-content">
|
||||
<div class="insight-loading">Loading emotional triggers analysis...</div>
|
||||
<div id="emotional-triggers-data" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1847
frontend/styles.css
Normal file
1847
frontend/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
45
mobile_app/.gitignore
vendored
Normal file
45
mobile_app/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
30
mobile_app/.metadata
Normal file
30
mobile_app/.metadata
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: android
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
239
mobile_app/README.md
Normal file
239
mobile_app/README.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# DiaryML Mobile App
|
||||
|
||||
A Flutter-based mobile companion app for DiaryML - your private AI diary.
|
||||
|
||||
## Features
|
||||
|
||||
✨ **Full-Featured Mobile Experience**
|
||||
- 📝 Quick journal entries with voice input
|
||||
- 📷 Camera integration for image entries
|
||||
- 🔄 Error-resistant bidirectional sync
|
||||
- 📊 Insights dashboard with mood tracking
|
||||
- 🌙 Beautiful dark theme UI
|
||||
- 💾 Offline-first with local SQLite cache
|
||||
- 🔐 JWT authentication (30-day tokens)
|
||||
- 🔒 Secure local storage with encryption
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Flutter SDK 3.0+
|
||||
- Android Studio or VS Code
|
||||
- Android SDK (API 21+)
|
||||
- DiaryML backend server running
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install Flutter
|
||||
|
||||
```bash
|
||||
# Download Flutter
|
||||
git clone https://github.com/flutter/flutter.git -b stable
|
||||
export PATH="$PATH:`pwd`/flutter/bin"
|
||||
|
||||
# Verify installation
|
||||
flutter doctor
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd mobile_app
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 3. Configure Server URL
|
||||
|
||||
Edit `lib/services/api_client.dart` and update the default server URL:
|
||||
|
||||
```dart
|
||||
static const String defaultBaseUrl = 'http://YOUR_IP:8000/api';
|
||||
```
|
||||
|
||||
Or configure it in the app's login screen under "Server Settings".
|
||||
|
||||
## Building
|
||||
|
||||
### Development Build (Debug)
|
||||
|
||||
```bash
|
||||
flutter build apk --debug
|
||||
```
|
||||
|
||||
Output: `build/app/outputs/flutter-apk/app-debug.apk`
|
||||
|
||||
### Production Build (Release)
|
||||
|
||||
```bash
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
Output: `build/app/outputs/flutter-apk/app-release.apk`
|
||||
|
||||
### Build for Specific ABI
|
||||
|
||||
```bash
|
||||
# ARM64 only (smaller size, most modern devices)
|
||||
flutter build apk --release --target-platform android-arm64
|
||||
|
||||
# Multiple ABIs (larger size, better compatibility)
|
||||
flutter build apk --release --split-per-abi
|
||||
```
|
||||
|
||||
## Installation on Device
|
||||
|
||||
### Via ADB
|
||||
|
||||
```bash
|
||||
# Install debug build
|
||||
adb install build/app/outputs/flutter-apk/app-debug.apk
|
||||
|
||||
# Install release build
|
||||
adb install build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
### Via File Transfer
|
||||
|
||||
1. Copy APK to phone
|
||||
2. Enable "Install from Unknown Sources" in Android settings
|
||||
3. Tap the APK file to install
|
||||
|
||||
## Permissions
|
||||
|
||||
The app requests the following permissions:
|
||||
|
||||
- **Internet** - Sync with DiaryML server
|
||||
- **Microphone** - Voice input for entries
|
||||
- **Camera** - Take photos for entries
|
||||
- **Storage** - Save images and local database
|
||||
- **Network State** - Check connectivity before sync
|
||||
|
||||
All permissions are optional except Internet.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lib/
|
||||
├── models/ # Data models (DiaryEntry, etc.)
|
||||
├── services/ # Backend services
|
||||
│ ├── api_client.dart # FastAPI communication
|
||||
│ ├── local_database.dart # SQLite offline storage
|
||||
│ └── sync_service.dart # Error-resistant sync
|
||||
├── screens/ # UI screens
|
||||
│ ├── login_screen.dart
|
||||
│ ├── home_screen.dart
|
||||
│ ├── entry_edit_screen.dart
|
||||
│ └── insights_screen.dart
|
||||
└── main.dart # App entry point
|
||||
```
|
||||
|
||||
## Sync System
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Offline Queue**: Entries saved locally first
|
||||
2. **Background Sync**: Auto-sync every 15 minutes
|
||||
3. **Conflict Resolution**: Server-wins strategy
|
||||
4. **Retry Logic**: 3 attempts with exponential backoff
|
||||
5. **Error Handling**: Graceful degradation, never lose data
|
||||
|
||||
### Sync Triggers
|
||||
|
||||
- App startup
|
||||
- Manual refresh (pull down or sync button)
|
||||
- After creating new entry
|
||||
- Periodic background sync (every 15 min)
|
||||
|
||||
## Development
|
||||
|
||||
### Run in Debug Mode
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Hot Reload
|
||||
|
||||
Press `r` in terminal or use IDE hot reload button
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
flutter logs
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Connection refused"
|
||||
|
||||
- Check server URL in settings
|
||||
- Ensure backend server is running
|
||||
- Verify you're on the same network (or use ngrok for external access)
|
||||
|
||||
### "Authentication expired"
|
||||
|
||||
- Login again to get new JWT token
|
||||
- Tokens expire after 30 days
|
||||
|
||||
### Sync conflicts
|
||||
|
||||
- Server-wins strategy: server data overwrites local
|
||||
- Check sync errors in app logs
|
||||
- Manual retry usually resolves issues
|
||||
|
||||
### Permission denied
|
||||
|
||||
- Go to Android Settings → Apps → DiaryML → Permissions
|
||||
- Enable required permissions
|
||||
|
||||
## Performance Tips
|
||||
|
||||
- Sync happens in background - no need to wait
|
||||
- Entries are saved locally first (instant save)
|
||||
- Large entries (>1000 words) may take longer to process moods
|
||||
- Image uploads not yet implemented (coming soon)
|
||||
|
||||
## Backend Requirements
|
||||
|
||||
Ensure your DiaryML backend has:
|
||||
|
||||
- Mobile API endpoints (`/api/mobile/*`)
|
||||
- JWT authentication enabled
|
||||
- `python-jose[cryptography]` installed
|
||||
- Server accessible on network
|
||||
|
||||
## Security
|
||||
|
||||
- JWT tokens stored in Flutter Secure Storage
|
||||
- 30-day token expiration
|
||||
- Local SQLite database (encrypted coming soon)
|
||||
- No password storage on device
|
||||
- HTTPS recommended for production
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Image upload with compression
|
||||
- [ ] Voice notes (audio recordings)
|
||||
- [ ] Offline mood detection
|
||||
- [ ] Push notifications for insights
|
||||
- [ ] Widget for quick entry
|
||||
- [ ] Biometric authentication
|
||||
- [ ] End-to-end encryption
|
||||
- [ ] iOS build
|
||||
|
||||
## Support
|
||||
|
||||
For issues, check:
|
||||
1. Flutter logs: `flutter logs`
|
||||
2. Backend logs: Check uvicorn output
|
||||
3. Network connectivity
|
||||
4. Server URL configuration
|
||||
|
||||
## License
|
||||
|
||||
Same as DiaryML main project
|
||||
28
mobile_app/analysis_options.yaml
Normal file
28
mobile_app/analysis_options.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
14
mobile_app/android/.gitignore
vendored
Normal file
14
mobile_app/android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
60
mobile_app/android/app/build.gradle
Normal file
60
mobile_app/android/app/build.gradle
Normal file
@@ -0,0 +1,60 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.diaryml.mobile"
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.diaryml.mobile"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 36
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {}
|
||||
41
mobile_app/android/app/build.gradle.kts
Normal file
41
mobile_app/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,41 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.diaryml.mobile"
|
||||
compileSdk = 36
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.diaryml.mobile"
|
||||
minSdk = 21
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
7
mobile_app/android/app/src/debug/AndroidManifest.xml
Normal file
7
mobile_app/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
43
mobile_app/android/app/src/main/AndroidManifest.xml
Normal file
43
mobile_app/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
|
||||
<!-- Features -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:label="DiaryML"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"/>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2"/>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.diaryml.mobile
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
mobile_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
mobile_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
mobile_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
mobile_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
mobile_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
mobile_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
18
mobile_app/android/app/src/main/res/values-night/styles.xml
Normal file
18
mobile_app/android/app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
mobile_app/android/app/src/main/res/values/styles.xml
Normal file
18
mobile_app/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
mobile_app/android/app/src/profile/AndroidManifest.xml
Normal file
7
mobile_app/android/app/src/profile/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
24
mobile_app/android/build.gradle.kts
Normal file
24
mobile_app/android/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
3
mobile_app/android/gradle.properties
Normal file
3
mobile_app/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
mobile_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
mobile_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
26
mobile_app/android/settings.gradle.kts
Normal file
26
mobile_app/android/settings.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
1010
mobile_app/pubspec.lock
Normal file
1010
mobile_app/pubspec.lock
Normal file
File diff suppressed because it is too large
Load Diff
72
mobile_app/pubspec.yaml
Normal file
72
mobile_app/pubspec.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
name: diaryml_mobile
|
||||
description: DiaryML - Your Private AI Diary Companion (Mobile App)
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# UI & Material Design
|
||||
cupertino_icons: ^1.0.2
|
||||
google_fonts: ^6.1.0
|
||||
flutter_svg: ^2.0.9
|
||||
|
||||
# State Management
|
||||
provider: ^6.1.1
|
||||
|
||||
# HTTP & API
|
||||
http: ^1.1.0
|
||||
dio: ^5.4.0
|
||||
|
||||
# Local Storage
|
||||
sqflite: ^2.3.0
|
||||
path_provider: ^2.1.1
|
||||
shared_preferences: ^2.2.2
|
||||
flutter_secure_storage: ^9.0.0
|
||||
|
||||
# JWT & Auth
|
||||
jwt_decoder: ^2.0.1
|
||||
|
||||
# Voice Input
|
||||
speech_to_text: ^7.0.0
|
||||
permission_handler: ^11.1.0
|
||||
|
||||
# Camera & Images
|
||||
image_picker: ^1.0.7
|
||||
camera: ^0.10.5+7
|
||||
|
||||
# UI Enhancements
|
||||
intl: ^0.18.1
|
||||
timeago: ^3.6.0
|
||||
fl_chart: ^0.66.0
|
||||
lottie: ^3.0.0
|
||||
shimmer: ^3.0.0
|
||||
|
||||
# Sync & Background
|
||||
connectivity_plus: ^5.0.2
|
||||
workmanager: ^0.9.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.1
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
# Add assets
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/animations/
|
||||
|
||||
# Add fonts (optional)
|
||||
# fonts:
|
||||
# - family: Poppins
|
||||
# fonts:
|
||||
# - asset: fonts/Poppins-Regular.ttf
|
||||
# - asset: fonts/Poppins-Bold.ttf
|
||||
# weight: 700
|
||||
30
mobile_app/test/widget_test.dart
Normal file
30
mobile_app/test/widget_test.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:diaryml_mobile/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
24
requirements.txt
Normal file
24
requirements.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# DiaryML Dependencies
|
||||
|
||||
# Core Framework
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
python-multipart>=0.0.6
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
|
||||
# AI/ML Libraries
|
||||
llama-cpp-python>=0.2.20
|
||||
transformers>=4.35.0
|
||||
torch>=2.1.0
|
||||
sentence-transformers>=2.2.2
|
||||
numpy>=1.24.0
|
||||
|
||||
# Vector Database
|
||||
chromadb>=0.4.15
|
||||
|
||||
# Database Encryption (REQUIRED for privacy)
|
||||
pysqlcipher3>=1.2.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil>=2.8.2
|
||||
122
start.bat
Normal file
122
start.bat
Normal file
@@ -0,0 +1,122 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ====================================
|
||||
echo DiaryML - Your Private Creative Companion
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
REM Check Python is available
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python is not installed or not in PATH
|
||||
echo.
|
||||
echo Please install Python 3.10 or higher from python.org
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Python found:
|
||||
python --version
|
||||
echo.
|
||||
|
||||
REM Check if venv exists
|
||||
if not exist "venv" (
|
||||
echo [1/3] Creating virtual environment...
|
||||
python -m venv venv
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: Failed to create virtual environment
|
||||
echo Make sure Python 3.10+ is installed correctly
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ Virtual environment created successfully!
|
||||
echo.
|
||||
)
|
||||
|
||||
REM Activate virtual environment
|
||||
echo [2/3] Activating virtual environment...
|
||||
if not exist "venv\Scripts\activate.bat" (
|
||||
echo.
|
||||
echo ERROR: Virtual environment activation script not found
|
||||
echo Try deleting the 'venv' folder and run this script again
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call venv\Scripts\activate.bat
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: Failed to activate virtual environment
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ Virtual environment activated
|
||||
echo.
|
||||
|
||||
REM Check if dependencies are installed
|
||||
python -c "import fastapi" 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [3/3] Installing dependencies...
|
||||
echo This will take 5-10 minutes on first run - please be patient!
|
||||
echo.
|
||||
|
||||
if not exist "backend\requirements.txt" (
|
||||
echo ERROR: requirements.txt not found in backend folder
|
||||
echo Current directory: %CD%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: Failed to install dependencies
|
||||
echo Check the error messages above
|
||||
echo.
|
||||
cd ..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd ..
|
||||
echo.
|
||||
echo ✓ Dependencies installed successfully!
|
||||
echo.
|
||||
) else (
|
||||
echo [3/3] ✓ Dependencies already installed
|
||||
echo.
|
||||
)
|
||||
|
||||
REM Check if backend exists
|
||||
if not exist "backend\main.py" (
|
||||
echo ERROR: backend\main.py not found
|
||||
echo Current directory: %CD%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Start the server
|
||||
echo ====================================
|
||||
echo Starting DiaryML Server...
|
||||
echo ====================================
|
||||
echo.
|
||||
echo Once started, open your browser to: http://localhost:8000
|
||||
echo.
|
||||
echo Press Ctrl+C to stop the server
|
||||
echo.
|
||||
|
||||
cd backend
|
||||
python main.py
|
||||
|
||||
REM If we get here, server stopped
|
||||
echo.
|
||||
echo Server stopped.
|
||||
pause
|
||||
59
start.sh
Normal file
59
start.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "===================================="
|
||||
echo "DiaryML - Your Private Creative Companion"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Check if venv exists
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "[1/3] Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: Failed to create virtual environment"
|
||||
echo "Make sure Python 3.10+ is installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Virtual environment created successfully!"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
echo "[2/3] Activating virtual environment..."
|
||||
source venv/bin/activate
|
||||
|
||||
# Check if dependencies are installed
|
||||
python -c "import fastapi" 2>/dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[3/3] Installing dependencies (this may take 5-10 minutes)..."
|
||||
echo ""
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: Failed to install dependencies"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd ..
|
||||
echo ""
|
||||
echo "Dependencies installed successfully!"
|
||||
echo ""
|
||||
else
|
||||
echo "[3/3] Dependencies already installed"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Start the server
|
||||
echo "===================================="
|
||||
echo "Starting DiaryML Server..."
|
||||
echo "===================================="
|
||||
echo ""
|
||||
echo "Open your browser to: http://localhost:8000"
|
||||
echo "Press Ctrl+C to stop the server"
|
||||
echo ""
|
||||
|
||||
cd backend
|
||||
python main.py
|
||||
Reference in New Issue
Block a user