initial commit

This commit is contained in:
2025-11-07 12:18:14 -05:00
commit 04aa1438af
52 changed files with 11583 additions and 0 deletions

63
.dockerignore Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,224 @@
# DiaryML
![Python](https://img.shields.io/badge/python-3.10%2B-blue?style=flat-square)
![Docker](https://img.shields.io/badge/docker-ready-blue?style=flat-square)
![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey?style=flat-square)
![AI](https://img.shields.io/badge/AI-Local%20GGUF-orange?style=flat-square)
![Privacy](https://img.shields.io/badge/privacy-100%25%20Local-success?style=flat-square)
![CPU](https://img.shields.io/badge/mode-CPU%20Only-blue?style=flat-square)
**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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

183
backend/mobile_auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

258
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

45
mobile_app/.gitignore vendored Normal file
View 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
View 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
View 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

View 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
View 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

View 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 {}

View 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 = "../.."
}

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
package com.diaryml.mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

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

View 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>

View 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>

View 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>

View 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)
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View 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

View 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

File diff suppressed because it is too large Load Diff

72
mobile_app/pubspec.yaml Normal file
View 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

View 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
View 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
View 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
View 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