Spaces:
Sleeping
Sleeping
Commit Β·
05c4fa2
0
Parent(s):
Initial VoiceCal.ai v1.0.0 deployment
Browse filesClean repository structure for HuggingFace Spaces deployment:
- All files at root level (no nested directories)
- Standard Docker deployment structure
- JWT session management for stateless deployment
- Voice interface with STT/TTS integration
- Google Calendar integration with OAuth2
- Groq Llama-3.1-8b-instant LLM backend
Features:
- Voice-first booking interface (/chat-widget)
- 15-minute time slot increments
- Google Meet integration
- Email confirmations
- Multi-turn conversation support
π€ Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
- .env.example +27 -0
- .gitignore +134 -0
- Dockerfile +52 -0
- README.md +237 -0
- app.py +43 -0
- app/__init__.py +0 -0
- app/api/__init__.py +0 -0
- app/api/chat_widget.py +1401 -0
- app/api/main.py +480 -0
- app/api/models.py +116 -0
- app/api/simple_chat.py +220 -0
- app/calendar/__init__.py +0 -0
- app/calendar/auth.py +226 -0
- app/calendar/service.py +308 -0
- app/calendar/utils.py +315 -0
- app/config.py +89 -0
- app/core/__init__.py +0 -0
- app/core/agent.py +996 -0
- app/core/conversation.py +74 -0
- app/core/email_service.py +359 -0
- app/core/exceptions.py +47 -0
- app/core/jwt_session.py +110 -0
- app/core/llm.py +71 -0
- app/core/llm_anthropic.py +89 -0
- app/core/llm_gemini.py +89 -0
- app/core/mock_llm.py +122 -0
- app/core/session.py +151 -0
- app/core/session_factory.py +17 -0
- app/core/tools.py +981 -0
- app/core/validators.py +173 -0
- app/personality/__init__.py +0 -0
- app/personality/prompts.py +231 -0
- app/services/tts_proxy.py +174 -0
- pyproject.toml +50 -0
- requirements.txt +22 -0
.env.example
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Anthropic API (optional)
|
| 2 |
+
ANTHROPIC_API_KEY=
|
| 3 |
+
|
| 4 |
+
# Gemini API
|
| 5 |
+
GEMINI_API_KEY=your_gemini_api_key_here
|
| 6 |
+
# Google Calendar
|
| 7 |
+
GOOGLE_CALENDAR_ID=pgits.job@gmail.com
|
| 8 |
+
GOOGLE_CLIENT_ID=
|
| 9 |
+
GOOGLE_CLIENT_SECRET=
|
| 10 |
+
|
| 11 |
+
# Session Backend (redis for local, jwt for HF)
|
| 12 |
+
SESSION_BACKEND=redis
|
| 13 |
+
# Redis Configuration (only needed if SESSION_BACKEND=redis)
|
| 14 |
+
REDIS_URL=redis://redis:6379/0
|
| 15 |
+
|
| 16 |
+
# Application Settings
|
| 17 |
+
APP_NAME=ChatCal.ai
|
| 18 |
+
APP_ENV=development
|
| 19 |
+
APP_PORT=8000
|
| 20 |
+
APP_HOST=0.0.0.0
|
| 21 |
+
|
| 22 |
+
# Security
|
| 23 |
+
SECRET_KEY=
|
| 24 |
+
CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"]
|
| 25 |
+
|
| 26 |
+
# Timezone
|
| 27 |
+
DEFAULT_TIMEZONE=America/New_York
|
.gitignore
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment variables
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
.env.*.local
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.so
|
| 11 |
+
.Python
|
| 12 |
+
build/
|
| 13 |
+
develop-eggs/
|
| 14 |
+
dist/
|
| 15 |
+
downloads/
|
| 16 |
+
eggs/
|
| 17 |
+
.eggs/
|
| 18 |
+
lib/
|
| 19 |
+
lib64/
|
| 20 |
+
parts/
|
| 21 |
+
sdist/
|
| 22 |
+
var/
|
| 23 |
+
wheels/
|
| 24 |
+
pip-wheel-metadata/
|
| 25 |
+
share/python-wheels/
|
| 26 |
+
*.egg-info/
|
| 27 |
+
.installed.cfg
|
| 28 |
+
*.egg
|
| 29 |
+
MANIFEST
|
| 30 |
+
|
| 31 |
+
# PyInstaller
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
|
| 35 |
+
# Installer logs
|
| 36 |
+
pip-log.txt
|
| 37 |
+
pip-delete-this-directory.txt
|
| 38 |
+
|
| 39 |
+
# Unit test / coverage reports
|
| 40 |
+
htmlcov/
|
| 41 |
+
.tox/
|
| 42 |
+
.nox/
|
| 43 |
+
.coverage
|
| 44 |
+
.coverage.*
|
| 45 |
+
.cache
|
| 46 |
+
nosetests.xml
|
| 47 |
+
coverage.xml
|
| 48 |
+
*.cover
|
| 49 |
+
*.py,cover
|
| 50 |
+
.hypothesis/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
|
| 53 |
+
# Jupyter Notebook
|
| 54 |
+
.ipynb_checkpoints
|
| 55 |
+
|
| 56 |
+
# IPython
|
| 57 |
+
profile_default/
|
| 58 |
+
ipython_config.py
|
| 59 |
+
|
| 60 |
+
# pyenv
|
| 61 |
+
.python-version
|
| 62 |
+
|
| 63 |
+
# pipenv
|
| 64 |
+
Pipfile.lock
|
| 65 |
+
|
| 66 |
+
# PEP 582
|
| 67 |
+
__pypackages__/
|
| 68 |
+
|
| 69 |
+
# Celery stuff
|
| 70 |
+
celerybeat-schedule
|
| 71 |
+
celerybeat.pid
|
| 72 |
+
|
| 73 |
+
# SageMath parsed files
|
| 74 |
+
*.sage.py
|
| 75 |
+
|
| 76 |
+
# Environments
|
| 77 |
+
.venv
|
| 78 |
+
env/
|
| 79 |
+
venv/
|
| 80 |
+
ENV/
|
| 81 |
+
env.bak/
|
| 82 |
+
venv.bak/
|
| 83 |
+
|
| 84 |
+
# Spyder project settings
|
| 85 |
+
.spyderproject
|
| 86 |
+
.spyproject
|
| 87 |
+
|
| 88 |
+
# Rope project settings
|
| 89 |
+
.ropeproject
|
| 90 |
+
|
| 91 |
+
# mkdocs documentation
|
| 92 |
+
/site
|
| 93 |
+
|
| 94 |
+
# mypy
|
| 95 |
+
.mypy_cache/
|
| 96 |
+
.dmypy.json
|
| 97 |
+
dmypy.json
|
| 98 |
+
|
| 99 |
+
# Pyre type checker
|
| 100 |
+
.pyre/
|
| 101 |
+
|
| 102 |
+
# IDEs
|
| 103 |
+
.vscode/
|
| 104 |
+
.idea/
|
| 105 |
+
*.swp
|
| 106 |
+
*.swo
|
| 107 |
+
|
| 108 |
+
# macOS
|
| 109 |
+
.DS_Store
|
| 110 |
+
.DS_Store?
|
| 111 |
+
._*
|
| 112 |
+
.Spotlight-V100
|
| 113 |
+
.Trashes
|
| 114 |
+
ehthumbs.db
|
| 115 |
+
Thumbs.db
|
| 116 |
+
|
| 117 |
+
# Logs
|
| 118 |
+
logs/
|
| 119 |
+
*.log
|
| 120 |
+
server.log
|
| 121 |
+
|
| 122 |
+
# Redis dump file
|
| 123 |
+
dump.rdb
|
| 124 |
+
|
| 125 |
+
# Google Calendar credentials
|
| 126 |
+
credentials.json
|
| 127 |
+
token.json
|
| 128 |
+
|
| 129 |
+
# API testing
|
| 130 |
+
test_api.py
|
| 131 |
+
|
| 132 |
+
# Temporary files
|
| 133 |
+
*.tmp
|
| 134 |
+
*.temp
|
Dockerfile
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Environment variables for HF deployment
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV SESSION_BACKEND=jwt
|
| 7 |
+
ENV APP_ENV=production
|
| 8 |
+
ENV PORT=7860
|
| 9 |
+
|
| 10 |
+
# Install system dependencies and debugging tools
|
| 11 |
+
RUN apt-get update && apt-get install -y \
|
| 12 |
+
gcc \
|
| 13 |
+
g++ \
|
| 14 |
+
wget \
|
| 15 |
+
git \
|
| 16 |
+
curl \
|
| 17 |
+
procps \
|
| 18 |
+
htop \
|
| 19 |
+
nano \
|
| 20 |
+
vim \
|
| 21 |
+
net-tools \
|
| 22 |
+
lsof \
|
| 23 |
+
strace \
|
| 24 |
+
telnet \
|
| 25 |
+
netcat-openbsd \
|
| 26 |
+
tree \
|
| 27 |
+
less \
|
| 28 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 29 |
+
|
| 30 |
+
# Set working directory
|
| 31 |
+
WORKDIR /app
|
| 32 |
+
|
| 33 |
+
# Copy requirements first for better caching
|
| 34 |
+
COPY requirements.txt .
|
| 35 |
+
|
| 36 |
+
# Install Python dependencies
|
| 37 |
+
RUN pip install --no-cache-dir --upgrade pip
|
| 38 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 39 |
+
|
| 40 |
+
# Copy application code
|
| 41 |
+
COPY . .
|
| 42 |
+
|
| 43 |
+
# Create non-root user
|
| 44 |
+
RUN useradd -m -u 1000 user && \
|
| 45 |
+
chown -R user:user /app
|
| 46 |
+
USER user
|
| 47 |
+
|
| 48 |
+
# Expose port
|
| 49 |
+
EXPOSE 7860
|
| 50 |
+
|
| 51 |
+
# Run the application (no tee to prevent exit)
|
| 52 |
+
CMD ["python", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: VoiceCal.ai - Voice Calendar Assistant v1
|
| 3 |
+
emoji: ποΈ
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
suggested_hardware: cpu-basic
|
| 10 |
+
suggested_storage: small
|
| 11 |
+
startup_duration_timeout: 5m
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# VoiceCal.ai v1.0.0 - AI-Powered Voice Calendar Assistant
|
| 15 |
+
|
| 16 |
+
An intelligent AI scheduling assistant powered by Groq's Llama-3.1-8b-instant and LlamaIndex that helps users book appointments on Google Calendar with natural conversation and smart features.
|
| 17 |
+
|
| 18 |
+
## Features
|
| 19 |
+
|
| 20 |
+
- π€ **Groq-powered LLM**: Fast, efficient conversation using Llama-3.1-8b-instant
|
| 21 |
+
- π
**Smart Calendar Integration**: Seamless Google Calendar booking with conflict detection
|
| 22 |
+
- π§ **Conversation Memory**: Persistent context across multi-turn conversations
|
| 23 |
+
- π¨ **HTML-formatted Responses**: Rich, styled responses for better readability
|
| 24 |
+
- π **Custom Meeting IDs**: Human-readable meeting IDs (MMDD-HHMM-DURm format)
|
| 25 |
+
- ποΈ **Smart Cancellation**: Intelligent meeting matching with automatic email notifications
|
| 26 |
+
- π₯ **Google Meet Integration**: Automatic video conference setup for remote meetings
|
| 27 |
+
- π§ **Email Notifications**: Automated booking confirmations and cancellation notices
|
| 28 |
+
- π§ͺ **Testing Mode**: Configurable testing environment for development
|
| 29 |
+
- π **FastAPI Backend**: High-performance API with Redis session management
|
| 30 |
+
- π **Secure Authentication**: OAuth2 for Google Calendar access
|
| 31 |
+
- π **Timezone-aware**: Smart scheduling across time zones
|
| 32 |
+
|
| 33 |
+
## Prerequisites
|
| 34 |
+
|
| 35 |
+
- Python 3.11+
|
| 36 |
+
- Docker and Docker Compose
|
| 37 |
+
- Groq API key (primary LLM)
|
| 38 |
+
- Anthropic API key (optional fallback)
|
| 39 |
+
- Google Cloud Project with Calendar API enabled
|
| 40 |
+
- Google OAuth2 credentials
|
| 41 |
+
- SMTP credentials for email notifications
|
| 42 |
+
|
| 43 |
+
## Quick Start
|
| 44 |
+
|
| 45 |
+
### 1. Clone the repository
|
| 46 |
+
```bash
|
| 47 |
+
git clone <repository>
|
| 48 |
+
cd chatcal-ai
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### 2. Set up environment variables
|
| 52 |
+
```bash
|
| 53 |
+
cp .env.example .env
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
Edit `.env` with your credentials:
|
| 57 |
+
- `GROQ_API_KEY`: Your Groq API key (primary)
|
| 58 |
+
- `ANTHROPIC_API_KEY`: Your Anthropic API key (optional fallback)
|
| 59 |
+
- `GOOGLE_CLIENT_ID` & `GOOGLE_CLIENT_SECRET`: From Google Cloud Console
|
| 60 |
+
- `SECRET_KEY`: Generate a secure secret key
|
| 61 |
+
- `MY_PHONE_NUMBER` & `MY_EMAIL_ADDRESS`: Contact information
|
| 62 |
+
- `SMTP_USERNAME` & `SMTP_PASSWORD`: Email service credentials
|
| 63 |
+
- `TESTING_MODE`: Set to true for development (ignores Peter's email validation)
|
| 64 |
+
|
| 65 |
+
### 3. Google Calendar Setup
|
| 66 |
+
|
| 67 |
+
1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
| 68 |
+
2. Create a new project or select existing
|
| 69 |
+
3. Enable Google Calendar API:
|
| 70 |
+
- Navigate to "APIs & Services" > "Library"
|
| 71 |
+
- Search for "Google Calendar API"
|
| 72 |
+
- Click "Enable"
|
| 73 |
+
4. Create OAuth2 credentials:
|
| 74 |
+
- Go to "APIs & Services" > "Credentials"
|
| 75 |
+
- Click "Create Credentials" > "OAuth client ID"
|
| 76 |
+
- Choose "Web application"
|
| 77 |
+
- Add authorized redirect URIs:
|
| 78 |
+
- `http://localhost:8000/auth/callback`
|
| 79 |
+
- `https://yourdomain.com/auth/callback` (for production)
|
| 80 |
+
- Save and download the credentials
|
| 81 |
+
|
| 82 |
+
### 4. Run with Docker
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
# Build and start all services
|
| 86 |
+
docker-compose up -d
|
| 87 |
+
|
| 88 |
+
# View logs
|
| 89 |
+
docker-compose logs -f
|
| 90 |
+
|
| 91 |
+
# Stop services
|
| 92 |
+
docker-compose down
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
The application will be available at:
|
| 96 |
+
- API: http://localhost:8000
|
| 97 |
+
- API Docs: http://localhost:8000/docs
|
| 98 |
+
|
| 99 |
+
### 5. Development Setup (without Docker)
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
# Install Poetry
|
| 103 |
+
pip install poetry
|
| 104 |
+
|
| 105 |
+
# Install dependencies
|
| 106 |
+
poetry install
|
| 107 |
+
|
| 108 |
+
# Activate virtual environment
|
| 109 |
+
poetry shell
|
| 110 |
+
|
| 111 |
+
# Run the application
|
| 112 |
+
uvicorn app.api.main:app --reload
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
## Project Structure
|
| 116 |
+
|
| 117 |
+
```
|
| 118 |
+
chatcal-ai/
|
| 119 |
+
βββ app/
|
| 120 |
+
β βββ core/ # Agent, LLM (Groq), tools, and email service
|
| 121 |
+
β βββ calendar/ # Google Calendar integration
|
| 122 |
+
β βββ api/ # FastAPI endpoints (simple-chat, chat-widget)
|
| 123 |
+
β βββ personality/ # System prompts and response templates
|
| 124 |
+
βββ frontend/ # Web UI with HTML response rendering
|
| 125 |
+
βββ docker/ # Docker configurations
|
| 126 |
+
βββ tests/ # Test suite
|
| 127 |
+
βββ credentials/ # Google credentials (gitignored)
|
| 128 |
+
βββ docker-compose.yml
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
## API Endpoints
|
| 132 |
+
|
| 133 |
+
- `POST /chat` - Send a message to the chatbot (with HTML response support)
|
| 134 |
+
- `GET /simple-chat` - Simple chat interface for testing
|
| 135 |
+
- `GET /chat-widget` - Embeddable chat widget with HTML rendering
|
| 136 |
+
- `GET /health` - Health check endpoint
|
| 137 |
+
- `POST /sessions` - Create new session
|
| 138 |
+
- `GET /auth/login` - Initiate Google OAuth flow
|
| 139 |
+
- `GET /auth/callback` - OAuth callback handler
|
| 140 |
+
|
| 141 |
+
## Deployment
|
| 142 |
+
|
| 143 |
+
### Deploy to any platform with Docker:
|
| 144 |
+
|
| 145 |
+
1. Build the production image:
|
| 146 |
+
```bash
|
| 147 |
+
docker build -t chatcal-ai:latest .
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
2. Push to your container registry:
|
| 151 |
+
```bash
|
| 152 |
+
docker tag chatcal-ai:latest your-registry/chatcal-ai:latest
|
| 153 |
+
docker push your-registry/chatcal-ai:latest
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
3. Deploy using your platform's container service (ECS, GKE, Azure Container Instances, etc.)
|
| 157 |
+
|
| 158 |
+
### Embed in landing pages:
|
| 159 |
+
|
| 160 |
+
Add the chat widget to any webpage:
|
| 161 |
+
```html
|
| 162 |
+
<iframe
|
| 163 |
+
src="https://your-deployment.com/chat-widget"
|
| 164 |
+
width="400"
|
| 165 |
+
height="600"
|
| 166 |
+
frameborder="0">
|
| 167 |
+
</iframe>
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
## Key Features
|
| 171 |
+
|
| 172 |
+
### Conversation Memory
|
| 173 |
+
- Persistent conversation context using ChatMemoryBuffer
|
| 174 |
+
- Multi-turn dialog support for complex booking flows
|
| 175 |
+
- User information extraction and retention
|
| 176 |
+
|
| 177 |
+
### Smart Meeting Management
|
| 178 |
+
- **Custom Meeting IDs**: Format MMDD-HHMM-DURm (e.g., 0731-1400-60m)
|
| 179 |
+
- **Intelligent Cancellation**: Matches meetings by user name and date/time
|
| 180 |
+
- **Automatic Email Notifications**: Booking confirmations and cancellation notices
|
| 181 |
+
|
| 182 |
+
### HTML Response Formatting
|
| 183 |
+
- Rich, styled responses with proper formatting
|
| 184 |
+
- Color-coded status messages (confirmations, cancellations, errors)
|
| 185 |
+
- Easy-to-read meeting details and contact information
|
| 186 |
+
|
| 187 |
+
### Google Meet Integration
|
| 188 |
+
- Automatic detection of video meeting requests
|
| 189 |
+
- Google Meet link generation and embedding
|
| 190 |
+
- Support for both in-person and remote meetings
|
| 191 |
+
|
| 192 |
+
## Configuration
|
| 193 |
+
|
| 194 |
+
See `app/config.py` for all available settings. Key configurations:
|
| 195 |
+
|
| 196 |
+
- `MAX_CONVERSATION_HISTORY`: Number of messages to maintain in context
|
| 197 |
+
- `SESSION_TIMEOUT_MINUTES`: Session expiration time
|
| 198 |
+
- `DEFAULT_TIMEZONE`: Default timezone for appointments
|
| 199 |
+
- `TESTING_MODE`: Enable/disable testing features
|
| 200 |
+
|
| 201 |
+
## Testing
|
| 202 |
+
|
| 203 |
+
```bash
|
| 204 |
+
# Run tests
|
| 205 |
+
poetry run pytest
|
| 206 |
+
|
| 207 |
+
# Run with coverage
|
| 208 |
+
poetry run pytest --cov=app
|
| 209 |
+
|
| 210 |
+
# Run linting
|
| 211 |
+
poetry run black .
|
| 212 |
+
poetry run flake8
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
## Security Considerations
|
| 216 |
+
|
| 217 |
+
- Always use HTTPS in production
|
| 218 |
+
- Keep your API keys secure and never commit them
|
| 219 |
+
- Implement rate limiting for production deployments
|
| 220 |
+
- Use environment-specific configurations
|
| 221 |
+
- Regular security audits of dependencies
|
| 222 |
+
|
| 223 |
+
## Contributing
|
| 224 |
+
|
| 225 |
+
1. Fork the repository
|
| 226 |
+
2. Create a feature branch
|
| 227 |
+
3. Make your changes
|
| 228 |
+
4. Run tests and linting
|
| 229 |
+
5. Submit a pull request
|
| 230 |
+
|
| 231 |
+
## License
|
| 232 |
+
|
| 233 |
+
[Your chosen license]
|
| 234 |
+
|
| 235 |
+
## Support
|
| 236 |
+
|
| 237 |
+
For issues or questions, please open an issue on GitHub or contact pgits.job@gmail.com
|
app.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HuggingFace Spaces entry point for ChatCal.ai
|
| 3 |
+
Minimal changes - uses JWT sessions instead of Redis
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
import uvicorn
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
# Add the current directory to Python path for imports
|
| 11 |
+
sys.path.insert(0, '/app')
|
| 12 |
+
|
| 13 |
+
# Set HF-specific environment variables
|
| 14 |
+
os.environ.setdefault("SESSION_BACKEND", "jwt")
|
| 15 |
+
os.environ.setdefault("APP_ENV", "production")
|
| 16 |
+
os.environ.setdefault("APP_PORT", "7860") # HF Spaces default port
|
| 17 |
+
|
| 18 |
+
# Import after setting up the path
|
| 19 |
+
from app.api.main import app
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
import logging
|
| 23 |
+
|
| 24 |
+
# Configure logging
|
| 25 |
+
logging.basicConfig(
|
| 26 |
+
level=logging.INFO,
|
| 27 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 28 |
+
handlers=[
|
| 29 |
+
logging.FileHandler('/tmp/app.log'),
|
| 30 |
+
logging.StreamHandler()
|
| 31 |
+
]
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
port = int(os.environ.get("PORT", os.environ.get("APP_PORT", 7860)))
|
| 35 |
+
print(f"===== Application Startup at {datetime.now()} =====")
|
| 36 |
+
print(f"π Starting ChatCal.ai on port {port}")
|
| 37 |
+
|
| 38 |
+
uvicorn.run(
|
| 39 |
+
app,
|
| 40 |
+
host="0.0.0.0",
|
| 41 |
+
port=port,
|
| 42 |
+
log_level="info"
|
| 43 |
+
)
|
app/__init__.py
ADDED
|
File without changes
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/chat_widget.py
ADDED
|
@@ -0,0 +1,1401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat widget HTML interface for ChatCal.ai."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request
|
| 4 |
+
from fastapi.responses import HTMLResponse
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.get("/chat-widget", response_class=HTMLResponse)
|
| 10 |
+
async def chat_widget(request: Request):
|
| 11 |
+
"""Embeddable chat widget."""
|
| 12 |
+
base_url = f"{request.url.scheme}://{request.url.netloc}"
|
| 13 |
+
|
| 14 |
+
return f"""
|
| 15 |
+
<!DOCTYPE html>
|
| 16 |
+
<html lang="en">
|
| 17 |
+
<head>
|
| 18 |
+
<meta charset="UTF-8">
|
| 19 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 20 |
+
<title>ChatCal.ai - Calendar Assistant</title>
|
| 21 |
+
<style>
|
| 22 |
+
* {{
|
| 23 |
+
margin: 0;
|
| 24 |
+
padding: 0;
|
| 25 |
+
box-sizing: border-box;
|
| 26 |
+
}}
|
| 27 |
+
|
| 28 |
+
body {{
|
| 29 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 30 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 31 |
+
min-height: 100vh;
|
| 32 |
+
display: flex;
|
| 33 |
+
align-items: center;
|
| 34 |
+
justify-content: center;
|
| 35 |
+
padding: 20px;
|
| 36 |
+
}}
|
| 37 |
+
|
| 38 |
+
.chat-container {{
|
| 39 |
+
background: white;
|
| 40 |
+
border-radius: 20px;
|
| 41 |
+
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
| 42 |
+
width: 100%;
|
| 43 |
+
max-width: 800px;
|
| 44 |
+
height: 600px;
|
| 45 |
+
display: flex;
|
| 46 |
+
flex-direction: column;
|
| 47 |
+
overflow: hidden;
|
| 48 |
+
}}
|
| 49 |
+
|
| 50 |
+
.chat-header {{
|
| 51 |
+
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
| 52 |
+
color: white;
|
| 53 |
+
padding: 20px;
|
| 54 |
+
text-align: center;
|
| 55 |
+
position: relative;
|
| 56 |
+
}}
|
| 57 |
+
|
| 58 |
+
.chat-header h1 {{
|
| 59 |
+
font-size: 24px;
|
| 60 |
+
margin-bottom: 5px;
|
| 61 |
+
}}
|
| 62 |
+
|
| 63 |
+
.chat-header p {{
|
| 64 |
+
opacity: 0.9;
|
| 65 |
+
font-size: 14px;
|
| 66 |
+
}}
|
| 67 |
+
|
| 68 |
+
.status-indicator {{
|
| 69 |
+
position: absolute;
|
| 70 |
+
top: 20px;
|
| 71 |
+
right: 20px;
|
| 72 |
+
width: 12px;
|
| 73 |
+
height: 12px;
|
| 74 |
+
background: #4CAF50;
|
| 75 |
+
border-radius: 50%;
|
| 76 |
+
border: 2px solid white;
|
| 77 |
+
animation: pulse 2s infinite;
|
| 78 |
+
}}
|
| 79 |
+
|
| 80 |
+
@keyframes pulse {{
|
| 81 |
+
0% {{ opacity: 1; }}
|
| 82 |
+
50% {{ opacity: 0.5; }}
|
| 83 |
+
100% {{ opacity: 1; }}
|
| 84 |
+
}}
|
| 85 |
+
|
| 86 |
+
.chat-messages {{
|
| 87 |
+
flex: 1;
|
| 88 |
+
padding: 20px;
|
| 89 |
+
overflow-y: auto;
|
| 90 |
+
background: #f8f9fa;
|
| 91 |
+
}}
|
| 92 |
+
|
| 93 |
+
.message {{
|
| 94 |
+
margin-bottom: 15px;
|
| 95 |
+
display: flex;
|
| 96 |
+
align-items: flex-start;
|
| 97 |
+
gap: 10px;
|
| 98 |
+
}}
|
| 99 |
+
|
| 100 |
+
.message.user {{
|
| 101 |
+
flex-direction: row-reverse;
|
| 102 |
+
}}
|
| 103 |
+
|
| 104 |
+
.message-avatar {{
|
| 105 |
+
width: 36px;
|
| 106 |
+
height: 36px;
|
| 107 |
+
border-radius: 50%;
|
| 108 |
+
display: flex;
|
| 109 |
+
align-items: center;
|
| 110 |
+
justify-content: center;
|
| 111 |
+
font-size: 16px;
|
| 112 |
+
flex-shrink: 0;
|
| 113 |
+
}}
|
| 114 |
+
|
| 115 |
+
.message.user .message-avatar {{
|
| 116 |
+
background: #2196F3;
|
| 117 |
+
color: white;
|
| 118 |
+
}}
|
| 119 |
+
|
| 120 |
+
.message.assistant .message-avatar {{
|
| 121 |
+
background: #4CAF50;
|
| 122 |
+
color: white;
|
| 123 |
+
}}
|
| 124 |
+
|
| 125 |
+
.message-content {{
|
| 126 |
+
max-width: 70%;
|
| 127 |
+
padding: 12px 16px;
|
| 128 |
+
border-radius: 18px;
|
| 129 |
+
line-height: 1.4;
|
| 130 |
+
white-space: pre-wrap;
|
| 131 |
+
}}
|
| 132 |
+
|
| 133 |
+
.message.user .message-content {{
|
| 134 |
+
background: #2196F3;
|
| 135 |
+
color: white;
|
| 136 |
+
border-bottom-right-radius: 4px;
|
| 137 |
+
}}
|
| 138 |
+
|
| 139 |
+
.message.assistant .message-content {{
|
| 140 |
+
background: white;
|
| 141 |
+
color: #333;
|
| 142 |
+
border: 1px solid #e0e0e0;
|
| 143 |
+
border-bottom-left-radius: 4px;
|
| 144 |
+
}}
|
| 145 |
+
|
| 146 |
+
.chat-input {{
|
| 147 |
+
padding: 20px;
|
| 148 |
+
background: white;
|
| 149 |
+
border-top: 1px solid #e0e0e0;
|
| 150 |
+
display: flex;
|
| 151 |
+
gap: 10px;
|
| 152 |
+
align-items: center;
|
| 153 |
+
}}
|
| 154 |
+
|
| 155 |
+
.chat-input textarea {{
|
| 156 |
+
flex: 1;
|
| 157 |
+
padding: 12px 16px;
|
| 158 |
+
border: 2px solid #e0e0e0;
|
| 159 |
+
border-radius: 20px;
|
| 160 |
+
outline: none;
|
| 161 |
+
font-size: 14px;
|
| 162 |
+
font-family: inherit;
|
| 163 |
+
resize: none;
|
| 164 |
+
min-height: 20px;
|
| 165 |
+
max-height: 120px;
|
| 166 |
+
overflow-y: auto;
|
| 167 |
+
line-height: 1.4;
|
| 168 |
+
transition: border-color 0.3s;
|
| 169 |
+
}}
|
| 170 |
+
|
| 171 |
+
.chat-input textarea:focus {{
|
| 172 |
+
border-color: #4CAF50;
|
| 173 |
+
}}
|
| 174 |
+
|
| 175 |
+
.chat-input button {{
|
| 176 |
+
width: 40px;
|
| 177 |
+
height: 40px;
|
| 178 |
+
border: none;
|
| 179 |
+
background: #4CAF50;
|
| 180 |
+
color: white;
|
| 181 |
+
border-radius: 50%;
|
| 182 |
+
cursor: pointer;
|
| 183 |
+
display: flex;
|
| 184 |
+
align-items: center;
|
| 185 |
+
justify-content: center;
|
| 186 |
+
transition: background-color 0.3s;
|
| 187 |
+
}}
|
| 188 |
+
|
| 189 |
+
.chat-input button:hover {{
|
| 190 |
+
background: #45a049;
|
| 191 |
+
}}
|
| 192 |
+
|
| 193 |
+
.chat-input button:disabled {{
|
| 194 |
+
background: #ccc;
|
| 195 |
+
cursor: not-allowed;
|
| 196 |
+
}}
|
| 197 |
+
|
| 198 |
+
.typing-indicator {{
|
| 199 |
+
display: none;
|
| 200 |
+
padding: 12px 16px;
|
| 201 |
+
background: white;
|
| 202 |
+
border: 1px solid #e0e0e0;
|
| 203 |
+
border-radius: 18px;
|
| 204 |
+
border-bottom-left-radius: 4px;
|
| 205 |
+
max-width: 70%;
|
| 206 |
+
margin-bottom: 15px;
|
| 207 |
+
}}
|
| 208 |
+
|
| 209 |
+
.typing-dots {{
|
| 210 |
+
display: flex;
|
| 211 |
+
gap: 4px;
|
| 212 |
+
}}
|
| 213 |
+
|
| 214 |
+
.typing-dots span {{
|
| 215 |
+
width: 8px;
|
| 216 |
+
height: 8px;
|
| 217 |
+
background: #999;
|
| 218 |
+
border-radius: 50%;
|
| 219 |
+
animation: typing 1.4s infinite;
|
| 220 |
+
}}
|
| 221 |
+
|
| 222 |
+
.typing-dots span:nth-child(2) {{
|
| 223 |
+
animation-delay: 0.2s;
|
| 224 |
+
}}
|
| 225 |
+
|
| 226 |
+
.typing-dots span:nth-child(3) {{
|
| 227 |
+
animation-delay: 0.4s;
|
| 228 |
+
}}
|
| 229 |
+
|
| 230 |
+
@keyframes typing {{
|
| 231 |
+
0%, 60%, 100% {{
|
| 232 |
+
transform: translateY(0);
|
| 233 |
+
opacity: 0.4;
|
| 234 |
+
}}
|
| 235 |
+
30% {{
|
| 236 |
+
transform: translateY(-10px);
|
| 237 |
+
opacity: 1;
|
| 238 |
+
}}
|
| 239 |
+
}}
|
| 240 |
+
|
| 241 |
+
.welcome-message {{
|
| 242 |
+
text-align: center;
|
| 243 |
+
color: #666;
|
| 244 |
+
font-style: italic;
|
| 245 |
+
margin: 20px 0;
|
| 246 |
+
}}
|
| 247 |
+
|
| 248 |
+
.quick-actions {{
|
| 249 |
+
display: flex;
|
| 250 |
+
gap: 10px;
|
| 251 |
+
margin: 10px 0;
|
| 252 |
+
flex-wrap: wrap;
|
| 253 |
+
}}
|
| 254 |
+
|
| 255 |
+
.quick-action {{
|
| 256 |
+
background: #f0f0f0;
|
| 257 |
+
color: #555;
|
| 258 |
+
padding: 8px 12px;
|
| 259 |
+
border-radius: 15px;
|
| 260 |
+
border: none;
|
| 261 |
+
cursor: pointer;
|
| 262 |
+
font-size: 12px;
|
| 263 |
+
transition: background-color 0.3s;
|
| 264 |
+
}}
|
| 265 |
+
|
| 266 |
+
.quick-action:hover {{
|
| 267 |
+
background: #e0e0e0;
|
| 268 |
+
}}
|
| 269 |
+
|
| 270 |
+
/* STT Quick Action Animation */
|
| 271 |
+
.quick-action.listening {{
|
| 272 |
+
animation: stt-recording-pulse 1.5s infinite;
|
| 273 |
+
}}
|
| 274 |
+
|
| 275 |
+
@keyframes stt-recording-pulse {{
|
| 276 |
+
0% {{
|
| 277 |
+
transform: scale(1);
|
| 278 |
+
box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4);
|
| 279 |
+
}}
|
| 280 |
+
50% {{
|
| 281 |
+
transform: scale(1.05);
|
| 282 |
+
box-shadow: 0 0 0 8px rgba(244, 67, 54, 0.1);
|
| 283 |
+
}}
|
| 284 |
+
100% {{
|
| 285 |
+
transform: scale(1);
|
| 286 |
+
box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4);
|
| 287 |
+
}}
|
| 288 |
+
}}
|
| 289 |
+
</style>
|
| 290 |
+
</head>
|
| 291 |
+
<body>
|
| 292 |
+
<div class="chat-container">
|
| 293 |
+
<div class="chat-header">
|
| 294 |
+
<div class="status-indicator"></div>
|
| 295 |
+
<h1>π ChatCal.ai</h1>
|
| 296 |
+
<p>Your friendly AI calendar assistant</p>
|
| 297 |
+
</div>
|
| 298 |
+
|
| 299 |
+
<div class="chat-messages" id="chatMessages">
|
| 300 |
+
<div class="welcome-message">
|
| 301 |
+
π Welcome! I'm ChatCal, Peter Michael Gits' scheduling assistant.<br>
|
| 302 |
+
I can schedule business consultations, project meetings, and advisory sessions.<br>
|
| 303 |
+
<strong>π To book:</strong> <strong>name</strong>, <strong>topic</strong>, <strong>day</strong>, <strong>time</strong>, <strong>length</strong>, <strong>type</strong>: GoogleMeet (requires your <strong>email</strong>), or call (requires your <strong>phone #</strong>)<br>
|
| 304 |
+
<em>(Otherwise I will ask for it and may get the email address wrong unless you spell it out military style.)</em>
|
| 305 |
+
</div>
|
| 306 |
+
|
| 307 |
+
<div class="quick-actions">
|
| 308 |
+
<button id="sttIndicator" class="quick-action muted" title="Click to start/stop voice input">ποΈ Voice Input</button>
|
| 309 |
+
<button class="quick-action" onclick="sendQuickMessage('Schedule a Google Meet with Peter')">π₯ Google Meet</button>
|
| 310 |
+
<button class="quick-action" onclick="sendQuickMessage('Check Peter\\'s availability tomorrow')">π
Check availability</button>
|
| 311 |
+
<button class="quick-action" onclick="sendQuickMessage('Schedule an in-person meeting')">π€ In-person meeting</button>
|
| 312 |
+
<button class="quick-action" onclick="sendQuickMessage('/help')">β Help</button>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
<div class="typing-indicator" id="typingIndicator">
|
| 317 |
+
<div class="typing-dots">
|
| 318 |
+
<span></span>
|
| 319 |
+
<span></span>
|
| 320 |
+
<span></span>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
|
| 324 |
+
<div class="chat-input">
|
| 325 |
+
<textarea
|
| 326 |
+
id="messageInput"
|
| 327 |
+
placeholder="Type your message..."
|
| 328 |
+
maxlength="1000"
|
| 329 |
+
rows="1"
|
| 330 |
+
></textarea>
|
| 331 |
+
<button id="sendButton" type="button">
|
| 332 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
| 333 |
+
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
| 334 |
+
</svg>
|
| 335 |
+
</button>
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
+
<!-- Version Footer -->
|
| 339 |
+
<div style="text-align: center; margin-top: 10px; padding: 5px; color: #999; font-size: 10px; border-top: 1px solid #f0f0f0;">
|
| 340 |
+
ChatCal.ai v0.3.0 | β‘ Streaming + Interruption | π§ Smart Email Verification
|
| 341 |
+
</div>
|
| 342 |
+
</div>
|
| 343 |
+
|
| 344 |
+
<!-- Hidden audio element for TTS playback -->
|
| 345 |
+
<audio id="ttsAudioElement" style="display: none;"></audio>
|
| 346 |
+
|
| 347 |
+
<script>
|
| 348 |
+
let sessionId = null;
|
| 349 |
+
let isLoading = false;
|
| 350 |
+
|
| 351 |
+
const chatMessages = document.getElementById('chatMessages');
|
| 352 |
+
const messageInput = document.getElementById('messageInput');
|
| 353 |
+
const sendButton = document.getElementById('sendButton');
|
| 354 |
+
const typingIndicator = document.getElementById('typingIndicator');
|
| 355 |
+
const sttIndicator = document.getElementById('sttIndicator');
|
| 356 |
+
|
| 357 |
+
// Shared Audio Variables (for both TTS and STT)
|
| 358 |
+
let globalAudioContext = null;
|
| 359 |
+
let globalMediaStream = null;
|
| 360 |
+
let isAudioInitialized = false;
|
| 361 |
+
|
| 362 |
+
// STT v2 Variables
|
| 363 |
+
let sttv2Manager = null;
|
| 364 |
+
let silenceTimer = null;
|
| 365 |
+
let lastSpeechTime = 0;
|
| 366 |
+
let hasReceivedSpeech = false;
|
| 367 |
+
|
| 368 |
+
// TTS Integration - ChatCal WebRTC TTS Class
|
| 369 |
+
class ChatCalTTS {{
|
| 370 |
+
constructor() {{
|
| 371 |
+
this.audioContext = null;
|
| 372 |
+
this.audioElement = document.getElementById('ttsAudioElement');
|
| 373 |
+
this.webrtcEnabled = false;
|
| 374 |
+
this.initializeTTS();
|
| 375 |
+
}}
|
| 376 |
+
|
| 377 |
+
async initializeTTS() {{
|
| 378 |
+
try {{
|
| 379 |
+
console.log('π€ Initializing WebRTC TTS for ChatCal...');
|
| 380 |
+
|
| 381 |
+
// Request microphone access to enable WebRTC autoplay policies
|
| 382 |
+
const stream = await navigator.mediaDevices.getUserMedia({{ audio: true }});
|
| 383 |
+
|
| 384 |
+
// Create AudioContext
|
| 385 |
+
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| 386 |
+
|
| 387 |
+
// Resume if suspended
|
| 388 |
+
if (this.audioContext.state === 'suspended') {{
|
| 389 |
+
await this.audioContext.resume();
|
| 390 |
+
}}
|
| 391 |
+
|
| 392 |
+
// Stop microphone (we don't need to record)
|
| 393 |
+
stream.getTracks().forEach(track => track.stop());
|
| 394 |
+
|
| 395 |
+
this.webrtcEnabled = true;
|
| 396 |
+
console.log('β
WebRTC TTS enabled for ChatCal');
|
| 397 |
+
|
| 398 |
+
}} catch (error) {{
|
| 399 |
+
console.warn('β οΈ TTS initialization failed, continuing without TTS:', error);
|
| 400 |
+
this.webrtcEnabled = false;
|
| 401 |
+
}}
|
| 402 |
+
}}
|
| 403 |
+
|
| 404 |
+
async synthesizeAndPlay(text) {{
|
| 405 |
+
if (!this.webrtcEnabled || !text.trim()) {{
|
| 406 |
+
return;
|
| 407 |
+
}}
|
| 408 |
+
|
| 409 |
+
try {{
|
| 410 |
+
console.log('π΅ Synthesizing TTS for:', text.substring(0, 50) + '...');
|
| 411 |
+
|
| 412 |
+
// Call TTS proxy server
|
| 413 |
+
const response = await fetch('http://localhost:8081/api/synthesize', {{
|
| 414 |
+
method: 'POST',
|
| 415 |
+
headers: {{
|
| 416 |
+
'Content-Type': 'application/json',
|
| 417 |
+
}},
|
| 418 |
+
body: JSON.stringify({{
|
| 419 |
+
text: text,
|
| 420 |
+
voice: 'expresso/ex03-ex01_happy_001_channel1_334s.wav'
|
| 421 |
+
}})
|
| 422 |
+
}});
|
| 423 |
+
|
| 424 |
+
if (!response.ok) {{
|
| 425 |
+
throw new Error('TTS service unavailable');
|
| 426 |
+
}}
|
| 427 |
+
|
| 428 |
+
const data = await response.json();
|
| 429 |
+
if (!data.success || !data.audio_url) {{
|
| 430 |
+
throw new Error('TTS generation failed');
|
| 431 |
+
}}
|
| 432 |
+
|
| 433 |
+
// Fetch the audio file
|
| 434 |
+
const audioResponse = await fetch(`http://localhost:8081${{data.audio_url}}`);
|
| 435 |
+
if (!audioResponse.ok) {{
|
| 436 |
+
throw new Error('Audio file not found');
|
| 437 |
+
}}
|
| 438 |
+
|
| 439 |
+
const audioArrayBuffer = await audioResponse.arrayBuffer();
|
| 440 |
+
const audioBuffer = await this.audioContext.decodeAudioData(audioArrayBuffer);
|
| 441 |
+
|
| 442 |
+
// Play via WebRTC MediaStream
|
| 443 |
+
await this.playAudioWithWebRTC(audioBuffer);
|
| 444 |
+
|
| 445 |
+
}} catch (error) {{
|
| 446 |
+
console.warn('π TTS failed silently:', error);
|
| 447 |
+
// Fail silently as requested - no error handling UI
|
| 448 |
+
}}
|
| 449 |
+
}}
|
| 450 |
+
|
| 451 |
+
async playAudioWithWebRTC(audioBuffer) {{
|
| 452 |
+
try {{
|
| 453 |
+
console.log('π Playing TTS audio via WebRTC...');
|
| 454 |
+
|
| 455 |
+
// Create MediaStream from audio buffer
|
| 456 |
+
const source = this.audioContext.createBufferSource();
|
| 457 |
+
const destination = this.audioContext.createMediaStreamDestination();
|
| 458 |
+
|
| 459 |
+
source.buffer = audioBuffer;
|
| 460 |
+
source.connect(destination);
|
| 461 |
+
|
| 462 |
+
// Set the MediaStream to audio element
|
| 463 |
+
this.audioElement.srcObject = destination.stream;
|
| 464 |
+
|
| 465 |
+
// Start the audio source
|
| 466 |
+
source.start(0);
|
| 467 |
+
|
| 468 |
+
// Play - should work due to WebRTC permissions
|
| 469 |
+
await this.audioElement.play();
|
| 470 |
+
console.log('π΅ TTS audio playing successfully');
|
| 471 |
+
|
| 472 |
+
}} catch (error) {{
|
| 473 |
+
console.warn('π WebRTC audio playback failed:', error);
|
| 474 |
+
}}
|
| 475 |
+
}}
|
| 476 |
+
}}
|
| 477 |
+
|
| 478 |
+
// Initialize TTS system
|
| 479 |
+
const chatCalTTS = new ChatCalTTS();
|
| 480 |
+
|
| 481 |
+
// Shared Audio Initialization (for TTS only now, STT v2 handles its own audio)
|
| 482 |
+
async function initializeSharedAudio() {{
|
| 483 |
+
if (isAudioInitialized) return;
|
| 484 |
+
|
| 485 |
+
console.log('π€ Initializing shared audio for TTS...');
|
| 486 |
+
|
| 487 |
+
// Basic audio context for TTS
|
| 488 |
+
globalAudioContext = new AudioContext();
|
| 489 |
+
|
| 490 |
+
if (globalAudioContext.state === 'suspended') {{
|
| 491 |
+
await globalAudioContext.resume();
|
| 492 |
+
}}
|
| 493 |
+
|
| 494 |
+
isAudioInitialized = true;
|
| 495 |
+
console.log('β
Shared audio initialized for TTS');
|
| 496 |
+
}}
|
| 497 |
+
|
| 498 |
+
// STT Visual State Management
|
| 499 |
+
function updateSTTVisualState(state) {{
|
| 500 |
+
const sttIndicator = document.getElementById('sttIndicator');
|
| 501 |
+
if (!sttIndicator) return;
|
| 502 |
+
|
| 503 |
+
// Remove all status classes first
|
| 504 |
+
sttIndicator.classList.remove('muted', 'listening');
|
| 505 |
+
|
| 506 |
+
switch (state) {{
|
| 507 |
+
case 'ready':
|
| 508 |
+
sttIndicator.innerHTML = 'ποΈ Start Recording';
|
| 509 |
+
sttIndicator.title = 'Click to start voice recording';
|
| 510 |
+
sttIndicator.classList.add('muted');
|
| 511 |
+
sttIndicator.style.background = '#f0f0f0';
|
| 512 |
+
sttIndicator.style.color = '#555';
|
| 513 |
+
break;
|
| 514 |
+
case 'connecting':
|
| 515 |
+
sttIndicator.innerHTML = 'π Connecting...';
|
| 516 |
+
sttIndicator.title = 'Connecting to voice service...';
|
| 517 |
+
sttIndicator.style.background = '#fff3cd';
|
| 518 |
+
sttIndicator.style.color = '#856404';
|
| 519 |
+
break;
|
| 520 |
+
case 'recording':
|
| 521 |
+
sttIndicator.innerHTML = 'βΉοΈ Stop & Send';
|
| 522 |
+
sttIndicator.title = 'Click to stop recording and transcribe';
|
| 523 |
+
sttIndicator.classList.add('listening');
|
| 524 |
+
sttIndicator.style.background = '#ffebee';
|
| 525 |
+
sttIndicator.style.color = '#d32f2f';
|
| 526 |
+
sttIndicator.style.fontWeight = 'bold';
|
| 527 |
+
break;
|
| 528 |
+
case 'processing':
|
| 529 |
+
sttIndicator.innerHTML = 'β‘ Transcribing...';
|
| 530 |
+
sttIndicator.title = 'Processing your speech...';
|
| 531 |
+
sttIndicator.style.background = '#d1ecf1';
|
| 532 |
+
sttIndicator.style.color = '#0c5460';
|
| 533 |
+
sttIndicator.style.fontWeight = 'normal';
|
| 534 |
+
break;
|
| 535 |
+
case 'error':
|
| 536 |
+
sttIndicator.innerHTML = 'β Try Again';
|
| 537 |
+
sttIndicator.title = 'Click to retry voice recording';
|
| 538 |
+
sttIndicator.style.background = '#f8d7da';
|
| 539 |
+
sttIndicator.style.color = '#721c24';
|
| 540 |
+
sttIndicator.style.fontWeight = 'normal';
|
| 541 |
+
break;
|
| 542 |
+
}}
|
| 543 |
+
}}
|
| 544 |
+
|
| 545 |
+
// STT v2 Manager Class (adapted from stt-gpu-service-v2/client-stt/v2-audio-client.js)
|
| 546 |
+
class STTv2Manager {{
|
| 547 |
+
constructor() {{
|
| 548 |
+
this.isRecording = false;
|
| 549 |
+
this.mediaRecorder = null;
|
| 550 |
+
this.audioChunks = [];
|
| 551 |
+
this.serverUrl = 'https://pgits-stt-gpu-service-v2.hf.space';
|
| 552 |
+
this.language = 'en';
|
| 553 |
+
this.modelSize = 'base';
|
| 554 |
+
this.recordingTimer = null;
|
| 555 |
+
this.maxRecordingTime = 30000; // 30 seconds max
|
| 556 |
+
|
| 557 |
+
console.log('π€ STT v2 Manager initialized');
|
| 558 |
+
}}
|
| 559 |
+
|
| 560 |
+
generateSessionHash() {{
|
| 561 |
+
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
| 562 |
+
}}
|
| 563 |
+
|
| 564 |
+
async toggleRecording() {{
|
| 565 |
+
if (!this.isRecording) {{
|
| 566 |
+
await this.startRecording();
|
| 567 |
+
}} else {{
|
| 568 |
+
await this.stopRecording();
|
| 569 |
+
}}
|
| 570 |
+
}}
|
| 571 |
+
|
| 572 |
+
async startRecording() {{
|
| 573 |
+
try {{
|
| 574 |
+
console.log('π€ Starting STT v2 recording...');
|
| 575 |
+
updateSTTVisualState('connecting');
|
| 576 |
+
|
| 577 |
+
const stream = await navigator.mediaDevices.getUserMedia({{
|
| 578 |
+
audio: {{
|
| 579 |
+
sampleRate: 44100,
|
| 580 |
+
channelCount: 1,
|
| 581 |
+
echoCancellation: true,
|
| 582 |
+
noiseSuppression: true,
|
| 583 |
+
autoGainControl: true
|
| 584 |
+
}}
|
| 585 |
+
}});
|
| 586 |
+
|
| 587 |
+
this.mediaRecorder = new MediaRecorder(stream, {{
|
| 588 |
+
mimeType: 'audio/webm;codecs=opus'
|
| 589 |
+
}});
|
| 590 |
+
|
| 591 |
+
this.audioChunks = [];
|
| 592 |
+
|
| 593 |
+
this.mediaRecorder.ondataavailable = (event) => {{
|
| 594 |
+
if (event.data.size > 0) {{
|
| 595 |
+
this.audioChunks.push(event.data);
|
| 596 |
+
}}
|
| 597 |
+
}};
|
| 598 |
+
|
| 599 |
+
this.mediaRecorder.onstop = () => {{
|
| 600 |
+
this.processRecording();
|
| 601 |
+
}};
|
| 602 |
+
|
| 603 |
+
this.mediaRecorder.start();
|
| 604 |
+
this.isRecording = true;
|
| 605 |
+
updateSTTVisualState('recording');
|
| 606 |
+
sttIndicator.classList.remove('muted');
|
| 607 |
+
sttIndicator.classList.add('listening');
|
| 608 |
+
|
| 609 |
+
// Auto-stop after max recording time
|
| 610 |
+
this.recordingTimer = setTimeout(() => {{
|
| 611 |
+
if (this.isRecording) {{
|
| 612 |
+
console.log('β° Auto-stopping recording after 30 seconds');
|
| 613 |
+
this.stopRecording();
|
| 614 |
+
}}
|
| 615 |
+
}}, this.maxRecordingTime);
|
| 616 |
+
|
| 617 |
+
console.log('β
STT v2 recording started (auto-stop in 30s)');
|
| 618 |
+
|
| 619 |
+
}} catch (error) {{
|
| 620 |
+
console.error('β STT v2 recording failed:', error);
|
| 621 |
+
updateSTTVisualState('error');
|
| 622 |
+
setTimeout(() => updateSTTVisualState('ready'), 3000);
|
| 623 |
+
}}
|
| 624 |
+
}}
|
| 625 |
+
|
| 626 |
+
async stopRecording() {{
|
| 627 |
+
if (this.mediaRecorder && this.isRecording) {{
|
| 628 |
+
console.log('π Stopping STT v2 recording...');
|
| 629 |
+
|
| 630 |
+
// Clear the auto-stop timer
|
| 631 |
+
if (this.recordingTimer) {{
|
| 632 |
+
clearTimeout(this.recordingTimer);
|
| 633 |
+
this.recordingTimer = null;
|
| 634 |
+
}}
|
| 635 |
+
|
| 636 |
+
this.mediaRecorder.stop();
|
| 637 |
+
this.isRecording = false;
|
| 638 |
+
updateSTTVisualState('processing');
|
| 639 |
+
sttIndicator.classList.remove('listening');
|
| 640 |
+
sttIndicator.classList.add('muted');
|
| 641 |
+
|
| 642 |
+
// Stop all tracks
|
| 643 |
+
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
| 644 |
+
console.log('β
STT v2 recording stopped');
|
| 645 |
+
}}
|
| 646 |
+
}}
|
| 647 |
+
|
| 648 |
+
async processRecording() {{
|
| 649 |
+
if (this.audioChunks.length === 0) {{
|
| 650 |
+
updateSTTVisualState('error');
|
| 651 |
+
setTimeout(() => updateSTTVisualState('ready'), 3000);
|
| 652 |
+
console.warn('β οΈ No audio recorded');
|
| 653 |
+
return;
|
| 654 |
+
}}
|
| 655 |
+
|
| 656 |
+
try {{
|
| 657 |
+
console.log('π Processing STT v2 recording...');
|
| 658 |
+
|
| 659 |
+
// Create blob from chunks
|
| 660 |
+
const audioBlob = new Blob(this.audioChunks, {{ type: 'audio/webm;codecs=opus' }});
|
| 661 |
+
console.log(`π¦ Audio blob created: ${{audioBlob.size}} bytes`);
|
| 662 |
+
|
| 663 |
+
// Convert to base64
|
| 664 |
+
const audioBase64 = await this.blobToBase64(audioBlob);
|
| 665 |
+
console.log(`π Base64 length: ${{audioBase64.length}} characters`);
|
| 666 |
+
|
| 667 |
+
// Send to transcription service
|
| 668 |
+
await this.transcribeAudio(audioBase64);
|
| 669 |
+
|
| 670 |
+
}} catch (error) {{
|
| 671 |
+
console.error('β STT v2 processing failed:', error);
|
| 672 |
+
updateSTTVisualState('error');
|
| 673 |
+
setTimeout(() => updateSTTVisualState('ready'), 3000);
|
| 674 |
+
}}
|
| 675 |
+
}}
|
| 676 |
+
|
| 677 |
+
async blobToBase64(blob) {{
|
| 678 |
+
return new Promise((resolve, reject) => {{
|
| 679 |
+
const reader = new FileReader();
|
| 680 |
+
reader.onloadend = () => {{
|
| 681 |
+
const result = reader.result;
|
| 682 |
+
// Extract base64 part from data URL
|
| 683 |
+
const base64 = result.split(',')[1];
|
| 684 |
+
resolve(base64);
|
| 685 |
+
}};
|
| 686 |
+
reader.onerror = reject;
|
| 687 |
+
reader.readAsDataURL(blob);
|
| 688 |
+
}});
|
| 689 |
+
}}
|
| 690 |
+
|
| 691 |
+
async transcribeAudio(audioBase64) {{
|
| 692 |
+
const sessionHash = this.generateSessionHash();
|
| 693 |
+
const payload = {{
|
| 694 |
+
data: [
|
| 695 |
+
audioBase64,
|
| 696 |
+
this.language,
|
| 697 |
+
this.modelSize
|
| 698 |
+
],
|
| 699 |
+
session_hash: sessionHash
|
| 700 |
+
}};
|
| 701 |
+
|
| 702 |
+
console.log(`π€ Sending to STT v2 service: ${{this.serverUrl}}/call/gradio_transcribe_memory`);
|
| 703 |
+
|
| 704 |
+
try {{
|
| 705 |
+
const startTime = Date.now();
|
| 706 |
+
|
| 707 |
+
const response = await fetch(`${{this.serverUrl}}/call/gradio_transcribe_memory`, {{
|
| 708 |
+
method: 'POST',
|
| 709 |
+
headers: {{
|
| 710 |
+
'Content-Type': 'application/json',
|
| 711 |
+
}},
|
| 712 |
+
body: JSON.stringify(payload)
|
| 713 |
+
}});
|
| 714 |
+
|
| 715 |
+
if (!response.ok) {{
|
| 716 |
+
throw new Error(`STT v2 request failed: ${{response.status}}`);
|
| 717 |
+
}}
|
| 718 |
+
|
| 719 |
+
const responseData = await response.json();
|
| 720 |
+
console.log('π¨ STT v2 queue response:', responseData);
|
| 721 |
+
|
| 722 |
+
let result;
|
| 723 |
+
|
| 724 |
+
if (responseData.event_id) {{
|
| 725 |
+
console.log(`π― Got queue event_id: ${{responseData.event_id}}`);
|
| 726 |
+
result = await this.listenForQueueResult(responseData, startTime, sessionHash);
|
| 727 |
+
}} else if (responseData.data && Array.isArray(responseData.data)) {{
|
| 728 |
+
result = responseData.data[0];
|
| 729 |
+
console.log('π₯ Got direct response from queue');
|
| 730 |
+
}} else {{
|
| 731 |
+
throw new Error(`Unexpected response format: ${{JSON.stringify(responseData)}}`);
|
| 732 |
+
}}
|
| 733 |
+
|
| 734 |
+
if (result && result.trim()) {{
|
| 735 |
+
const processingTime = (Date.now() - startTime) / 1000;
|
| 736 |
+
console.log(`β
STT v2 transcription successful (${{processingTime.toFixed(2)}}s): "${{result.substring(0, 100)}}"`);
|
| 737 |
+
|
| 738 |
+
// Add transcription to message input
|
| 739 |
+
this.addTranscriptionToInput(result);
|
| 740 |
+
updateSTTVisualState('ready');
|
| 741 |
+
}} else {{
|
| 742 |
+
console.warn('β οΈ Empty transcription result');
|
| 743 |
+
updateSTTVisualState('ready');
|
| 744 |
+
}}
|
| 745 |
+
|
| 746 |
+
}} catch (error) {{
|
| 747 |
+
console.error('β STT v2 transcription failed:', error);
|
| 748 |
+
updateSTTVisualState('error');
|
| 749 |
+
setTimeout(() => updateSTTVisualState('ready'), 3000);
|
| 750 |
+
}}
|
| 751 |
+
}}
|
| 752 |
+
|
| 753 |
+
async listenForQueueResult(queueResponse, startTime, sessionHash) {{
|
| 754 |
+
return new Promise((resolve, reject) => {{
|
| 755 |
+
const wsUrl = this.serverUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/queue/data';
|
| 756 |
+
console.log(`π Connecting to STT v2 WebSocket: ${{wsUrl}}`);
|
| 757 |
+
|
| 758 |
+
const ws = new WebSocket(wsUrl);
|
| 759 |
+
|
| 760 |
+
const timeout = setTimeout(() => {{
|
| 761 |
+
ws.close();
|
| 762 |
+
reject(new Error('STT v2 queue timeout after 30 seconds'));
|
| 763 |
+
}}, 30000);
|
| 764 |
+
|
| 765 |
+
ws.onopen = () => {{
|
| 766 |
+
console.log('β
STT v2 WebSocket connected');
|
| 767 |
+
if (queueResponse.event_id) {{
|
| 768 |
+
ws.send(JSON.stringify({{
|
| 769 |
+
event_id: queueResponse.event_id
|
| 770 |
+
}}));
|
| 771 |
+
console.log(`π€ Sent event_id: ${{queueResponse.event_id}}`);
|
| 772 |
+
}}
|
| 773 |
+
}};
|
| 774 |
+
|
| 775 |
+
ws.onmessage = (event) => {{
|
| 776 |
+
try {{
|
| 777 |
+
const data = JSON.parse(event.data);
|
| 778 |
+
console.log('π¨ STT v2 queue message:', data);
|
| 779 |
+
|
| 780 |
+
if (data.msg === 'process_completed' && data.output && data.output.data) {{
|
| 781 |
+
clearTimeout(timeout);
|
| 782 |
+
ws.close();
|
| 783 |
+
resolve(data.output.data[0]);
|
| 784 |
+
}} else if (data.msg === 'process_starts') {{
|
| 785 |
+
updateSTTVisualState('processing');
|
| 786 |
+
}}
|
| 787 |
+
}} catch (e) {{
|
| 788 |
+
console.warn('β οΈ STT v2 WebSocket parse error:', e.message);
|
| 789 |
+
}}
|
| 790 |
+
}};
|
| 791 |
+
|
| 792 |
+
ws.onerror = (error) => {{
|
| 793 |
+
console.error('β STT v2 WebSocket error:', error);
|
| 794 |
+
clearTimeout(timeout);
|
| 795 |
+
// Try polling as fallback
|
| 796 |
+
this.pollForResult(queueResponse.event_id, startTime, sessionHash).then(resolve).catch(reject);
|
| 797 |
+
}};
|
| 798 |
+
|
| 799 |
+
ws.onclose = (event) => {{
|
| 800 |
+
console.log(`π STT v2 WebSocket closed: code=${{event.code}}`);
|
| 801 |
+
clearTimeout(timeout);
|
| 802 |
+
}};
|
| 803 |
+
}});
|
| 804 |
+
}}
|
| 805 |
+
|
| 806 |
+
async pollForResult(eventId, startTime, sessionHash) {{
|
| 807 |
+
console.log(`π Starting STT v2 polling for event: ${{eventId}}`);
|
| 808 |
+
const maxAttempts = 20;
|
| 809 |
+
|
| 810 |
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {{
|
| 811 |
+
try {{
|
| 812 |
+
const endpoint = `/queue/data?event_id=${{eventId}}&session_hash=${{sessionHash}}`;
|
| 813 |
+
const response = await fetch(`${{this.serverUrl}}${{endpoint}}`);
|
| 814 |
+
|
| 815 |
+
if (response.ok) {{
|
| 816 |
+
const responseText = await response.text();
|
| 817 |
+
console.log(`π STT v2 poll attempt ${{attempt + 1}}: ${{responseText.substring(0, 200)}}`);
|
| 818 |
+
|
| 819 |
+
if (responseText.includes('data: ')) {{
|
| 820 |
+
const lines = responseText.split('\\n');
|
| 821 |
+
for (const line of lines) {{
|
| 822 |
+
if (line.startsWith('data: ')) {{
|
| 823 |
+
try {{
|
| 824 |
+
const data = JSON.parse(line.substring(6));
|
| 825 |
+
if (data.msg === 'process_completed' && data.output && data.output.data) {{
|
| 826 |
+
return data.output.data[0];
|
| 827 |
+
}}
|
| 828 |
+
}} catch (parseError) {{
|
| 829 |
+
console.warn('β οΈ STT v2 SSE parse error:', parseError.message);
|
| 830 |
+
}}
|
| 831 |
+
}}
|
| 832 |
+
}}
|
| 833 |
+
}}
|
| 834 |
+
}}
|
| 835 |
+
}} catch (e) {{
|
| 836 |
+
console.warn(`β οΈ STT v2 poll error attempt ${{attempt + 1}}:`, e.message);
|
| 837 |
+
}}
|
| 838 |
+
|
| 839 |
+
// Progressive delay
|
| 840 |
+
const delay = attempt < 5 ? 200 : 500;
|
| 841 |
+
await new Promise(resolve => setTimeout(resolve, delay));
|
| 842 |
+
}}
|
| 843 |
+
|
| 844 |
+
throw new Error('STT v2 polling timeout - no result after 20 attempts');
|
| 845 |
+
}}
|
| 846 |
+
|
| 847 |
+
addTranscriptionToInput(transcription) {{
|
| 848 |
+
const currentValue = messageInput.value;
|
| 849 |
+
let newText = transcription.trim();
|
| 850 |
+
|
| 851 |
+
// SMART EMAIL PARSING: Convert spoken email patterns to proper email format
|
| 852 |
+
newText = this.parseSpokenEmail(newText);
|
| 853 |
+
|
| 854 |
+
// Add transcription to message input
|
| 855 |
+
if (currentValue && !currentValue.endsWith(' ')) {{
|
| 856 |
+
messageInput.value = currentValue + ' ' + newText;
|
| 857 |
+
}} else {{
|
| 858 |
+
messageInput.value = currentValue + newText;
|
| 859 |
+
}}
|
| 860 |
+
|
| 861 |
+
// Move cursor to end
|
| 862 |
+
messageInput.setSelectionRange(messageInput.value.length, messageInput.value.length);
|
| 863 |
+
|
| 864 |
+
// Auto-resize textarea
|
| 865 |
+
autoResizeTextarea();
|
| 866 |
+
|
| 867 |
+
// Track speech activity for auto-submission
|
| 868 |
+
lastSpeechTime = Date.now();
|
| 869 |
+
hasReceivedSpeech = true;
|
| 870 |
+
|
| 871 |
+
// UNIFIED TIMER: Always start 2.5 second timer after ANY transcription
|
| 872 |
+
console.log('β±οΈ Starting 2.5 second timer after transcription...');
|
| 873 |
+
|
| 874 |
+
// Clear any existing timer first
|
| 875 |
+
if (silenceTimer) {{
|
| 876 |
+
clearTimeout(silenceTimer);
|
| 877 |
+
}}
|
| 878 |
+
|
| 879 |
+
// Check if transcription contains an email address for UI feedback only
|
| 880 |
+
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{{2,}}\b/;
|
| 881 |
+
const hasEmail = emailRegex.test(newText);
|
| 882 |
+
|
| 883 |
+
if (hasEmail) {{
|
| 884 |
+
console.log('π§ Email detected - showing verification notice');
|
| 885 |
+
this.showEmailVerificationNotice();
|
| 886 |
+
this.highlightEmailInInput();
|
| 887 |
+
}}
|
| 888 |
+
|
| 889 |
+
// Start 2.5 second timer for ALL transcriptions (not just emails)
|
| 890 |
+
silenceTimer = setTimeout(() => {{
|
| 891 |
+
if (hasReceivedSpeech && messageInput.value.trim()) {{
|
| 892 |
+
console.log('β±οΈ 2.5 seconds completed after transcription, auto-submitting...');
|
| 893 |
+
submitMessage();
|
| 894 |
+
}}
|
| 895 |
+
}}, 2500);
|
| 896 |
+
|
| 897 |
+
}}
|
| 898 |
+
|
| 899 |
+
showEmailVerificationNotice() {{
|
| 900 |
+
// Create a temporary notification
|
| 901 |
+
const notification = document.createElement('div');
|
| 902 |
+
notification.style.cssText = `
|
| 903 |
+
position: fixed;
|
| 904 |
+
top: 20px;
|
| 905 |
+
right: 20px;
|
| 906 |
+
background: #e3f2fd;
|
| 907 |
+
color: #1976d2;
|
| 908 |
+
padding: 12px 16px;
|
| 909 |
+
border-radius: 8px;
|
| 910 |
+
border-left: 4px solid #2196f3;
|
| 911 |
+
font-size: 14px;
|
| 912 |
+
font-weight: 500;
|
| 913 |
+
z-index: 1000;
|
| 914 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
| 915 |
+
max-width: 300px;
|
| 916 |
+
animation: slideIn 0.3s ease-out;
|
| 917 |
+
`;
|
| 918 |
+
notification.innerHTML = `
|
| 919 |
+
<div style="display: flex; align-items: center; gap: 8px;">
|
| 920 |
+
<span>π§</span>
|
| 921 |
+
<div>
|
| 922 |
+
<div style="font-weight: bold;">Email Detected!</div>
|
| 923 |
+
<div style="font-size: 12px; opacity: 0.8;">You have 2.5 seconds to verify/edit before auto-submission</div>
|
| 924 |
+
</div>
|
| 925 |
+
</div>
|
| 926 |
+
`;
|
| 927 |
+
|
| 928 |
+
document.body.appendChild(notification);
|
| 929 |
+
|
| 930 |
+
// Add CSS animation
|
| 931 |
+
const style = document.createElement('style');
|
| 932 |
+
style.textContent = `
|
| 933 |
+
@keyframes slideIn {{
|
| 934 |
+
from {{
|
| 935 |
+
transform: translateX(100%);
|
| 936 |
+
opacity: 0;
|
| 937 |
+
}}
|
| 938 |
+
to {{
|
| 939 |
+
transform: translateX(0);
|
| 940 |
+
opacity: 1;
|
| 941 |
+
}}
|
| 942 |
+
}}
|
| 943 |
+
`;
|
| 944 |
+
document.head.appendChild(style);
|
| 945 |
+
|
| 946 |
+
// Remove notification after 4 seconds
|
| 947 |
+
setTimeout(() => {{
|
| 948 |
+
if (notification.parentNode) {{
|
| 949 |
+
notification.style.animation = 'slideIn 0.3s ease-out reverse';
|
| 950 |
+
setTimeout(() => {{
|
| 951 |
+
if (notification.parentNode) {{
|
| 952 |
+
notification.parentNode.removeChild(notification);
|
| 953 |
+
}}
|
| 954 |
+
}}, 300);
|
| 955 |
+
}}
|
| 956 |
+
}}, 4000);
|
| 957 |
+
}}
|
| 958 |
+
|
| 959 |
+
highlightEmailInInput() {{
|
| 960 |
+
// Find and select the email address in the input field
|
| 961 |
+
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{{2,}}\b/;
|
| 962 |
+
const inputValue = messageInput.value;
|
| 963 |
+
const match = inputValue.match(emailRegex);
|
| 964 |
+
|
| 965 |
+
if (match) {{
|
| 966 |
+
const emailStart = inputValue.indexOf(match[0]);
|
| 967 |
+
const emailEnd = emailStart + match[0].length;
|
| 968 |
+
|
| 969 |
+
// Focus the input field
|
| 970 |
+
messageInput.focus();
|
| 971 |
+
|
| 972 |
+
// Select the email address
|
| 973 |
+
messageInput.setSelectionRange(emailStart, emailEnd);
|
| 974 |
+
|
| 975 |
+
// Add visual styling to indicate selection
|
| 976 |
+
messageInput.style.backgroundColor = '#fff3cd';
|
| 977 |
+
messageInput.style.borderColor = '#ffc107';
|
| 978 |
+
|
| 979 |
+
// Remove highlighting after 3 seconds or when user starts typing
|
| 980 |
+
const removeHighlight = () => {{
|
| 981 |
+
messageInput.style.backgroundColor = '';
|
| 982 |
+
messageInput.style.borderColor = '';
|
| 983 |
+
}};
|
| 984 |
+
|
| 985 |
+
setTimeout(removeHighlight, 3000);
|
| 986 |
+
|
| 987 |
+
// Remove highlight immediately when user starts typing
|
| 988 |
+
const handleInput = () => {{
|
| 989 |
+
removeHighlight();
|
| 990 |
+
messageInput.removeEventListener('input', handleInput);
|
| 991 |
+
}};
|
| 992 |
+
messageInput.addEventListener('input', handleInput);
|
| 993 |
+
}}
|
| 994 |
+
}}
|
| 995 |
+
|
| 996 |
+
parseSpokenEmail(text) {{
|
| 997 |
+
// Convert common spoken email patterns to proper email format
|
| 998 |
+
let processed = text;
|
| 999 |
+
|
| 1000 |
+
// Pattern 1: "pgits at gmail dot com" -> "pgits@gmail.com"
|
| 1001 |
+
processed = processed.replace(/\b(\w+)\s+at\s+(\w+)\s+dot\s+(\w+)\b/gi, '$1@$2.$3');
|
| 1002 |
+
|
| 1003 |
+
// Pattern 2a: "pgitsatgmail.com" (catch this specific pattern first)
|
| 1004 |
+
processed = processed.replace(/(\w+)at(\w+)\.com/gi, '$1@$2.com');
|
| 1005 |
+
|
| 1006 |
+
// Pattern 2b: "pgitsatgmail.org" and other domains
|
| 1007 |
+
processed = processed.replace(/(\w+)at(\w+)\.(\w+)/gi, '$1@$2.$3');
|
| 1008 |
+
|
| 1009 |
+
// Pattern 3: "pgits at gmail.com" -> "pgits@gmail.com"
|
| 1010 |
+
processed = processed.replace(/\b(\w+)\s+at\s+(\w+\.\w+)\b/gi, '$1@$2');
|
| 1011 |
+
|
| 1012 |
+
// Pattern 4: "pgitsatgmaildotcom" -> "pgits@gmail.com" (everything run together)
|
| 1013 |
+
processed = processed.replace(/\b(\w+)at(\w+)dot(\w+)\b/gi, '$1@$2.$3');
|
| 1014 |
+
|
| 1015 |
+
// Pattern 5: "petergetsgitusat gmail.com" -> "petergetsgitus@gmail.com" (space before at)
|
| 1016 |
+
processed = processed.replace(/\b(\w+)\s*at\s+(\w+\.\w+)\b/gi, '$1@$2');
|
| 1017 |
+
|
| 1018 |
+
// Pattern 6: "petergetsgitusat gmail dot com" -> "petergetsgitus@gmail.com"
|
| 1019 |
+
processed = processed.replace(/\b(\w+)\s*at\s+(\w+)\s+dot\s+(\w+)\b/gi, '$1@$2.$3');
|
| 1020 |
+
|
| 1021 |
+
// Pattern 7: Handle multiple dots - "john at company dot co dot uk" -> "john@company.co.uk"
|
| 1022 |
+
processed = processed.replace(/\b(\w+)\s+at\s+([\w\s]+?)\s+dot\s+([\w\s]+)\b/gi, (match, username, domain, tld) => {{
|
| 1023 |
+
// Replace spaces and 'dot' with actual dots in domain part
|
| 1024 |
+
const cleanDomain = domain.replace(/\s+dot\s+/g, '.').replace(/\s+/g, '');
|
| 1025 |
+
const cleanTld = tld.replace(/\s+dot\s+/g, '.').replace(/\s+/g, '');
|
| 1026 |
+
return `${{username}}@${{cleanDomain}}.${{cleanTld}}`;
|
| 1027 |
+
}});
|
| 1028 |
+
|
| 1029 |
+
// Log the conversion if any changes were made
|
| 1030 |
+
if (processed !== text) {{
|
| 1031 |
+
console.log(`π§ Email pattern converted: "${{text}}" -> "${{processed}}"`);
|
| 1032 |
+
}}
|
| 1033 |
+
|
| 1034 |
+
return processed;
|
| 1035 |
+
}}
|
| 1036 |
+
}}
|
| 1037 |
+
|
| 1038 |
+
// Auto-submission function
|
| 1039 |
+
function submitMessage() {{
|
| 1040 |
+
const message = messageInput.value.trim();
|
| 1041 |
+
if (message && !isLoading) {{
|
| 1042 |
+
// Clear the speech tracking
|
| 1043 |
+
hasReceivedSpeech = false;
|
| 1044 |
+
if (silenceTimer) {{
|
| 1045 |
+
clearTimeout(silenceTimer);
|
| 1046 |
+
silenceTimer = null;
|
| 1047 |
+
}}
|
| 1048 |
+
|
| 1049 |
+
// Submit the message (using existing sendMessage logic)
|
| 1050 |
+
sendMessage(message);
|
| 1051 |
+
}}
|
| 1052 |
+
}}
|
| 1053 |
+
|
| 1054 |
+
// Initialize session
|
| 1055 |
+
async function initializeSession() {{
|
| 1056 |
+
try {{
|
| 1057 |
+
const response = await fetch('{base_url}/sessions', {{
|
| 1058 |
+
method: 'POST',
|
| 1059 |
+
headers: {{
|
| 1060 |
+
'Content-Type': 'application/json',
|
| 1061 |
+
}},
|
| 1062 |
+
body: JSON.stringify({{user_data: {{}}}})
|
| 1063 |
+
}});
|
| 1064 |
+
|
| 1065 |
+
if (response.ok) {{
|
| 1066 |
+
const data = await response.json();
|
| 1067 |
+
sessionId = data.session_id;
|
| 1068 |
+
}}
|
| 1069 |
+
}} catch (error) {{
|
| 1070 |
+
console.error('Failed to initialize session:', error);
|
| 1071 |
+
}}
|
| 1072 |
+
}}
|
| 1073 |
+
|
| 1074 |
+
async function addMessage(content, isUser = false) {{
|
| 1075 |
+
const messageDiv = document.createElement('div');
|
| 1076 |
+
messageDiv.className = `message ${{isUser ? 'user' : 'assistant'}}`;
|
| 1077 |
+
|
| 1078 |
+
const avatar = document.createElement('div');
|
| 1079 |
+
avatar.className = 'message-avatar';
|
| 1080 |
+
avatar.textContent = isUser ? 'π€' : 'π€';
|
| 1081 |
+
|
| 1082 |
+
const messageContent = document.createElement('div');
|
| 1083 |
+
messageContent.className = 'message-content';
|
| 1084 |
+
|
| 1085 |
+
// For assistant messages, start TTS FIRST, then display with delay
|
| 1086 |
+
if (!isUser && content && content.trim()) {{
|
| 1087 |
+
// Create temporary element to extract text content
|
| 1088 |
+
const tempDiv = document.createElement('div');
|
| 1089 |
+
tempDiv.innerHTML = content;
|
| 1090 |
+
const textContent = tempDiv.textContent || tempDiv.innerText;
|
| 1091 |
+
|
| 1092 |
+
if (textContent && textContent.trim()) {{
|
| 1093 |
+
// Start TTS synthesis immediately (non-blocking)
|
| 1094 |
+
chatCalTTS.synthesizeAndPlay(textContent.trim());
|
| 1095 |
+
|
| 1096 |
+
// Wait 500ms before displaying the text
|
| 1097 |
+
await new Promise(resolve => setTimeout(resolve, 500));
|
| 1098 |
+
}}
|
| 1099 |
+
|
| 1100 |
+
// Now render the HTML content
|
| 1101 |
+
messageContent.innerHTML = content;
|
| 1102 |
+
}} else {{
|
| 1103 |
+
// For user messages, just set text content immediately
|
| 1104 |
+
messageContent.textContent = content;
|
| 1105 |
+
}}
|
| 1106 |
+
|
| 1107 |
+
messageDiv.appendChild(avatar);
|
| 1108 |
+
messageDiv.appendChild(messageContent);
|
| 1109 |
+
|
| 1110 |
+
// Simply append to chatMessages instead of insertBefore
|
| 1111 |
+
chatMessages.appendChild(messageDiv);
|
| 1112 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1113 |
+
}}
|
| 1114 |
+
|
| 1115 |
+
function showTyping() {{
|
| 1116 |
+
if (typingIndicator) {{
|
| 1117 |
+
typingIndicator.style.display = 'block';
|
| 1118 |
+
}}
|
| 1119 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1120 |
+
}}
|
| 1121 |
+
|
| 1122 |
+
function hideTyping() {{
|
| 1123 |
+
if (typingIndicator) {{
|
| 1124 |
+
typingIndicator.style.display = 'none';
|
| 1125 |
+
}}
|
| 1126 |
+
}}
|
| 1127 |
+
|
| 1128 |
+
async function sendMessage(message = null) {{
|
| 1129 |
+
const text = message || messageInput.value.trim();
|
| 1130 |
+
|
| 1131 |
+
if (!text || isLoading) {{
|
| 1132 |
+
return;
|
| 1133 |
+
}}
|
| 1134 |
+
|
| 1135 |
+
// Ensure we have a session before sending
|
| 1136 |
+
if (!sessionId) {{
|
| 1137 |
+
await initializeSession();
|
| 1138 |
+
if (!sessionId) {{
|
| 1139 |
+
await addMessage('Sorry, I had trouble connecting. Please try again!');
|
| 1140 |
+
return;
|
| 1141 |
+
}}
|
| 1142 |
+
}}
|
| 1143 |
+
|
| 1144 |
+
// Add user message
|
| 1145 |
+
await addMessage(text, true);
|
| 1146 |
+
messageInput.value = '';
|
| 1147 |
+
|
| 1148 |
+
// Show loading state
|
| 1149 |
+
isLoading = true;
|
| 1150 |
+
sendButton.disabled = true;
|
| 1151 |
+
showTyping();
|
| 1152 |
+
|
| 1153 |
+
try {{
|
| 1154 |
+
const response = await fetch('{base_url}/chat', {{
|
| 1155 |
+
method: 'POST',
|
| 1156 |
+
headers: {{
|
| 1157 |
+
'Content-Type': 'application/json',
|
| 1158 |
+
}},
|
| 1159 |
+
body: JSON.stringify({{
|
| 1160 |
+
message: text,
|
| 1161 |
+
session_id: sessionId
|
| 1162 |
+
}})
|
| 1163 |
+
}});
|
| 1164 |
+
|
| 1165 |
+
if (response.ok) {{
|
| 1166 |
+
const data = await response.json();
|
| 1167 |
+
sessionId = data.session_id; // Update session ID
|
| 1168 |
+
await addMessage(data.response);
|
| 1169 |
+
|
| 1170 |
+
// TTS integration - play the response
|
| 1171 |
+
if (chatCalTTS && chatCalTTS.webrtcEnabled && data.response) {{
|
| 1172 |
+
chatCalTTS.synthesizeAndPlay(data.response);
|
| 1173 |
+
}}
|
| 1174 |
+
}} else {{
|
| 1175 |
+
const error = await response.json();
|
| 1176 |
+
await addMessage(`Sorry, I encountered an error: ${{error.message || 'Unknown error'}}`);
|
| 1177 |
+
}}
|
| 1178 |
+
}} catch (error) {{
|
| 1179 |
+
console.error('Chat error:', error);
|
| 1180 |
+
await addMessage('Sorry, I had trouble connecting. Please try again!');
|
| 1181 |
+
}} finally {{
|
| 1182 |
+
isLoading = false;
|
| 1183 |
+
sendButton.disabled = false;
|
| 1184 |
+
hideTyping();
|
| 1185 |
+
|
| 1186 |
+
// Clear any pending speech timers to allow fresh voice input
|
| 1187 |
+
hasReceivedSpeech = false;
|
| 1188 |
+
if (silenceTimer) {{
|
| 1189 |
+
clearTimeout(silenceTimer);
|
| 1190 |
+
silenceTimer = null;
|
| 1191 |
+
}}
|
| 1192 |
+
}}
|
| 1193 |
+
}}
|
| 1194 |
+
|
| 1195 |
+
function sendQuickMessage(message) {{
|
| 1196 |
+
messageInput.value = message;
|
| 1197 |
+
sendMessage();
|
| 1198 |
+
}}
|
| 1199 |
+
|
| 1200 |
+
// Event listeners
|
| 1201 |
+
messageInput.addEventListener('keypress', function(e) {{
|
| 1202 |
+
if (e.key === 'Enter' && !e.shiftKey) {{
|
| 1203 |
+
e.preventDefault();
|
| 1204 |
+
sendMessage();
|
| 1205 |
+
}}
|
| 1206 |
+
}});
|
| 1207 |
+
|
| 1208 |
+
// Auto-resize textarea as content grows
|
| 1209 |
+
function autoResizeTextarea() {{
|
| 1210 |
+
messageInput.style.height = 'auto';
|
| 1211 |
+
const newHeight = Math.min(messageInput.scrollHeight, 120); // Max height 120px
|
| 1212 |
+
messageInput.style.height = newHeight + 'px';
|
| 1213 |
+
}}
|
| 1214 |
+
|
| 1215 |
+
// Enhanced input handling with typing delay for STT
|
| 1216 |
+
let typingTimer = null;
|
| 1217 |
+
let lastTypingTime = 0;
|
| 1218 |
+
let lastMouseMoveTime = 0;
|
| 1219 |
+
let mouseTimer = null;
|
| 1220 |
+
|
| 1221 |
+
messageInput.addEventListener('input', function() {{
|
| 1222 |
+
autoResizeTextarea();
|
| 1223 |
+
|
| 1224 |
+
// Track typing activity to delay STT auto-submission
|
| 1225 |
+
lastTypingTime = Date.now();
|
| 1226 |
+
|
| 1227 |
+
// Mouse movement tracking for editing detection
|
| 1228 |
+
lastMouseMoveTime = Date.now();
|
| 1229 |
+
|
| 1230 |
+
// If user is typing, clear any existing silence timer to prevent premature submission
|
| 1231 |
+
if (silenceTimer) {{
|
| 1232 |
+
clearTimeout(silenceTimer);
|
| 1233 |
+
silenceTimer = null;
|
| 1234 |
+
}}
|
| 1235 |
+
|
| 1236 |
+
// Clear existing typing timer
|
| 1237 |
+
if (typingTimer) {{
|
| 1238 |
+
clearTimeout(typingTimer);
|
| 1239 |
+
}}
|
| 1240 |
+
|
| 1241 |
+
// Set new typing timer - if user stops typing for 2.5 seconds, check for STT auto-submission
|
| 1242 |
+
typingTimer = setTimeout(() => {{
|
| 1243 |
+
// Only check for STT auto-submission if user has stopped typing and we have speech input
|
| 1244 |
+
if (hasReceivedSpeech && messageInput.value.trim() && (Date.now() - lastTypingTime) >= 2500) {{
|
| 1245 |
+
console.log('π User stopped typing, checking for STT auto-submission...');
|
| 1246 |
+
|
| 1247 |
+
// Additional delay to ensure user is done typing (2.5 seconds after last keystroke)
|
| 1248 |
+
silenceTimer = setTimeout(() => {{
|
| 1249 |
+
if (hasReceivedSpeech && messageInput.value.trim()) {{
|
| 1250 |
+
console.log('π STT auto-submitting after typing pause...');
|
| 1251 |
+
submitMessage();
|
| 1252 |
+
}}
|
| 1253 |
+
}}, 2500);
|
| 1254 |
+
}}
|
| 1255 |
+
}}, 2500);
|
| 1256 |
+
}});
|
| 1257 |
+
|
| 1258 |
+
messageInput.addEventListener('paste', () => setTimeout(autoResizeTextarea, 0));
|
| 1259 |
+
|
| 1260 |
+
// Add click listener to send button
|
| 1261 |
+
if (sendButton) {{
|
| 1262 |
+
sendButton.addEventListener('click', function(e) {{
|
| 1263 |
+
e.preventDefault();
|
| 1264 |
+
sendMessage();
|
| 1265 |
+
}});
|
| 1266 |
+
}}
|
| 1267 |
+
|
| 1268 |
+
// Mouse movement detection for editing detection
|
| 1269 |
+
document.addEventListener('mousemove', function() {{
|
| 1270 |
+
lastMouseMoveTime = Date.now();
|
| 1271 |
+
// Reset timer when user moves mouse (indicates they might be editing)
|
| 1272 |
+
resetAutoSubmitTimer();
|
| 1273 |
+
}});
|
| 1274 |
+
|
| 1275 |
+
// Keyboard activity detection for editing detection
|
| 1276 |
+
messageInput.addEventListener('keydown', function() {{
|
| 1277 |
+
// Reset timer when user types (indicates they are editing)
|
| 1278 |
+
resetAutoSubmitTimer();
|
| 1279 |
+
}});
|
| 1280 |
+
|
| 1281 |
+
messageInput.addEventListener('input', function() {{
|
| 1282 |
+
// Reset timer when input content changes
|
| 1283 |
+
resetAutoSubmitTimer();
|
| 1284 |
+
}});
|
| 1285 |
+
|
| 1286 |
+
// Function to reset the auto-submit timer
|
| 1287 |
+
function resetAutoSubmitTimer() {{
|
| 1288 |
+
if (silenceTimer && hasReceivedSpeech) {{
|
| 1289 |
+
console.log('β¨οΈ User activity detected, resetting 2.5 second timer...');
|
| 1290 |
+
clearTimeout(silenceTimer);
|
| 1291 |
+
|
| 1292 |
+
// Restart the 2.5 second timer
|
| 1293 |
+
silenceTimer = setTimeout(() => {{
|
| 1294 |
+
if (hasReceivedSpeech && messageInput.value.trim()) {{
|
| 1295 |
+
console.log('β±οΈ 2.5 seconds completed after user activity, auto-submitting...');
|
| 1296 |
+
submitMessage();
|
| 1297 |
+
}}
|
| 1298 |
+
}}, 2500);
|
| 1299 |
+
}}
|
| 1300 |
+
}}
|
| 1301 |
+
|
| 1302 |
+
// Initialize when page loads
|
| 1303 |
+
// STT v2 indicator click to toggle recording
|
| 1304 |
+
sttIndicator.addEventListener('click', () => {{
|
| 1305 |
+
if (sttv2Manager) {{
|
| 1306 |
+
sttv2Manager.toggleRecording().catch(error => {{
|
| 1307 |
+
console.error('Failed to toggle STT v2 recording:', error);
|
| 1308 |
+
updateSTTVisualState('error');
|
| 1309 |
+
setTimeout(() => updateSTTVisualState('ready'), 3000);
|
| 1310 |
+
alert('Microphone access failed. Please check permissions.');
|
| 1311 |
+
}});
|
| 1312 |
+
}}
|
| 1313 |
+
}});
|
| 1314 |
+
|
| 1315 |
+
// Initialize session and STT v2
|
| 1316 |
+
async function initAndStartSTT() {{
|
| 1317 |
+
await initializeSession();
|
| 1318 |
+
|
| 1319 |
+
// Initialize STT v2 Manager
|
| 1320 |
+
try {{
|
| 1321 |
+
sttv2Manager = new STTv2Manager();
|
| 1322 |
+
updateSTTVisualState('ready');
|
| 1323 |
+
console.log('β
STT v2 Manager initialized and ready');
|
| 1324 |
+
}} catch (error) {{
|
| 1325 |
+
console.warn('STT v2 initialization failed:', error);
|
| 1326 |
+
updateSTTVisualState('error');
|
| 1327 |
+
// STT failure is not critical, user can still type
|
| 1328 |
+
}}
|
| 1329 |
+
}}
|
| 1330 |
+
|
| 1331 |
+
// Cleanup on page unload
|
| 1332 |
+
window.addEventListener('beforeunload', () => {{
|
| 1333 |
+
if (sttv2Manager && sttv2Manager.isRecording) {{
|
| 1334 |
+
sttv2Manager.stopRecording();
|
| 1335 |
+
}}
|
| 1336 |
+
}});
|
| 1337 |
+
|
| 1338 |
+
window.addEventListener('load', initAndStartSTT);
|
| 1339 |
+
|
| 1340 |
+
</script>
|
| 1341 |
+
</body>
|
| 1342 |
+
</html>
|
| 1343 |
+
"""
|
| 1344 |
+
|
| 1345 |
+
|
| 1346 |
+
@router.get("/widget", response_class=HTMLResponse)
|
| 1347 |
+
async def embeddable_widget():
|
| 1348 |
+
"""Minimal embeddable widget for other websites."""
|
| 1349 |
+
return """
|
| 1350 |
+
<div id="chatcal-widget" style="
|
| 1351 |
+
position: fixed;
|
| 1352 |
+
bottom: 20px;
|
| 1353 |
+
right: 20px;
|
| 1354 |
+
width: 400px;
|
| 1355 |
+
height: 500px;
|
| 1356 |
+
background: white;
|
| 1357 |
+
border-radius: 10px;
|
| 1358 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
| 1359 |
+
z-index: 9999;
|
| 1360 |
+
display: none;
|
| 1361 |
+
">
|
| 1362 |
+
<iframe
|
| 1363 |
+
src="/chat-widget"
|
| 1364 |
+
width="100%"
|
| 1365 |
+
height="100%"
|
| 1366 |
+
frameborder="0"
|
| 1367 |
+
style="border-radius: 10px;">
|
| 1368 |
+
</iframe>
|
| 1369 |
+
</div>
|
| 1370 |
+
|
| 1371 |
+
<button id="chatcal-toggle" style="
|
| 1372 |
+
position: fixed;
|
| 1373 |
+
bottom: 20px;
|
| 1374 |
+
right: 20px;
|
| 1375 |
+
width: 60px;
|
| 1376 |
+
height: 60px;
|
| 1377 |
+
background: #4CAF50;
|
| 1378 |
+
color: white;
|
| 1379 |
+
border: none;
|
| 1380 |
+
border-radius: 50%;
|
| 1381 |
+
cursor: pointer;
|
| 1382 |
+
font-size: 24px;
|
| 1383 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
| 1384 |
+
z-index: 10000;
|
| 1385 |
+
">π¬</button>
|
| 1386 |
+
|
| 1387 |
+
<script>
|
| 1388 |
+
document.getElementById('chatcal-toggle').onclick = function() {
|
| 1389 |
+
const widget = document.getElementById('chatcal-widget');
|
| 1390 |
+
const toggle = document.getElementById('chatcal-toggle');
|
| 1391 |
+
|
| 1392 |
+
if (widget.style.display === 'none') {
|
| 1393 |
+
widget.style.display = 'block';
|
| 1394 |
+
toggle.textContent = 'β';
|
| 1395 |
+
} else {
|
| 1396 |
+
widget.style.display = 'none';
|
| 1397 |
+
toggle.textContent = 'π¬';
|
| 1398 |
+
}
|
| 1399 |
+
};
|
| 1400 |
+
</script>
|
| 1401 |
+
"""
|
app/api/main.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main FastAPI application for ChatCal.ai."""
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, HTTPException, Depends, Request, status
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
| 6 |
+
from fastapi.staticfiles import StaticFiles
|
| 7 |
+
from fastapi.exception_handlers import request_validation_exception_handler
|
| 8 |
+
from fastapi.exceptions import RequestValidationError
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import uuid
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Dict, Any, Optional
|
| 14 |
+
|
| 15 |
+
from app.config import settings
|
| 16 |
+
from app.api.models import (
|
| 17 |
+
ChatRequest, ChatResponse, StreamChatResponse, SessionCreate, SessionResponse,
|
| 18 |
+
ConversationHistory, HealthResponse, ErrorResponse, AuthRequest, AuthResponse
|
| 19 |
+
)
|
| 20 |
+
from app.api.chat_widget import router as chat_widget_router
|
| 21 |
+
from app.api.simple_chat import router as simple_chat_router
|
| 22 |
+
from app.core.session_factory import session_manager
|
| 23 |
+
from app.calendar.auth import CalendarAuth
|
| 24 |
+
from app.core.exceptions import (
|
| 25 |
+
ChatCalException, AuthenticationError, CalendarError,
|
| 26 |
+
LLMError, ValidationError, RateLimitError
|
| 27 |
+
)
|
| 28 |
+
from app.core.llm_anthropic import anthropic_llm
|
| 29 |
+
|
| 30 |
+
# Create FastAPI app
|
| 31 |
+
app = FastAPI(
|
| 32 |
+
title="ChatCal.ai",
|
| 33 |
+
description="AI-powered calendar assistant for booking appointments",
|
| 34 |
+
version="0.1.0",
|
| 35 |
+
docs_url="/docs",
|
| 36 |
+
redoc_url="/redoc"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# Add CORS middleware
|
| 40 |
+
app.add_middleware(
|
| 41 |
+
CORSMiddleware,
|
| 42 |
+
allow_origins=settings.cors_origins,
|
| 43 |
+
allow_credentials=True,
|
| 44 |
+
allow_methods=["*"],
|
| 45 |
+
allow_headers=["*"],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Configure logging
|
| 49 |
+
logging.basicConfig(level=logging.INFO)
|
| 50 |
+
logger = logging.getLogger(__name__)
|
| 51 |
+
|
| 52 |
+
# Log testing mode status on startup
|
| 53 |
+
if settings.testing_mode:
|
| 54 |
+
logger.info("π§ͺ TESTING MODE ENABLED - Peter's email will be treated as regular user email")
|
| 55 |
+
else:
|
| 56 |
+
logger.info("π§ Production mode - Peter's email will receive special formatting")
|
| 57 |
+
|
| 58 |
+
# Calendar auth instance
|
| 59 |
+
calendar_auth = CalendarAuth()
|
| 60 |
+
|
| 61 |
+
# Include routers
|
| 62 |
+
app.include_router(chat_widget_router)
|
| 63 |
+
app.include_router(simple_chat_router)
|
| 64 |
+
|
| 65 |
+
# Mount static files
|
| 66 |
+
import os
|
| 67 |
+
static_path = "/app/static"
|
| 68 |
+
if os.path.exists(static_path):
|
| 69 |
+
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# Global exception handlers
|
| 73 |
+
@app.exception_handler(ChatCalException)
|
| 74 |
+
async def chatcal_exception_handler(request: Request, exc: ChatCalException):
|
| 75 |
+
"""Handle custom ChatCal exceptions."""
|
| 76 |
+
logger.error(f"ChatCal exception: {exc.message}", extra={"details": exc.details})
|
| 77 |
+
return JSONResponse(
|
| 78 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 79 |
+
content={
|
| 80 |
+
"error": exc.__class__.__name__,
|
| 81 |
+
"message": exc.message,
|
| 82 |
+
"details": exc.details,
|
| 83 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@app.exception_handler(RequestValidationError)
|
| 89 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 90 |
+
"""Handle request validation errors."""
|
| 91 |
+
logger.warning(f"Validation error: {exc}")
|
| 92 |
+
return JSONResponse(
|
| 93 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 94 |
+
content={
|
| 95 |
+
"error": "ValidationError",
|
| 96 |
+
"message": "Invalid request data",
|
| 97 |
+
"details": {"validation_errors": exc.errors()},
|
| 98 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 99 |
+
}
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.exception_handler(500)
|
| 104 |
+
async def internal_server_error_handler(request: Request, exc: Exception):
|
| 105 |
+
"""Handle internal server errors."""
|
| 106 |
+
logger.error(f"Internal server error: {exc}", exc_info=True)
|
| 107 |
+
return JSONResponse(
|
| 108 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 109 |
+
content={
|
| 110 |
+
"error": "InternalServerError",
|
| 111 |
+
"message": "An unexpected error occurred. Please try again later.",
|
| 112 |
+
"details": {},
|
| 113 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 114 |
+
}
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@app.get("/", response_class=HTMLResponse)
|
| 119 |
+
async def root():
|
| 120 |
+
"""Root endpoint with basic information."""
|
| 121 |
+
return """
|
| 122 |
+
<!DOCTYPE html>
|
| 123 |
+
<html>
|
| 124 |
+
<head>
|
| 125 |
+
<title>ChatCal.ai</title>
|
| 126 |
+
<style>
|
| 127 |
+
body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }
|
| 128 |
+
.container { background: white; padding: 40px; border-radius: 10px; max-width: 800px; margin: 0 auto; }
|
| 129 |
+
h1 { color: #2c3e50; text-align: center; }
|
| 130 |
+
.feature { background: #e8f5e9; padding: 15px; margin: 15px 0; border-radius: 8px; border-left: 4px solid #4caf50; }
|
| 131 |
+
.api-link { background: #e3f2fd; padding: 15px; margin: 15px 0; border-radius: 8px; text-align: center; }
|
| 132 |
+
a { color: #1976d2; text-decoration: none; }
|
| 133 |
+
a:hover { text-decoration: underline; }
|
| 134 |
+
</style>
|
| 135 |
+
</head>
|
| 136 |
+
<body>
|
| 137 |
+
<div class="container">
|
| 138 |
+
<h1>π
Schedule Time with Peter Michael Gits</h1>
|
| 139 |
+
<p style="text-align: center; font-size: 18px; color: #666;">
|
| 140 |
+
Book consultations, meetings, and advisory sessions with Peter
|
| 141 |
+
</p>
|
| 142 |
+
|
| 143 |
+
<div style="text-align: center; margin: 30px 0;">
|
| 144 |
+
<a href="/chat-widget" style="background: #4caf50; color: white; padding: 15px 40px; font-size: 20px; border-radius: 8px; text-decoration: none; display: inline-block;">
|
| 145 |
+
Book an Appointment Now
|
| 146 |
+
</a>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div class="feature">
|
| 150 |
+
<h3>πΌ Professional Consultations</h3>
|
| 151 |
+
<p>Schedule one-on-one business consultations and advisory sessions with Peter Michael Gits</p>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<div class="feature">
|
| 155 |
+
<h3>π€ AI-Powered Scheduling</h3>
|
| 156 |
+
<p>Our intelligent assistant helps you find the perfect time that works for both you and Peter</p>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div class="feature">
|
| 160 |
+
<h3>π§ Instant Confirmation</h3>
|
| 161 |
+
<p>Receive immediate confirmation and calendar invitations for your scheduled meetings</p>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<div class="api-link">
|
| 165 |
+
<h3>π οΈ API Documentation</h3>
|
| 166 |
+
<p>
|
| 167 |
+
<a href="/docs">Interactive API Docs (Swagger)</a> |
|
| 168 |
+
<a href="/redoc">Alternative Docs (ReDoc)</a>
|
| 169 |
+
</p>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<div style="text-align: center; margin-top: 30px; color: #888;">
|
| 173 |
+
<p>Version 0.1.0 | Built with FastAPI & LlamaIndex</p>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</body>
|
| 177 |
+
</html>
|
| 178 |
+
"""
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@app.get("/health", response_model=HealthResponse)
|
| 182 |
+
async def health_check():
|
| 183 |
+
"""Health check endpoint."""
|
| 184 |
+
services = {}
|
| 185 |
+
|
| 186 |
+
# Check session backend connection
|
| 187 |
+
try:
|
| 188 |
+
if hasattr(session_manager, 'redis_client'):
|
| 189 |
+
session_manager.redis_client.ping()
|
| 190 |
+
services["session_backend"] = "healthy"
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"Session backend health check failed: {e}")
|
| 193 |
+
services["session_backend"] = "unhealthy"
|
| 194 |
+
|
| 195 |
+
# Check Groq LLM (via anthropic_llm interface)
|
| 196 |
+
try:
|
| 197 |
+
from app.core.llm_anthropic import anthropic_llm
|
| 198 |
+
if anthropic_llm.test_connection():
|
| 199 |
+
services["llm"] = "healthy"
|
| 200 |
+
else:
|
| 201 |
+
services["llm"] = "unhealthy"
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(f"LLM health check failed: {e}")
|
| 204 |
+
services["llm"] = "not_configured"
|
| 205 |
+
|
| 206 |
+
# Add testing mode status
|
| 207 |
+
services["testing_mode"] = "enabled" if settings.testing_mode else "disabled"
|
| 208 |
+
|
| 209 |
+
# Check Calendar service
|
| 210 |
+
try:
|
| 211 |
+
if calendar_auth.client_id and calendar_auth.client_secret:
|
| 212 |
+
services["calendar"] = "ready"
|
| 213 |
+
else:
|
| 214 |
+
services["calendar"] = "not_configured"
|
| 215 |
+
except Exception as e:
|
| 216 |
+
logger.error(f"Calendar health check failed: {e}")
|
| 217 |
+
services["calendar"] = "unhealthy"
|
| 218 |
+
|
| 219 |
+
overall_status = "healthy" if all(status in ["healthy", "ready"] for status in services.values()) else "degraded"
|
| 220 |
+
|
| 221 |
+
return HealthResponse(
|
| 222 |
+
status=overall_status,
|
| 223 |
+
version="0.1.0",
|
| 224 |
+
timestamp=datetime.utcnow(),
|
| 225 |
+
services=services
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@app.post("/sessions", response_model=SessionResponse)
|
| 230 |
+
async def create_session(request: SessionCreate):
|
| 231 |
+
"""Create a new chat session."""
|
| 232 |
+
try:
|
| 233 |
+
session_id = session_manager.create_session(request.user_data)
|
| 234 |
+
|
| 235 |
+
if not session_id:
|
| 236 |
+
raise HTTPException(
|
| 237 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 238 |
+
detail="Failed to create session"
|
| 239 |
+
)
|
| 240 |
+
session_data = session_manager.get_session(session_id)
|
| 241 |
+
|
| 242 |
+
if not session_data:
|
| 243 |
+
raise HTTPException(status_code=500, detail="Failed to create session")
|
| 244 |
+
|
| 245 |
+
return SessionResponse(
|
| 246 |
+
session_id=session_id,
|
| 247 |
+
created_at=datetime.fromisoformat(session_data["created_at"]),
|
| 248 |
+
last_activity=datetime.fromisoformat(session_data["last_activity"]),
|
| 249 |
+
is_active=True
|
| 250 |
+
)
|
| 251 |
+
except Exception as e:
|
| 252 |
+
logger.error(f"Session creation failed: {e}")
|
| 253 |
+
raise HTTPException(
|
| 254 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 255 |
+
detail="Failed to create session. Please try again."
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@app.get("/sessions/{session_id}", response_model=SessionResponse)
|
| 260 |
+
async def get_session(session_id: str):
|
| 261 |
+
"""Get session information."""
|
| 262 |
+
session_data = session_manager.get_session(session_id)
|
| 263 |
+
|
| 264 |
+
if not session_data:
|
| 265 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 266 |
+
|
| 267 |
+
return SessionResponse(
|
| 268 |
+
session_id=session_id,
|
| 269 |
+
created_at=datetime.fromisoformat(session_data["created_at"]),
|
| 270 |
+
last_activity=datetime.fromisoformat(session_data["last_activity"]),
|
| 271 |
+
is_active=True
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@app.delete("/sessions/{session_id}")
|
| 276 |
+
async def delete_session(session_id: str):
|
| 277 |
+
"""Delete a session."""
|
| 278 |
+
success = session_manager.delete_session(session_id)
|
| 279 |
+
|
| 280 |
+
if not success:
|
| 281 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 282 |
+
|
| 283 |
+
return {"message": "Session deleted successfully"}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@app.get("/sessions/{session_id}/history", response_model=ConversationHistory)
|
| 287 |
+
async def get_conversation_history(session_id: str):
|
| 288 |
+
"""Get conversation history for a session."""
|
| 289 |
+
session_data = session_manager.get_session(session_id)
|
| 290 |
+
if not session_data:
|
| 291 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 292 |
+
|
| 293 |
+
history = session_manager.get_conversation_history(session_id)
|
| 294 |
+
|
| 295 |
+
return ConversationHistory(
|
| 296 |
+
session_id=session_id,
|
| 297 |
+
messages=history.get("messages", []) if history else [],
|
| 298 |
+
created_at=datetime.fromisoformat(session_data["created_at"])
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@app.post("/chat", response_model=ChatResponse)
|
| 303 |
+
async def chat(request: ChatRequest):
|
| 304 |
+
"""Chat with the AI assistant."""
|
| 305 |
+
try:
|
| 306 |
+
# Create session if not provided
|
| 307 |
+
if not request.session_id:
|
| 308 |
+
request.session_id = session_manager.create_session()
|
| 309 |
+
|
| 310 |
+
# Get or create conversation
|
| 311 |
+
conversation = session_manager.get_or_create_conversation(request.session_id)
|
| 312 |
+
if not conversation:
|
| 313 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 314 |
+
|
| 315 |
+
# Get response from agent
|
| 316 |
+
response = conversation.get_response(request.message)
|
| 317 |
+
|
| 318 |
+
# Store conversation history
|
| 319 |
+
session_manager.store_conversation_history(request.session_id)
|
| 320 |
+
|
| 321 |
+
return ChatResponse(
|
| 322 |
+
response=response,
|
| 323 |
+
session_id=request.session_id,
|
| 324 |
+
timestamp=datetime.utcnow(),
|
| 325 |
+
tools_used=None # Will implement tool tracking later
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
except Exception as e:
|
| 329 |
+
import traceback
|
| 330 |
+
error_traceback = traceback.format_exc()
|
| 331 |
+
logger.error(f"Chat error: {str(e)}")
|
| 332 |
+
logger.error(f"Traceback: {error_traceback}")
|
| 333 |
+
raise HTTPException(status_code=500, detail=f"Chat error: {str(e) if str(e) else 'Unknown error'} | Type: {type(e).__name__}")
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@app.post("/chat/stream")
|
| 337 |
+
async def stream_chat(request: ChatRequest):
|
| 338 |
+
"""Stream chat response from the AI assistant."""
|
| 339 |
+
try:
|
| 340 |
+
# Create session if not provided
|
| 341 |
+
if not request.session_id:
|
| 342 |
+
request.session_id = session_manager.create_session()
|
| 343 |
+
|
| 344 |
+
# Get or create conversation
|
| 345 |
+
conversation = session_manager.get_or_create_conversation(request.session_id)
|
| 346 |
+
if not conversation:
|
| 347 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 348 |
+
|
| 349 |
+
async def generate_stream():
|
| 350 |
+
"""Generate streaming response."""
|
| 351 |
+
try:
|
| 352 |
+
for token in conversation.get_streaming_response(request.message):
|
| 353 |
+
chunk = StreamChatResponse(
|
| 354 |
+
token=token,
|
| 355 |
+
session_id=request.session_id,
|
| 356 |
+
is_complete=False
|
| 357 |
+
)
|
| 358 |
+
yield f"data: {chunk.model_dump_json()}\n\n"
|
| 359 |
+
|
| 360 |
+
# Send completion signal
|
| 361 |
+
final_chunk = StreamChatResponse(
|
| 362 |
+
token="",
|
| 363 |
+
session_id=request.session_id,
|
| 364 |
+
is_complete=True
|
| 365 |
+
)
|
| 366 |
+
yield f"data: {final_chunk.model_dump_json()}\n\n"
|
| 367 |
+
|
| 368 |
+
# Store conversation history
|
| 369 |
+
session_manager.store_conversation_history(request.session_id)
|
| 370 |
+
|
| 371 |
+
except Exception as e:
|
| 372 |
+
error_chunk = {
|
| 373 |
+
"error": str(e),
|
| 374 |
+
"session_id": request.session_id,
|
| 375 |
+
"is_complete": True
|
| 376 |
+
}
|
| 377 |
+
yield f"data: {json.dumps(error_chunk)}\n\n"
|
| 378 |
+
|
| 379 |
+
return StreamingResponse(
|
| 380 |
+
generate_stream(),
|
| 381 |
+
media_type="text/plain",
|
| 382 |
+
headers={
|
| 383 |
+
"Cache-Control": "no-cache",
|
| 384 |
+
"Connection": "keep-alive",
|
| 385 |
+
"Access-Control-Allow-Origin": "*",
|
| 386 |
+
}
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
except Exception as e:
|
| 390 |
+
raise HTTPException(status_code=500, detail=f"Stream chat error: {str(e)}")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
@app.get("/auth/login", response_model=AuthResponse)
|
| 394 |
+
async def google_auth_login(request: Request, state: Optional[str] = None):
|
| 395 |
+
"""Initiate Google OAuth login."""
|
| 396 |
+
try:
|
| 397 |
+
auth_url, oauth_state = calendar_auth.get_authorization_url(state)
|
| 398 |
+
|
| 399 |
+
return AuthResponse(
|
| 400 |
+
auth_url=auth_url,
|
| 401 |
+
state=oauth_state
|
| 402 |
+
)
|
| 403 |
+
except Exception as e:
|
| 404 |
+
raise HTTPException(status_code=500, detail=f"Auth error: {str(e)}")
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
@app.get("/auth/callback")
|
| 408 |
+
async def google_auth_callback(request: Request, code: str, state: str):
|
| 409 |
+
"""Handle Google OAuth callback."""
|
| 410 |
+
try:
|
| 411 |
+
# Reconstruct the authorization response URL
|
| 412 |
+
authorization_response = str(request.url)
|
| 413 |
+
|
| 414 |
+
# Exchange code for credentials
|
| 415 |
+
credentials = calendar_auth.handle_callback(authorization_response, state)
|
| 416 |
+
|
| 417 |
+
return {
|
| 418 |
+
"message": "Authentication successful! Your calendar is now connected.",
|
| 419 |
+
"status": "success",
|
| 420 |
+
"expires_at": credentials.expiry.isoformat() if credentials.expiry else None
|
| 421 |
+
}
|
| 422 |
+
except Exception as e:
|
| 423 |
+
raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
@app.get("/auth/status")
|
| 427 |
+
async def auth_status():
|
| 428 |
+
"""Check authentication status."""
|
| 429 |
+
try:
|
| 430 |
+
is_authenticated = calendar_auth.is_authenticated()
|
| 431 |
+
|
| 432 |
+
return {
|
| 433 |
+
"authenticated": is_authenticated,
|
| 434 |
+
"calendar_id": settings.google_calendar_id if is_authenticated else None,
|
| 435 |
+
"message": "Connected to Google Calendar" if is_authenticated else "Not authenticated"
|
| 436 |
+
}
|
| 437 |
+
except Exception as e:
|
| 438 |
+
return {
|
| 439 |
+
"authenticated": False,
|
| 440 |
+
"error": str(e),
|
| 441 |
+
"message": "Authentication check failed"
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
@app.exception_handler(HTTPException)
|
| 446 |
+
async def http_exception_handler(request: Request, exc: HTTPException):
|
| 447 |
+
"""Custom HTTP exception handler."""
|
| 448 |
+
return JSONResponse(
|
| 449 |
+
status_code=exc.status_code,
|
| 450 |
+
content={
|
| 451 |
+
"error": "HTTPException",
|
| 452 |
+
"message": exc.detail,
|
| 453 |
+
"details": {"status_code": exc.status_code},
|
| 454 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 455 |
+
}
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
@app.exception_handler(Exception)
|
| 460 |
+
async def general_exception_handler(request: Request, exc: Exception):
|
| 461 |
+
"""General exception handler."""
|
| 462 |
+
return JSONResponse(
|
| 463 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 464 |
+
content={
|
| 465 |
+
"error": type(exc).__name__,
|
| 466 |
+
"message": str(exc),
|
| 467 |
+
"details": {"path": str(request.url)},
|
| 468 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 469 |
+
}
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
if __name__ == "__main__":
|
| 474 |
+
import uvicorn
|
| 475 |
+
uvicorn.run(
|
| 476 |
+
"app.api.main:app",
|
| 477 |
+
host=settings.app_host,
|
| 478 |
+
port=settings.app_port,
|
| 479 |
+
reload=True if settings.app_env == "development" else False
|
| 480 |
+
)
|
app/api/models.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for API requests and responses."""
|
| 2 |
+
|
| 3 |
+
from typing import Optional, List, Dict, Any
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ChatRequest(BaseModel):
|
| 9 |
+
"""Request model for chat endpoint."""
|
| 10 |
+
message: str = Field(..., description="User message to the chatbot", min_length=1, max_length=1000)
|
| 11 |
+
session_id: Optional[str] = Field(None, description="Session ID for conversation continuity")
|
| 12 |
+
stream: bool = Field(False, description="Whether to stream the response")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ChatResponse(BaseModel):
|
| 16 |
+
"""Response model for chat endpoint."""
|
| 17 |
+
response: str = Field(..., description="Chatbot response")
|
| 18 |
+
session_id: str = Field(..., description="Session ID")
|
| 19 |
+
timestamp: datetime = Field(..., description="Response timestamp")
|
| 20 |
+
tools_used: Optional[List[str]] = Field(None, description="List of tools used in response")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class StreamChatResponse(BaseModel):
|
| 24 |
+
"""Response model for streaming chat."""
|
| 25 |
+
token: str = Field(..., description="Response token")
|
| 26 |
+
session_id: str = Field(..., description="Session ID")
|
| 27 |
+
is_complete: bool = Field(False, description="Whether this is the final token")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SessionCreate(BaseModel):
|
| 31 |
+
"""Request model for creating a new session."""
|
| 32 |
+
user_data: Optional[Dict[str, Any]] = Field(None, description="Optional user data")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class SessionResponse(BaseModel):
|
| 36 |
+
"""Response model for session operations."""
|
| 37 |
+
session_id: str = Field(..., description="Session ID")
|
| 38 |
+
created_at: datetime = Field(..., description="Session creation timestamp")
|
| 39 |
+
last_activity: datetime = Field(..., description="Last activity timestamp")
|
| 40 |
+
is_active: bool = Field(..., description="Whether session is active")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ConversationHistory(BaseModel):
|
| 44 |
+
"""Model for conversation history."""
|
| 45 |
+
session_id: str = Field(..., description="Session ID")
|
| 46 |
+
messages: List[Dict[str, Any]] = Field(..., description="List of conversation messages")
|
| 47 |
+
created_at: datetime = Field(..., description="Conversation start timestamp")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class CalendarEvent(BaseModel):
|
| 51 |
+
"""Model for calendar events."""
|
| 52 |
+
id: Optional[str] = Field(None, description="Event ID")
|
| 53 |
+
summary: str = Field(..., description="Event title/summary")
|
| 54 |
+
start_time: datetime = Field(..., description="Event start time")
|
| 55 |
+
end_time: datetime = Field(..., description="Event end time")
|
| 56 |
+
description: Optional[str] = Field(None, description="Event description")
|
| 57 |
+
location: Optional[str] = Field(None, description="Event location")
|
| 58 |
+
attendees: Optional[List[str]] = Field(None, description="List of attendee emails")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class AvailabilityRequest(BaseModel):
|
| 62 |
+
"""Request model for checking availability."""
|
| 63 |
+
date: str = Field(..., description="Date in natural language (e.g., 'next Tuesday')")
|
| 64 |
+
duration_minutes: int = Field(60, description="Meeting duration in minutes", ge=15, le=480)
|
| 65 |
+
preferred_time: Optional[str] = Field(None, description="Preferred time (e.g., '2pm', 'morning')")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class AvailabilityResponse(BaseModel):
|
| 69 |
+
"""Response model for availability check."""
|
| 70 |
+
date: str = Field(..., description="Formatted date")
|
| 71 |
+
available_slots: List[Dict[str, str]] = Field(..., description="List of available time slots")
|
| 72 |
+
duration_minutes: int = Field(..., description="Requested duration")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class AppointmentRequest(BaseModel):
|
| 76 |
+
"""Request model for creating appointments."""
|
| 77 |
+
title: str = Field(..., description="Meeting title", min_length=1, max_length=200)
|
| 78 |
+
date: str = Field(..., description="Date in natural language")
|
| 79 |
+
time: str = Field(..., description="Time in natural language")
|
| 80 |
+
duration_minutes: int = Field(60, description="Duration in minutes", ge=15, le=480)
|
| 81 |
+
description: Optional[str] = Field(None, description="Meeting description", max_length=1000)
|
| 82 |
+
attendees: Optional[List[str]] = Field(None, description="List of attendee emails")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class AppointmentResponse(BaseModel):
|
| 86 |
+
"""Response model for appointment operations."""
|
| 87 |
+
success: bool = Field(..., description="Whether operation was successful")
|
| 88 |
+
message: str = Field(..., description="Success or error message")
|
| 89 |
+
event: Optional[CalendarEvent] = Field(None, description="Created/updated event details")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class AuthRequest(BaseModel):
|
| 93 |
+
"""Request model for authentication."""
|
| 94 |
+
state: Optional[str] = Field(None, description="OAuth state parameter")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class AuthResponse(BaseModel):
|
| 98 |
+
"""Response model for authentication."""
|
| 99 |
+
auth_url: str = Field(..., description="Google OAuth authorization URL")
|
| 100 |
+
state: str = Field(..., description="OAuth state parameter")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class HealthResponse(BaseModel):
|
| 104 |
+
"""Response model for health check."""
|
| 105 |
+
status: str = Field(..., description="Service status")
|
| 106 |
+
version: str = Field(..., description="Application version")
|
| 107 |
+
timestamp: datetime = Field(..., description="Health check timestamp")
|
| 108 |
+
services: Dict[str, str] = Field(..., description="Status of dependent services")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class ErrorResponse(BaseModel):
|
| 112 |
+
"""Response model for errors."""
|
| 113 |
+
error: str = Field(..., description="Error type")
|
| 114 |
+
message: str = Field(..., description="Error message")
|
| 115 |
+
details: Optional[Dict[str, Any]] = Field(None, description="Additional error details")
|
| 116 |
+
timestamp: datetime = Field(..., description="Error timestamp")
|
app/api/simple_chat.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Simple chat interface for ChatCal.ai."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
from fastapi.responses import HTMLResponse
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
@router.get("/simple-chat", response_class=HTMLResponse)
|
| 9 |
+
async def simple_chat():
|
| 10 |
+
"""Simple working chat interface."""
|
| 11 |
+
return """
|
| 12 |
+
<!DOCTYPE html>
|
| 13 |
+
<html>
|
| 14 |
+
<head>
|
| 15 |
+
<title>ChatCal.ai</title>
|
| 16 |
+
<style>
|
| 17 |
+
body {
|
| 18 |
+
font-family: Arial, sans-serif;
|
| 19 |
+
max-width: 800px;
|
| 20 |
+
margin: 50px auto;
|
| 21 |
+
padding: 20px;
|
| 22 |
+
background: #f5f5f5;
|
| 23 |
+
}
|
| 24 |
+
.chat-box {
|
| 25 |
+
background: white;
|
| 26 |
+
border-radius: 10px;
|
| 27 |
+
padding: 20px;
|
| 28 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 29 |
+
}
|
| 30 |
+
.messages {
|
| 31 |
+
height: 400px;
|
| 32 |
+
overflow-y: auto;
|
| 33 |
+
border: 1px solid #ddd;
|
| 34 |
+
padding: 15px;
|
| 35 |
+
margin-bottom: 20px;
|
| 36 |
+
background: #fafafa;
|
| 37 |
+
border-radius: 5px;
|
| 38 |
+
}
|
| 39 |
+
.message {
|
| 40 |
+
margin: 10px 0;
|
| 41 |
+
padding: 10px;
|
| 42 |
+
border-radius: 5px;
|
| 43 |
+
}
|
| 44 |
+
.user {
|
| 45 |
+
background: #007bff;
|
| 46 |
+
color: white;
|
| 47 |
+
text-align: right;
|
| 48 |
+
margin-left: 20%;
|
| 49 |
+
}
|
| 50 |
+
.assistant {
|
| 51 |
+
background: #e9ecef;
|
| 52 |
+
margin-right: 20%;
|
| 53 |
+
}
|
| 54 |
+
.input-group {
|
| 55 |
+
display: flex;
|
| 56 |
+
gap: 10px;
|
| 57 |
+
}
|
| 58 |
+
input {
|
| 59 |
+
flex: 1;
|
| 60 |
+
padding: 10px;
|
| 61 |
+
border: 1px solid #ddd;
|
| 62 |
+
border-radius: 5px;
|
| 63 |
+
font-size: 16px;
|
| 64 |
+
}
|
| 65 |
+
button {
|
| 66 |
+
padding: 10px 20px;
|
| 67 |
+
background: #28a745;
|
| 68 |
+
color: white;
|
| 69 |
+
border: none;
|
| 70 |
+
border-radius: 5px;
|
| 71 |
+
cursor: pointer;
|
| 72 |
+
font-size: 16px;
|
| 73 |
+
}
|
| 74 |
+
button:hover { background: #218838; }
|
| 75 |
+
button:disabled {
|
| 76 |
+
background: #ccc;
|
| 77 |
+
cursor: not-allowed;
|
| 78 |
+
}
|
| 79 |
+
.loading {
|
| 80 |
+
text-align: center;
|
| 81 |
+
color: #666;
|
| 82 |
+
padding: 10px;
|
| 83 |
+
display: none;
|
| 84 |
+
}
|
| 85 |
+
.error {
|
| 86 |
+
color: red;
|
| 87 |
+
padding: 10px;
|
| 88 |
+
background: #fee;
|
| 89 |
+
border-radius: 5px;
|
| 90 |
+
margin: 10px 0;
|
| 91 |
+
}
|
| 92 |
+
h1 {
|
| 93 |
+
text-align: center;
|
| 94 |
+
color: #333;
|
| 95 |
+
}
|
| 96 |
+
.subtitle {
|
| 97 |
+
text-align: center;
|
| 98 |
+
color: #666;
|
| 99 |
+
margin-bottom: 30px;
|
| 100 |
+
}
|
| 101 |
+
</style>
|
| 102 |
+
</head>
|
| 103 |
+
<body>
|
| 104 |
+
<div class="chat-box">
|
| 105 |
+
<h1>π
Book Time with Peter Michael Gits</h1>
|
| 106 |
+
<p class="subtitle">AI-Powered Scheduling Assistant</p>
|
| 107 |
+
|
| 108 |
+
<div class="messages" id="messages">
|
| 109 |
+
<div class="message assistant">
|
| 110 |
+
π Welcome! I'm ChatCal, Peter Michael Gits' scheduling assistant. I'm here to help you book an appointment with Peter.
|
| 111 |
+
<br><br>
|
| 112 |
+
I can schedule:
|
| 113 |
+
<ul>
|
| 114 |
+
<li>Business consultations (60 min) - in-person or Google Meet</li>
|
| 115 |
+
<li>Quick discussions (30 min) - in-person or Google Meet</li>
|
| 116 |
+
<li>Project meetings (60 min) - in-person or Google Meet</li>
|
| 117 |
+
<li>Advisory sessions (90 min) - in-person or Google Meet</li>
|
| 118 |
+
</ul>
|
| 119 |
+
<strong>π₯ Google Meet:</strong> Just mention "Google Meet", "video call", or "online meeting" and I'll automatically create a video conference link for you!
|
| 120 |
+
<br><br>
|
| 121 |
+
What type of meeting would you like to schedule?
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div class="loading" id="loading">ChatCal is thinking...</div>
|
| 126 |
+
|
| 127 |
+
<div class="input-group">
|
| 128 |
+
<input
|
| 129 |
+
type="text"
|
| 130 |
+
id="input"
|
| 131 |
+
placeholder="Tell me what you'd like to discuss with Peter..."
|
| 132 |
+
onkeypress="if(event.key==='Enter') send()"
|
| 133 |
+
>
|
| 134 |
+
<button onclick="send()" id="sendBtn">Send</button>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<script>
|
| 139 |
+
let sessionId = null;
|
| 140 |
+
let busy = false;
|
| 141 |
+
|
| 142 |
+
// Get elements
|
| 143 |
+
const input = document.getElementById('input');
|
| 144 |
+
const messages = document.getElementById('messages');
|
| 145 |
+
const loading = document.getElementById('loading');
|
| 146 |
+
const sendBtn = document.getElementById('sendBtn');
|
| 147 |
+
|
| 148 |
+
// Initialize session
|
| 149 |
+
fetch('/sessions', {
|
| 150 |
+
method: 'POST',
|
| 151 |
+
headers: {'Content-Type': 'application/json'},
|
| 152 |
+
body: JSON.stringify({})
|
| 153 |
+
})
|
| 154 |
+
.then(r => r.json())
|
| 155 |
+
.then(data => {
|
| 156 |
+
sessionId = data.session_id;
|
| 157 |
+
console.log('Session:', sessionId);
|
| 158 |
+
})
|
| 159 |
+
.catch(err => {
|
| 160 |
+
showMessage('Error: Could not start session', 'error');
|
| 161 |
+
console.error(err);
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
function showMessage(text, type='assistant') {
|
| 165 |
+
const div = document.createElement('div');
|
| 166 |
+
div.className = 'message ' + type;
|
| 167 |
+
// For assistant messages, render HTML; for user messages, use text only
|
| 168 |
+
if (type === 'assistant') {
|
| 169 |
+
div.innerHTML = text;
|
| 170 |
+
} else {
|
| 171 |
+
div.textContent = text;
|
| 172 |
+
}
|
| 173 |
+
messages.appendChild(div);
|
| 174 |
+
messages.scrollTop = messages.scrollHeight;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
async function send() {
|
| 178 |
+
const text = input.value.trim();
|
| 179 |
+
if (!text || busy || !sessionId) return;
|
| 180 |
+
|
| 181 |
+
// Show user message
|
| 182 |
+
showMessage(text, 'user');
|
| 183 |
+
input.value = '';
|
| 184 |
+
|
| 185 |
+
// Disable while sending
|
| 186 |
+
busy = true;
|
| 187 |
+
sendBtn.disabled = true;
|
| 188 |
+
loading.style.display = 'block';
|
| 189 |
+
|
| 190 |
+
try {
|
| 191 |
+
const response = await fetch('/chat', {
|
| 192 |
+
method: 'POST',
|
| 193 |
+
headers: {'Content-Type': 'application/json'},
|
| 194 |
+
body: JSON.stringify({
|
| 195 |
+
message: text,
|
| 196 |
+
session_id: sessionId
|
| 197 |
+
})
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
const data = await response.json();
|
| 201 |
+
|
| 202 |
+
if (response.ok) {
|
| 203 |
+
showMessage(data.response, 'assistant');
|
| 204 |
+
} else {
|
| 205 |
+
showMessage('Error: ' + (data.message || 'Something went wrong'), 'error');
|
| 206 |
+
}
|
| 207 |
+
} catch (err) {
|
| 208 |
+
showMessage('Error: Connection failed', 'error');
|
| 209 |
+
console.error(err);
|
| 210 |
+
} finally {
|
| 211 |
+
busy = false;
|
| 212 |
+
sendBtn.disabled = false;
|
| 213 |
+
loading.style.display = 'none';
|
| 214 |
+
input.focus();
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
</script>
|
| 218 |
+
</body>
|
| 219 |
+
</html>
|
| 220 |
+
"""
|
app/calendar/__init__.py
ADDED
|
File without changes
|
app/calendar/auth.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Calendar OAuth2 authentication handling."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
from typing import Optional, Dict
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from google.auth.transport.requests import Request
|
| 8 |
+
from google.oauth2.credentials import Credentials
|
| 9 |
+
from google_auth_oauthlib.flow import Flow
|
| 10 |
+
from googleapiclient.discovery import build
|
| 11 |
+
from googleapiclient.errors import HttpError
|
| 12 |
+
from app.config import settings
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CalendarAuth:
|
| 16 |
+
"""Handles Google Calendar OAuth2 authentication."""
|
| 17 |
+
|
| 18 |
+
# OAuth2 scopes required for calendar access
|
| 19 |
+
SCOPES = [
|
| 20 |
+
'https://www.googleapis.com/auth/calendar',
|
| 21 |
+
'https://www.googleapis.com/auth/calendar.events'
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
def __init__(self):
|
| 25 |
+
self.client_id = settings.google_client_id
|
| 26 |
+
self.client_secret = settings.google_client_secret
|
| 27 |
+
self.redirect_uri = "http://localhost:8000/auth/callback"
|
| 28 |
+
self.credentials_path = settings.google_credentials_path
|
| 29 |
+
self._service = None
|
| 30 |
+
|
| 31 |
+
def create_auth_flow(self, state: Optional[str] = None) -> Flow:
|
| 32 |
+
"""Create OAuth2 flow for authentication."""
|
| 33 |
+
client_config = {
|
| 34 |
+
"web": {
|
| 35 |
+
"client_id": self.client_id,
|
| 36 |
+
"client_secret": self.client_secret,
|
| 37 |
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
| 38 |
+
"token_uri": "https://oauth2.googleapis.com/token",
|
| 39 |
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
| 40 |
+
"redirect_uris": [self.redirect_uri]
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
flow = Flow.from_client_config(
|
| 45 |
+
client_config,
|
| 46 |
+
scopes=self.SCOPES,
|
| 47 |
+
state=state
|
| 48 |
+
)
|
| 49 |
+
flow.redirect_uri = self.redirect_uri
|
| 50 |
+
|
| 51 |
+
# Allow insecure transport for local development
|
| 52 |
+
import os
|
| 53 |
+
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'
|
| 54 |
+
|
| 55 |
+
return flow
|
| 56 |
+
|
| 57 |
+
def get_authorization_url(self, state: Optional[str] = None) -> tuple[str, str]:
|
| 58 |
+
"""Generate authorization URL for OAuth2 flow."""
|
| 59 |
+
flow = self.create_auth_flow(state)
|
| 60 |
+
|
| 61 |
+
authorization_url, state = flow.authorization_url(
|
| 62 |
+
access_type='offline',
|
| 63 |
+
include_granted_scopes='true',
|
| 64 |
+
prompt='consent' # Force consent to get refresh token
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return authorization_url, state
|
| 68 |
+
|
| 69 |
+
def handle_callback(self, authorization_response: str, state: str) -> Credentials:
|
| 70 |
+
"""Handle OAuth2 callback and exchange code for credentials."""
|
| 71 |
+
flow = self.create_auth_flow(state)
|
| 72 |
+
flow.fetch_token(authorization_response=authorization_response)
|
| 73 |
+
|
| 74 |
+
credentials = flow.credentials
|
| 75 |
+
self.save_credentials(credentials)
|
| 76 |
+
|
| 77 |
+
return credentials
|
| 78 |
+
|
| 79 |
+
def save_credentials(self, credentials: Credentials):
|
| 80 |
+
"""Save credentials to both file and config for future use."""
|
| 81 |
+
os.makedirs(os.path.dirname(self.credentials_path), exist_ok=True)
|
| 82 |
+
|
| 83 |
+
creds_data = {
|
| 84 |
+
'token': credentials.token,
|
| 85 |
+
'refresh_token': credentials.refresh_token,
|
| 86 |
+
'token_uri': credentials.token_uri,
|
| 87 |
+
'client_id': credentials.client_id,
|
| 88 |
+
'client_secret': credentials.client_secret,
|
| 89 |
+
'scopes': credentials.scopes,
|
| 90 |
+
'expiry': credentials.expiry.isoformat() if credentials.expiry else None
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
# Save to JSON file
|
| 94 |
+
with open(self.credentials_path, 'w') as f:
|
| 95 |
+
json.dump(creds_data, f)
|
| 96 |
+
|
| 97 |
+
# Also save to environment variables for local development
|
| 98 |
+
print("πΎ Saving credentials to environment config")
|
| 99 |
+
print(f" Access Token: {credentials.token[:20]}...")
|
| 100 |
+
print(f" Refresh Token: {credentials.refresh_token[:20] if credentials.refresh_token else 'None'}...")
|
| 101 |
+
print(f" Expiry: {credentials.expiry}")
|
| 102 |
+
|
| 103 |
+
# Update .env file with new tokens
|
| 104 |
+
self._update_env_file(credentials)
|
| 105 |
+
|
| 106 |
+
def _update_env_file(self, credentials: Credentials):
|
| 107 |
+
"""Update .env file with new OAuth tokens."""
|
| 108 |
+
try:
|
| 109 |
+
env_path = ".env"
|
| 110 |
+
if not os.path.exists(env_path):
|
| 111 |
+
return
|
| 112 |
+
|
| 113 |
+
# Read current .env file
|
| 114 |
+
with open(env_path, 'r') as f:
|
| 115 |
+
lines = f.readlines()
|
| 116 |
+
|
| 117 |
+
# Update the token lines
|
| 118 |
+
updated_lines = []
|
| 119 |
+
for line in lines:
|
| 120 |
+
if line.startswith('GOOGLE_ACCESS_TOKEN='):
|
| 121 |
+
updated_lines.append(f'GOOGLE_ACCESS_TOKEN={credentials.token}\n')
|
| 122 |
+
elif line.startswith('GOOGLE_REFRESH_TOKEN=') and credentials.refresh_token:
|
| 123 |
+
updated_lines.append(f'GOOGLE_REFRESH_TOKEN={credentials.refresh_token}\n')
|
| 124 |
+
elif line.startswith('GOOGLE_TOKEN_EXPIRY=') and credentials.expiry:
|
| 125 |
+
updated_lines.append(f'GOOGLE_TOKEN_EXPIRY={credentials.expiry.isoformat()}\n')
|
| 126 |
+
else:
|
| 127 |
+
updated_lines.append(line)
|
| 128 |
+
|
| 129 |
+
# Write back to .env file
|
| 130 |
+
with open(env_path, 'w') as f:
|
| 131 |
+
f.writelines(updated_lines)
|
| 132 |
+
|
| 133 |
+
print("β
Updated .env file with new tokens")
|
| 134 |
+
except Exception as e:
|
| 135 |
+
print(f"β οΈ Failed to update .env file: {e}")
|
| 136 |
+
|
| 137 |
+
def load_credentials(self) -> Optional[Credentials]:
|
| 138 |
+
"""Load saved credentials from config or file."""
|
| 139 |
+
from app.config import settings
|
| 140 |
+
|
| 141 |
+
# First try to load from environment/config
|
| 142 |
+
if (settings.google_access_token and
|
| 143 |
+
settings.google_refresh_token and
|
| 144 |
+
settings.google_token_expiry):
|
| 145 |
+
print("π§ Loading credentials from environment config")
|
| 146 |
+
creds_data = {
|
| 147 |
+
'token': settings.google_access_token,
|
| 148 |
+
'refresh_token': settings.google_refresh_token,
|
| 149 |
+
'token_uri': 'https://oauth2.googleapis.com/token',
|
| 150 |
+
'client_id': settings.google_client_id,
|
| 151 |
+
'client_secret': settings.google_client_secret,
|
| 152 |
+
'scopes': self.SCOPES,
|
| 153 |
+
'expiry': settings.google_token_expiry
|
| 154 |
+
}
|
| 155 |
+
else:
|
| 156 |
+
# Fall back to JSON file
|
| 157 |
+
if not os.path.exists(self.credentials_path):
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
print("π Loading credentials from JSON file")
|
| 161 |
+
with open(self.credentials_path, 'r') as f:
|
| 162 |
+
creds_data = json.load(f)
|
| 163 |
+
|
| 164 |
+
credentials = Credentials(
|
| 165 |
+
token=creds_data['token'],
|
| 166 |
+
refresh_token=creds_data.get('refresh_token'),
|
| 167 |
+
token_uri=creds_data['token_uri'],
|
| 168 |
+
client_id=creds_data['client_id'],
|
| 169 |
+
client_secret=creds_data['client_secret'],
|
| 170 |
+
scopes=creds_data['scopes']
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Set expiry if available
|
| 174 |
+
if creds_data.get('expiry'):
|
| 175 |
+
credentials.expiry = datetime.fromisoformat(creds_data['expiry'])
|
| 176 |
+
print(f"π Loaded credentials with expiry: {credentials.expiry}")
|
| 177 |
+
print(f"π Credentials expired: {credentials.expired}")
|
| 178 |
+
|
| 179 |
+
# Refresh if expired
|
| 180 |
+
if credentials.expired and credentials.refresh_token:
|
| 181 |
+
try:
|
| 182 |
+
print(f"π Refreshing expired credentials...")
|
| 183 |
+
credentials.refresh(Request())
|
| 184 |
+
self.save_credentials(credentials)
|
| 185 |
+
print(f"β
Credentials refreshed successfully")
|
| 186 |
+
except Exception as e:
|
| 187 |
+
print(f"β Failed to refresh credentials: {e}")
|
| 188 |
+
return None
|
| 189 |
+
|
| 190 |
+
return credentials
|
| 191 |
+
|
| 192 |
+
def get_calendar_service(self, credentials: Optional[Credentials] = None):
|
| 193 |
+
"""Get authenticated Google Calendar service."""
|
| 194 |
+
if not credentials:
|
| 195 |
+
credentials = self.load_credentials()
|
| 196 |
+
|
| 197 |
+
if not credentials:
|
| 198 |
+
raise ValueError("No valid credentials available. Please authenticate first.")
|
| 199 |
+
|
| 200 |
+
if not self._service:
|
| 201 |
+
self._service = build('calendar', 'v3', credentials=credentials)
|
| 202 |
+
|
| 203 |
+
return self._service
|
| 204 |
+
|
| 205 |
+
def revoke_credentials(self):
|
| 206 |
+
"""Revoke stored credentials."""
|
| 207 |
+
credentials = self.load_credentials()
|
| 208 |
+
if credentials:
|
| 209 |
+
try:
|
| 210 |
+
credentials.revoke(Request())
|
| 211 |
+
except:
|
| 212 |
+
pass # Ignore revocation errors
|
| 213 |
+
|
| 214 |
+
# Remove credentials file
|
| 215 |
+
if os.path.exists(self.credentials_path):
|
| 216 |
+
os.remove(self.credentials_path)
|
| 217 |
+
|
| 218 |
+
self._service = None
|
| 219 |
+
|
| 220 |
+
def is_authenticated(self) -> bool:
|
| 221 |
+
"""Check if valid credentials exist."""
|
| 222 |
+
try:
|
| 223 |
+
credentials = self.load_credentials()
|
| 224 |
+
return credentials is not None and credentials.valid
|
| 225 |
+
except:
|
| 226 |
+
return False
|
app/calendar/service.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Calendar service for managing calendar operations."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timedelta, timezone
|
| 4 |
+
from typing import List, Dict, Optional, Tuple
|
| 5 |
+
import pytz
|
| 6 |
+
from googleapiclient.errors import HttpError
|
| 7 |
+
from app.calendar.auth import CalendarAuth
|
| 8 |
+
from app.config import settings
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class CalendarService:
|
| 12 |
+
"""Service for interacting with Google Calendar."""
|
| 13 |
+
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.auth = None # Lazy initialization
|
| 16 |
+
self.calendar_id = settings.google_calendar_id
|
| 17 |
+
self.default_timezone = pytz.timezone(settings.default_timezone)
|
| 18 |
+
|
| 19 |
+
def _get_service(self):
|
| 20 |
+
"""Get authenticated calendar service."""
|
| 21 |
+
if self.auth is None:
|
| 22 |
+
from app.calendar.auth import CalendarAuth
|
| 23 |
+
self.auth = CalendarAuth()
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
service = self.auth.get_calendar_service()
|
| 27 |
+
if not service:
|
| 28 |
+
raise RuntimeError("Failed to get calendar service - authentication required")
|
| 29 |
+
return service
|
| 30 |
+
except Exception as e:
|
| 31 |
+
print(f"β οΈ Calendar service error: {e}")
|
| 32 |
+
# Check for authentication errors
|
| 33 |
+
if "invalid_grant" in str(e) or "Token has been expired" in str(e):
|
| 34 |
+
return None # Return None instead of raising for auth errors
|
| 35 |
+
raise
|
| 36 |
+
|
| 37 |
+
def list_events(
|
| 38 |
+
self,
|
| 39 |
+
time_min: Optional[datetime] = None,
|
| 40 |
+
time_max: Optional[datetime] = None,
|
| 41 |
+
max_results: int = 10,
|
| 42 |
+
single_events: bool = True
|
| 43 |
+
) -> List[Dict]:
|
| 44 |
+
"""List calendar events within a time range."""
|
| 45 |
+
try:
|
| 46 |
+
service = self._get_service()
|
| 47 |
+
if not service:
|
| 48 |
+
raise RuntimeError("Calendar service not authenticated")
|
| 49 |
+
|
| 50 |
+
# Validate inputs
|
| 51 |
+
if max_results < 1 or max_results > 2500:
|
| 52 |
+
raise ValueError("max_results must be between 1 and 2500")
|
| 53 |
+
|
| 54 |
+
# Default to next 7 days if not specified
|
| 55 |
+
if not time_min:
|
| 56 |
+
time_min = datetime.now(self.default_timezone)
|
| 57 |
+
if not time_max:
|
| 58 |
+
time_max = time_min + timedelta(days=7)
|
| 59 |
+
|
| 60 |
+
# Validate time range
|
| 61 |
+
if time_min >= time_max:
|
| 62 |
+
raise ValueError("time_min must be before time_max")
|
| 63 |
+
|
| 64 |
+
# Convert to RFC3339 timestamp
|
| 65 |
+
time_min_str = time_min.isoformat()
|
| 66 |
+
time_max_str = time_max.isoformat()
|
| 67 |
+
|
| 68 |
+
events_result = service.events().list(
|
| 69 |
+
calendarId=self.calendar_id,
|
| 70 |
+
timeMin=time_min_str,
|
| 71 |
+
timeMax=time_max_str,
|
| 72 |
+
maxResults=max_results,
|
| 73 |
+
singleEvents=single_events,
|
| 74 |
+
orderBy='startTime'
|
| 75 |
+
).execute()
|
| 76 |
+
|
| 77 |
+
return events_result.get('items', [])
|
| 78 |
+
|
| 79 |
+
except HttpError as error:
|
| 80 |
+
print(f'An error occurred: {error}')
|
| 81 |
+
return []
|
| 82 |
+
|
| 83 |
+
def get_availability(
|
| 84 |
+
self,
|
| 85 |
+
date: datetime,
|
| 86 |
+
duration_minutes: int = 60,
|
| 87 |
+
start_hour: int = 9,
|
| 88 |
+
end_hour: int = 17,
|
| 89 |
+
interval_minutes: int = 15
|
| 90 |
+
) -> List[Tuple[datetime, datetime]]:
|
| 91 |
+
"""Find available time slots on a given date."""
|
| 92 |
+
# Ensure date is timezone-aware
|
| 93 |
+
if date.tzinfo is None:
|
| 94 |
+
date = self.default_timezone.localize(date)
|
| 95 |
+
|
| 96 |
+
# Set working hours for the day
|
| 97 |
+
start_time = date.replace(hour=start_hour, minute=0, second=0, microsecond=0)
|
| 98 |
+
end_time = date.replace(hour=end_hour, minute=0, second=0, microsecond=0)
|
| 99 |
+
|
| 100 |
+
# Get existing events for the day
|
| 101 |
+
events = self.list_events(
|
| 102 |
+
time_min=start_time,
|
| 103 |
+
time_max=end_time,
|
| 104 |
+
max_results=50
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Build list of busy times
|
| 108 |
+
busy_times = []
|
| 109 |
+
for event in events:
|
| 110 |
+
if 'dateTime' in event.get('start', {}):
|
| 111 |
+
start = datetime.fromisoformat(event['start']['dateTime'])
|
| 112 |
+
end = datetime.fromisoformat(event['end']['dateTime'])
|
| 113 |
+
busy_times.append((start, end))
|
| 114 |
+
|
| 115 |
+
# Sort busy times
|
| 116 |
+
busy_times.sort(key=lambda x: x[0])
|
| 117 |
+
|
| 118 |
+
# Find available slots
|
| 119 |
+
available_slots = []
|
| 120 |
+
current_time = start_time
|
| 121 |
+
|
| 122 |
+
while current_time + timedelta(minutes=duration_minutes) <= end_time:
|
| 123 |
+
slot_end = current_time + timedelta(minutes=duration_minutes)
|
| 124 |
+
|
| 125 |
+
# Check if this slot conflicts with any busy time
|
| 126 |
+
is_available = True
|
| 127 |
+
for busy_start, busy_end in busy_times:
|
| 128 |
+
if not (slot_end <= busy_start or current_time >= busy_end):
|
| 129 |
+
is_available = False
|
| 130 |
+
break
|
| 131 |
+
|
| 132 |
+
if is_available:
|
| 133 |
+
available_slots.append((current_time, slot_end))
|
| 134 |
+
|
| 135 |
+
# Move to next interval
|
| 136 |
+
current_time += timedelta(minutes=interval_minutes)
|
| 137 |
+
|
| 138 |
+
return available_slots
|
| 139 |
+
|
| 140 |
+
def create_event(
|
| 141 |
+
self,
|
| 142 |
+
summary: str,
|
| 143 |
+
start_time: datetime,
|
| 144 |
+
end_time: datetime,
|
| 145 |
+
description: Optional[str] = None,
|
| 146 |
+
attendees: Optional[List[str]] = None,
|
| 147 |
+
location: Optional[str] = None,
|
| 148 |
+
send_notifications: bool = True,
|
| 149 |
+
create_meet_conference: bool = False
|
| 150 |
+
) -> Dict:
|
| 151 |
+
"""Create a new calendar event."""
|
| 152 |
+
service = self._get_service()
|
| 153 |
+
|
| 154 |
+
# Ensure times are timezone-aware
|
| 155 |
+
if start_time.tzinfo is None:
|
| 156 |
+
start_time = self.default_timezone.localize(start_time)
|
| 157 |
+
if end_time.tzinfo is None:
|
| 158 |
+
end_time = self.default_timezone.localize(end_time)
|
| 159 |
+
|
| 160 |
+
event = {
|
| 161 |
+
'summary': summary,
|
| 162 |
+
'start': {
|
| 163 |
+
'dateTime': start_time.isoformat(),
|
| 164 |
+
'timeZone': str(self.default_timezone),
|
| 165 |
+
},
|
| 166 |
+
'end': {
|
| 167 |
+
'dateTime': end_time.isoformat(),
|
| 168 |
+
'timeZone': str(self.default_timezone),
|
| 169 |
+
},
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if description:
|
| 173 |
+
event['description'] = description
|
| 174 |
+
|
| 175 |
+
if location:
|
| 176 |
+
event['location'] = location
|
| 177 |
+
|
| 178 |
+
if attendees:
|
| 179 |
+
event['attendees'] = [{'email': email} for email in attendees]
|
| 180 |
+
|
| 181 |
+
# Add Google Meet conference if requested
|
| 182 |
+
if create_meet_conference:
|
| 183 |
+
import uuid
|
| 184 |
+
event['conferenceData'] = {
|
| 185 |
+
'createRequest': {
|
| 186 |
+
'requestId': str(uuid.uuid4()), # Generate unique request ID
|
| 187 |
+
'conferenceSolutionKey': {
|
| 188 |
+
'type': 'hangoutsMeet'
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
# Use conferenceDataVersion=1 if creating a Meet conference
|
| 195 |
+
conference_version = 1 if create_meet_conference else None
|
| 196 |
+
|
| 197 |
+
created_event = service.events().insert(
|
| 198 |
+
calendarId=self.calendar_id,
|
| 199 |
+
body=event,
|
| 200 |
+
sendNotifications=send_notifications,
|
| 201 |
+
conferenceDataVersion=conference_version
|
| 202 |
+
).execute()
|
| 203 |
+
|
| 204 |
+
return created_event
|
| 205 |
+
|
| 206 |
+
except HttpError as error:
|
| 207 |
+
print(f'An error occurred: {error}')
|
| 208 |
+
raise
|
| 209 |
+
|
| 210 |
+
def update_event(
|
| 211 |
+
self,
|
| 212 |
+
event_id: str,
|
| 213 |
+
updates: Dict,
|
| 214 |
+
send_notifications: bool = True
|
| 215 |
+
) -> Dict:
|
| 216 |
+
"""Update an existing calendar event."""
|
| 217 |
+
service = self._get_service()
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
# Get the existing event
|
| 221 |
+
event = service.events().get(
|
| 222 |
+
calendarId=self.calendar_id,
|
| 223 |
+
eventId=event_id
|
| 224 |
+
).execute()
|
| 225 |
+
|
| 226 |
+
# Apply updates
|
| 227 |
+
event.update(updates)
|
| 228 |
+
|
| 229 |
+
# Update the event
|
| 230 |
+
updated_event = service.events().update(
|
| 231 |
+
calendarId=self.calendar_id,
|
| 232 |
+
eventId=event_id,
|
| 233 |
+
body=event,
|
| 234 |
+
sendNotifications=send_notifications
|
| 235 |
+
).execute()
|
| 236 |
+
|
| 237 |
+
return updated_event
|
| 238 |
+
|
| 239 |
+
except HttpError as error:
|
| 240 |
+
print(f'An error occurred: {error}')
|
| 241 |
+
raise
|
| 242 |
+
|
| 243 |
+
def delete_event(
|
| 244 |
+
self,
|
| 245 |
+
event_id: str,
|
| 246 |
+
send_notifications: bool = True
|
| 247 |
+
) -> bool:
|
| 248 |
+
"""Delete a calendar event."""
|
| 249 |
+
service = self._get_service()
|
| 250 |
+
|
| 251 |
+
try:
|
| 252 |
+
service.events().delete(
|
| 253 |
+
calendarId=self.calendar_id,
|
| 254 |
+
eventId=event_id,
|
| 255 |
+
sendNotifications=send_notifications
|
| 256 |
+
).execute()
|
| 257 |
+
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
except HttpError as error:
|
| 261 |
+
print(f'An error occurred: {error}')
|
| 262 |
+
return False
|
| 263 |
+
|
| 264 |
+
def get_event(self, event_id: str) -> Optional[Dict]:
|
| 265 |
+
"""Get a specific event by ID."""
|
| 266 |
+
service = self._get_service()
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
event = service.events().get(
|
| 270 |
+
calendarId=self.calendar_id,
|
| 271 |
+
eventId=event_id
|
| 272 |
+
).execute()
|
| 273 |
+
|
| 274 |
+
return event
|
| 275 |
+
|
| 276 |
+
except HttpError as error:
|
| 277 |
+
print(f'An error occurred: {error}')
|
| 278 |
+
return None
|
| 279 |
+
|
| 280 |
+
def check_conflicts(
|
| 281 |
+
self,
|
| 282 |
+
start_time: datetime,
|
| 283 |
+
end_time: datetime,
|
| 284 |
+
exclude_event_id: Optional[str] = None
|
| 285 |
+
) -> List[Dict]:
|
| 286 |
+
"""Check for conflicting events in the given time range."""
|
| 287 |
+
events = self.list_events(
|
| 288 |
+
time_min=start_time,
|
| 289 |
+
time_max=end_time,
|
| 290 |
+
max_results=50
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
conflicts = []
|
| 294 |
+
for event in events:
|
| 295 |
+
# Skip if this is the event we're updating
|
| 296 |
+
if exclude_event_id and event.get('id') == exclude_event_id:
|
| 297 |
+
continue
|
| 298 |
+
|
| 299 |
+
# Check for overlap
|
| 300 |
+
if 'dateTime' in event.get('start', {}):
|
| 301 |
+
event_start = datetime.fromisoformat(event['start']['dateTime'])
|
| 302 |
+
event_end = datetime.fromisoformat(event['end']['dateTime'])
|
| 303 |
+
|
| 304 |
+
# Check if there's an overlap
|
| 305 |
+
if not (end_time <= event_start or start_time >= event_end):
|
| 306 |
+
conflicts.append(event)
|
| 307 |
+
|
| 308 |
+
return conflicts
|
app/calendar/utils.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Calendar utility functions for date/time parsing and formatting."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Optional, Tuple, List, Dict
|
| 6 |
+
import pytz
|
| 7 |
+
from dateutil import parser
|
| 8 |
+
from dateutil.relativedelta import relativedelta
|
| 9 |
+
from app.config import settings
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DateTimeParser:
|
| 13 |
+
"""Utility class for parsing natural language date/time expressions."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, default_timezone: str = None):
|
| 16 |
+
self.default_timezone = pytz.timezone(default_timezone or settings.default_timezone)
|
| 17 |
+
self.now = datetime.now(self.default_timezone)
|
| 18 |
+
|
| 19 |
+
def parse_datetime(self, text: str) -> Optional[datetime]:
|
| 20 |
+
"""Parse various datetime formats from natural language."""
|
| 21 |
+
text = text.lower().strip()
|
| 22 |
+
|
| 23 |
+
# Handle combined date and time with "and" (e.g., "tomorrow and 1.15")
|
| 24 |
+
if " and " in text:
|
| 25 |
+
parts = text.split(" and ")
|
| 26 |
+
if len(parts) == 2:
|
| 27 |
+
date_part, time_part = parts
|
| 28 |
+
|
| 29 |
+
# Parse date part
|
| 30 |
+
base_date = self._parse_relative_date(date_part.strip())
|
| 31 |
+
if not base_date:
|
| 32 |
+
base_date = self._parse_day_name(date_part.strip())
|
| 33 |
+
|
| 34 |
+
# Parse time part
|
| 35 |
+
if base_date:
|
| 36 |
+
time_tuple = self.parse_time(time_part.strip())
|
| 37 |
+
if time_tuple:
|
| 38 |
+
hour, minute = time_tuple
|
| 39 |
+
return base_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 40 |
+
|
| 41 |
+
# Handle combined date and time with "at" (e.g., "tomorrow at 1pm")
|
| 42 |
+
if " at " in text:
|
| 43 |
+
parts = text.split(" at ")
|
| 44 |
+
if len(parts) == 2:
|
| 45 |
+
date_part, time_part = parts
|
| 46 |
+
|
| 47 |
+
# Parse date part
|
| 48 |
+
base_date = self._parse_relative_date(date_part.strip())
|
| 49 |
+
if not base_date:
|
| 50 |
+
base_date = self._parse_day_name(date_part.strip())
|
| 51 |
+
|
| 52 |
+
# Parse time part
|
| 53 |
+
if base_date:
|
| 54 |
+
time_tuple = self.parse_time(time_part.strip())
|
| 55 |
+
if time_tuple:
|
| 56 |
+
hour, minute = time_tuple
|
| 57 |
+
return base_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 58 |
+
|
| 59 |
+
# Try standard parsing first
|
| 60 |
+
try:
|
| 61 |
+
dt = parser.parse(text, fuzzy=True)
|
| 62 |
+
if dt.tzinfo is None:
|
| 63 |
+
dt = self.default_timezone.localize(dt)
|
| 64 |
+
return dt
|
| 65 |
+
except:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
# Handle relative dates
|
| 69 |
+
relative_date = self._parse_relative_date(text)
|
| 70 |
+
if relative_date:
|
| 71 |
+
return relative_date
|
| 72 |
+
|
| 73 |
+
# Handle day names
|
| 74 |
+
day_date = self._parse_day_name(text)
|
| 75 |
+
if day_date:
|
| 76 |
+
return day_date
|
| 77 |
+
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
def _parse_relative_date(self, text: str) -> Optional[datetime]:
|
| 81 |
+
"""Parse relative dates like 'tomorrow', 'next week', etc."""
|
| 82 |
+
base_time = self.now.replace(hour=9, minute=0, second=0, microsecond=0)
|
| 83 |
+
|
| 84 |
+
# Today/tonight
|
| 85 |
+
if any(word in text for word in ['today', 'tonight']):
|
| 86 |
+
return base_time
|
| 87 |
+
|
| 88 |
+
# Tomorrow
|
| 89 |
+
if 'tomorrow' in text:
|
| 90 |
+
return base_time + timedelta(days=1)
|
| 91 |
+
|
| 92 |
+
# Yesterday
|
| 93 |
+
if 'yesterday' in text:
|
| 94 |
+
return base_time - timedelta(days=1)
|
| 95 |
+
|
| 96 |
+
# Next week
|
| 97 |
+
if 'next week' in text:
|
| 98 |
+
days_ahead = 7 - base_time.weekday()
|
| 99 |
+
return base_time + timedelta(days=days_ahead)
|
| 100 |
+
|
| 101 |
+
# This week
|
| 102 |
+
if 'this week' in text:
|
| 103 |
+
return base_time
|
| 104 |
+
|
| 105 |
+
# Next month
|
| 106 |
+
if 'next month' in text:
|
| 107 |
+
return base_time + relativedelta(months=1)
|
| 108 |
+
|
| 109 |
+
# In X days/weeks/months
|
| 110 |
+
match = re.search(r'in (\d+) (day|week|month)s?', text)
|
| 111 |
+
if match:
|
| 112 |
+
number = int(match.group(1))
|
| 113 |
+
unit = match.group(2)
|
| 114 |
+
|
| 115 |
+
if unit == 'day':
|
| 116 |
+
return base_time + timedelta(days=number)
|
| 117 |
+
elif unit == 'week':
|
| 118 |
+
return base_time + timedelta(weeks=number)
|
| 119 |
+
elif unit == 'month':
|
| 120 |
+
return base_time + relativedelta(months=number)
|
| 121 |
+
|
| 122 |
+
return None
|
| 123 |
+
|
| 124 |
+
def _parse_day_name(self, text: str) -> Optional[datetime]:
|
| 125 |
+
"""Parse day names like 'monday', 'next tuesday', etc."""
|
| 126 |
+
days = {
|
| 127 |
+
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
|
| 128 |
+
'friday': 4, 'saturday': 5, 'sunday': 6
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
for day_name, day_num in days.items():
|
| 132 |
+
if day_name in text:
|
| 133 |
+
current_day = self.now.weekday()
|
| 134 |
+
days_ahead = day_num - current_day
|
| 135 |
+
|
| 136 |
+
# If it's the same day, assume next week if "next" is mentioned
|
| 137 |
+
if days_ahead <= 0 or 'next' in text:
|
| 138 |
+
days_ahead += 7
|
| 139 |
+
|
| 140 |
+
target_date = self.now + timedelta(days=days_ahead)
|
| 141 |
+
return target_date.replace(hour=9, minute=0, second=0, microsecond=0)
|
| 142 |
+
|
| 143 |
+
return None
|
| 144 |
+
|
| 145 |
+
def parse_time(self, text: str) -> Optional[Tuple[int, int]]:
|
| 146 |
+
"""Parse time from text, returning (hour, minute) tuple."""
|
| 147 |
+
text = text.lower().strip()
|
| 148 |
+
|
| 149 |
+
# Handle AM/PM format
|
| 150 |
+
am_pm_match = re.search(r'(\d{1,2})(?::(\d{2}))?\s*(am|pm)', text)
|
| 151 |
+
if am_pm_match:
|
| 152 |
+
hour = int(am_pm_match.group(1))
|
| 153 |
+
minute = int(am_pm_match.group(2)) if am_pm_match.group(2) else 0
|
| 154 |
+
is_pm = am_pm_match.group(3) == 'pm'
|
| 155 |
+
|
| 156 |
+
if is_pm and hour != 12:
|
| 157 |
+
hour += 12
|
| 158 |
+
elif not is_pm and hour == 12:
|
| 159 |
+
hour = 0
|
| 160 |
+
|
| 161 |
+
return (hour, minute)
|
| 162 |
+
|
| 163 |
+
# Handle 24-hour format (1:15, 13:30)
|
| 164 |
+
time_match = re.search(r'(\d{1,2}):(\d{2})', text)
|
| 165 |
+
if time_match:
|
| 166 |
+
hour = int(time_match.group(1))
|
| 167 |
+
minute = int(time_match.group(2))
|
| 168 |
+
return (hour, minute)
|
| 169 |
+
|
| 170 |
+
# Handle dot format (1.15, 13.30) - common European format
|
| 171 |
+
dot_time_match = re.search(r'(\d{1,2})\.(\d{2})', text)
|
| 172 |
+
if dot_time_match:
|
| 173 |
+
hour = int(dot_time_match.group(1))
|
| 174 |
+
minute = int(dot_time_match.group(2))
|
| 175 |
+
# For times like 1.15, assume PM if hour is 1-12 and in business context
|
| 176 |
+
if 1 <= hour <= 12:
|
| 177 |
+
# Default to PM for business hours
|
| 178 |
+
hour += 12
|
| 179 |
+
return (hour, minute)
|
| 180 |
+
|
| 181 |
+
# Handle hour-only format
|
| 182 |
+
hour_match = re.search(r'(\d{1,2})\s*(?:o\'?clock)?', text)
|
| 183 |
+
if hour_match:
|
| 184 |
+
hour = int(hour_match.group(1))
|
| 185 |
+
# Assume afternoon for business hours
|
| 186 |
+
if 8 <= hour <= 12:
|
| 187 |
+
return (hour, 0)
|
| 188 |
+
elif 1 <= hour <= 7:
|
| 189 |
+
return (hour + 12, 0)
|
| 190 |
+
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
def parse_duration(self, text: str) -> Optional[int]:
|
| 194 |
+
"""Parse duration in minutes from text."""
|
| 195 |
+
text = text.lower().strip()
|
| 196 |
+
|
| 197 |
+
# Handle "X hours" or "X hour"
|
| 198 |
+
hour_match = re.search(r'(\d+(?:\.\d+)?)\s*hours?', text)
|
| 199 |
+
if hour_match:
|
| 200 |
+
hours = float(hour_match.group(1))
|
| 201 |
+
return int(hours * 60)
|
| 202 |
+
|
| 203 |
+
# Handle "X minutes" or "X mins"
|
| 204 |
+
minute_match = re.search(r'(\d+)\s*(?:minutes?|mins?)', text)
|
| 205 |
+
if minute_match:
|
| 206 |
+
return int(minute_match.group(1))
|
| 207 |
+
|
| 208 |
+
# Handle common durations
|
| 209 |
+
if '30' in text or 'thirty' in text or 'half' in text:
|
| 210 |
+
return 30
|
| 211 |
+
elif '15' in text or 'fifteen' in text or 'quarter' in text:
|
| 212 |
+
return 15
|
| 213 |
+
elif '45' in text or 'forty-five' in text:
|
| 214 |
+
return 45
|
| 215 |
+
elif '60' in text or 'hour' in text:
|
| 216 |
+
return 60
|
| 217 |
+
elif '90' in text or 'ninety' in text:
|
| 218 |
+
return 90
|
| 219 |
+
elif '120' in text or 'two hour' in text:
|
| 220 |
+
return 120
|
| 221 |
+
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class CalendarFormatter:
|
| 226 |
+
"""Utility class for formatting calendar information."""
|
| 227 |
+
|
| 228 |
+
def __init__(self, timezone: str = None):
|
| 229 |
+
self.timezone = pytz.timezone(timezone or settings.default_timezone)
|
| 230 |
+
|
| 231 |
+
def format_datetime(self, dt: datetime, include_date: bool = True) -> str:
|
| 232 |
+
"""Format datetime in a user-friendly way."""
|
| 233 |
+
if dt.tzinfo is None:
|
| 234 |
+
dt = self.timezone.localize(dt)
|
| 235 |
+
else:
|
| 236 |
+
dt = dt.astimezone(self.timezone)
|
| 237 |
+
|
| 238 |
+
# Format time
|
| 239 |
+
time_str = dt.strftime("%-I:%M %p" if dt.minute else "%-I %p")
|
| 240 |
+
|
| 241 |
+
if not include_date:
|
| 242 |
+
return time_str
|
| 243 |
+
|
| 244 |
+
# Format date
|
| 245 |
+
today = datetime.now(self.timezone).date()
|
| 246 |
+
tomorrow = today + timedelta(days=1)
|
| 247 |
+
yesterday = today - timedelta(days=1)
|
| 248 |
+
|
| 249 |
+
if dt.date() == today:
|
| 250 |
+
return f"today at {time_str}"
|
| 251 |
+
elif dt.date() == tomorrow:
|
| 252 |
+
return f"tomorrow at {time_str}"
|
| 253 |
+
elif dt.date() == yesterday:
|
| 254 |
+
return f"yesterday at {time_str}"
|
| 255 |
+
else:
|
| 256 |
+
# Check if it's this week
|
| 257 |
+
days_diff = (dt.date() - today).days
|
| 258 |
+
if 0 < days_diff <= 7:
|
| 259 |
+
day_name = dt.strftime("%A")
|
| 260 |
+
return f"{day_name} at {time_str}"
|
| 261 |
+
else:
|
| 262 |
+
date_str = dt.strftime("%B %-d")
|
| 263 |
+
if dt.year != today.year:
|
| 264 |
+
date_str += f", {dt.year}"
|
| 265 |
+
return f"{date_str} at {time_str}"
|
| 266 |
+
|
| 267 |
+
def format_duration(self, minutes: int) -> str:
|
| 268 |
+
"""Format duration in a user-friendly way."""
|
| 269 |
+
if minutes < 60:
|
| 270 |
+
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
| 271 |
+
|
| 272 |
+
hours = minutes // 60
|
| 273 |
+
remaining_minutes = minutes % 60
|
| 274 |
+
|
| 275 |
+
if remaining_minutes == 0:
|
| 276 |
+
return f"{hours} hour{'s' if hours != 1 else ''}"
|
| 277 |
+
else:
|
| 278 |
+
return f"{hours} hour{'s' if hours != 1 else ''} and {remaining_minutes} minute{'s' if remaining_minutes != 1 else ''}"
|
| 279 |
+
|
| 280 |
+
def format_availability_list(self, slots: List[Tuple[datetime, datetime]]) -> str:
|
| 281 |
+
"""Format list of available time slots."""
|
| 282 |
+
if not slots:
|
| 283 |
+
return "No available time slots found."
|
| 284 |
+
|
| 285 |
+
formatted_slots = []
|
| 286 |
+
for start, end in slots:
|
| 287 |
+
start_str = self.format_datetime(start, include_date=False)
|
| 288 |
+
end_str = self.format_datetime(end, include_date=False)
|
| 289 |
+
formatted_slots.append(f"{start_str} - {end_str}")
|
| 290 |
+
|
| 291 |
+
if len(formatted_slots) <= 3:
|
| 292 |
+
return ", ".join(formatted_slots)
|
| 293 |
+
else:
|
| 294 |
+
first_three = ", ".join(formatted_slots[:3])
|
| 295 |
+
remaining = len(formatted_slots) - 3
|
| 296 |
+
return f"{first_three}, and {remaining} more slot{'s' if remaining != 1 else ''}"
|
| 297 |
+
|
| 298 |
+
def format_event_summary(self, event: Dict) -> str:
|
| 299 |
+
"""Format a calendar event for display."""
|
| 300 |
+
summary = event.get('summary', 'Untitled Event')
|
| 301 |
+
|
| 302 |
+
if 'start' in event and 'dateTime' in event['start']:
|
| 303 |
+
start = datetime.fromisoformat(event['start']['dateTime'])
|
| 304 |
+
start_str = self.format_datetime(start)
|
| 305 |
+
|
| 306 |
+
if 'end' in event and 'dateTime' in event['end']:
|
| 307 |
+
end = datetime.fromisoformat(event['end']['dateTime'])
|
| 308 |
+
duration = int((end - start).total_seconds() / 60)
|
| 309 |
+
duration_str = self.format_duration(duration)
|
| 310 |
+
|
| 311 |
+
return f"{summary} ({start_str}, {duration_str})"
|
| 312 |
+
else:
|
| 313 |
+
return f"{summary} ({start_str})"
|
| 314 |
+
|
| 315 |
+
return summary
|
app/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from pydantic_settings import BaseSettings
|
| 4 |
+
from pydantic import Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
# Application
|
| 9 |
+
app_name: str = Field(default="ChatCal.ai", env="APP_NAME")
|
| 10 |
+
app_env: str = Field(default="development", env="APP_ENV")
|
| 11 |
+
app_host: str = Field(default="0.0.0.0", env="APP_HOST")
|
| 12 |
+
app_port: int = Field(default=8000, env="APP_PORT")
|
| 13 |
+
|
| 14 |
+
# Groq API (primary)
|
| 15 |
+
groq_api_key: str = Field(..., env="GROQ_API_KEY")
|
| 16 |
+
|
| 17 |
+
# Anthropic (optional - fallback)
|
| 18 |
+
anthropic_api_key: Optional[str] = Field(None, env="ANTHROPIC_API_KEY")
|
| 19 |
+
|
| 20 |
+
# Gemini API (optional - fallback)
|
| 21 |
+
gemini_api_key: Optional[str] = Field(None, env="GEMINI_API_KEY")
|
| 22 |
+
|
| 23 |
+
# Google Calendar
|
| 24 |
+
google_calendar_id: str = Field(default="pgits.job@gmail.com", env="GOOGLE_CALENDAR_ID")
|
| 25 |
+
google_client_id: Optional[str] = Field(None, env="GOOGLE_CLIENT_ID")
|
| 26 |
+
google_client_secret: Optional[str] = Field(None, env="GOOGLE_CLIENT_SECRET")
|
| 27 |
+
google_credentials_path: str = Field(default="credentials/google_calendar_credentials.json", env="GOOGLE_CREDENTIALS_PATH")
|
| 28 |
+
|
| 29 |
+
# OAuth Token Storage (for local development)
|
| 30 |
+
google_access_token: Optional[str] = Field(None, env="GOOGLE_ACCESS_TOKEN")
|
| 31 |
+
google_refresh_token: Optional[str] = Field(None, env="GOOGLE_REFRESH_TOKEN")
|
| 32 |
+
google_token_expiry: Optional[str] = Field(None, env="GOOGLE_TOKEN_EXPIRY")
|
| 33 |
+
|
| 34 |
+
# Redis
|
| 35 |
+
redis_url: str = Field(default="redis://localhost:6379/0", env="REDIS_URL")
|
| 36 |
+
|
| 37 |
+
# Session Backend (redis or jwt)
|
| 38 |
+
session_backend: str = Field(default="redis", env="SESSION_BACKEND")
|
| 39 |
+
|
| 40 |
+
# Security
|
| 41 |
+
secret_key: str = Field(..., env="SECRET_KEY")
|
| 42 |
+
cors_origins: List[str] = Field(default=["http://localhost:3000", "http://localhost:8000"], env="CORS_ORIGINS")
|
| 43 |
+
|
| 44 |
+
# Timezone
|
| 45 |
+
default_timezone: str = Field(default="America/New_York", env="DEFAULT_TIMEZONE")
|
| 46 |
+
|
| 47 |
+
# Chat Settings
|
| 48 |
+
max_conversation_history: int = Field(default=20, env="MAX_CONVERSATION_HISTORY")
|
| 49 |
+
session_timeout_minutes: int = Field(default=10, env="SESSION_TIMEOUT_MINUTES") # 10 minutes max
|
| 50 |
+
|
| 51 |
+
# HuggingFace Settings
|
| 52 |
+
tokenizers_parallelism: Optional[str] = Field(default="false", env="TOKENIZERS_PARALLELISM")
|
| 53 |
+
|
| 54 |
+
# Peter's Contact Information
|
| 55 |
+
my_phone_number: str = Field(..., env="MY_PHONE_NUMBER")
|
| 56 |
+
my_email_address: str = Field(..., env="MY_EMAIL_ADDRESS")
|
| 57 |
+
|
| 58 |
+
# Email Service Configuration
|
| 59 |
+
smtp_server: str = Field(default="smtp.gmail.com", env="SMTP_SERVER")
|
| 60 |
+
smtp_port: int = Field(default=587, env="SMTP_PORT")
|
| 61 |
+
smtp_username: Optional[str] = Field(None, env="SMTP_USERNAME")
|
| 62 |
+
smtp_password: Optional[str] = Field(None, env="SMTP_PASSWORD")
|
| 63 |
+
email_from_name: str = Field(default="ChatCal.ai", env="EMAIL_FROM_NAME")
|
| 64 |
+
|
| 65 |
+
# Testing Configuration
|
| 66 |
+
testing_mode: bool = Field(default=True, env="TESTING_MODE") # Set to True to ignore Peter's email validation
|
| 67 |
+
|
| 68 |
+
# Default Appointment Settings
|
| 69 |
+
default_appointment_title: str = Field(default="VoiceCal Appt", env="DEFAULT_APPOINTMENT_TITLE")
|
| 70 |
+
|
| 71 |
+
# STT Service Settings (Direct Connection)
|
| 72 |
+
stt_service_wss_url: str = Field(default="wss://pgits-stt-gpu-service-v3.hf.space/ws", env="STT_SERVICE_WSS_URL")
|
| 73 |
+
stt_websocket_timeout: int = Field(default=30, env="STT_WEBSOCKET_TIMEOUT")
|
| 74 |
+
stt_audio_chunk_size: int = Field(default=1280, env="STT_AUDIO_CHUNK_SIZE") # 80ms at 16kHz
|
| 75 |
+
stt_sample_rate: int = Field(default=16000, env="STT_SAMPLE_RATE") # v3 service native rate
|
| 76 |
+
|
| 77 |
+
# STT UI Settings
|
| 78 |
+
stt_listening_indicator: bool = Field(default=True, env="STT_LISTENING_INDICATOR")
|
| 79 |
+
stt_auto_submit: bool = Field(default=False, env="STT_AUTO_SUBMIT") # Future feature
|
| 80 |
+
stt_silence_threshold: float = Field(default=2.5, env="STT_SILENCE_THRESHOLD") # seconds
|
| 81 |
+
|
| 82 |
+
class Config:
|
| 83 |
+
env_file = ".env"
|
| 84 |
+
env_file_encoding = "utf-8"
|
| 85 |
+
case_sensitive = False
|
| 86 |
+
extra = "ignore" # Allow extra environment variables
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
settings = Settings()
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/agent.py
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main ChatCal.ai agent that combines LLM with calendar tools."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Dict, Optional
|
| 4 |
+
from llama_index.core.llms import ChatMessage, MessageRole
|
| 5 |
+
from llama_index.core.tools import BaseTool
|
| 6 |
+
from llama_index.core.memory import ChatMemoryBuffer
|
| 7 |
+
from llama_index.core.agent import ReActAgent
|
| 8 |
+
from app.core.llm_anthropic import anthropic_llm
|
| 9 |
+
from app.core.tools import CalendarTools
|
| 10 |
+
from app.personality.prompts import SYSTEM_PROMPT, ENCOURAGEMENT_PHRASES
|
| 11 |
+
from app.config import settings
|
| 12 |
+
import random
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ChatCalAgent:
|
| 16 |
+
"""Main ChatCal.ai conversational agent."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, session_id: str):
|
| 19 |
+
self.session_id = session_id
|
| 20 |
+
self.calendar_tools = CalendarTools(agent=self)
|
| 21 |
+
self.llm = anthropic_llm.get_llm()
|
| 22 |
+
|
| 23 |
+
# User information storage - load from session if available
|
| 24 |
+
print(f"π ChatCalAgent initializing for session: {session_id}")
|
| 25 |
+
loaded_user_info = self._load_user_info_from_session()
|
| 26 |
+
print(f"π Loaded user info: {loaded_user_info}")
|
| 27 |
+
|
| 28 |
+
self.user_info = loaded_user_info or {
|
| 29 |
+
"name": None,
|
| 30 |
+
"email": None,
|
| 31 |
+
"phone": None,
|
| 32 |
+
"preferences": {}
|
| 33 |
+
}
|
| 34 |
+
print(f"π Final user info: {self.user_info}")
|
| 35 |
+
|
| 36 |
+
# Meeting ID storage for cancellation tracking - load from session if available
|
| 37 |
+
self.stored_meetings = self._load_stored_meetings_from_session() or {}
|
| 38 |
+
|
| 39 |
+
# Conversation state for multi-turn operations
|
| 40 |
+
self.conversation_state = {
|
| 41 |
+
"pending_operation": None, # "cancellation", "booking", etc.
|
| 42 |
+
"operation_context": {}, # Context data for the pending operation
|
| 43 |
+
"awaiting_clarification": False # Whether we're waiting for user clarification
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# Set up memory
|
| 47 |
+
self.memory = ChatMemoryBuffer(token_limit=3000)
|
| 48 |
+
|
| 49 |
+
# Create agent with tools
|
| 50 |
+
self.agent = self._create_agent()
|
| 51 |
+
self.conversation_started = False
|
| 52 |
+
|
| 53 |
+
def _create_agent(self):
|
| 54 |
+
"""Create the ReAct agent with calendar tools."""
|
| 55 |
+
# Get calendar tools
|
| 56 |
+
tools = self.calendar_tools.get_tools()
|
| 57 |
+
|
| 58 |
+
# Get current user context
|
| 59 |
+
user_context = self._get_user_context()
|
| 60 |
+
|
| 61 |
+
# Enhanced system prompt for calendar agent with Peter's contact info
|
| 62 |
+
self.base_system_prompt = SYSTEM_PROMPT.format(
|
| 63 |
+
my_phone_number=settings.my_phone_number,
|
| 64 |
+
my_email_address=settings.my_email_address
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
enhanced_system_prompt = f"""{self.base_system_prompt}
|
| 68 |
+
|
| 69 |
+
{user_context}
|
| 70 |
+
|
| 71 |
+
## Response Guidelines
|
| 72 |
+
|
| 73 |
+
**AVAILABILITY CHECKING (NO CONTACT INFO REQUIRED):**
|
| 74 |
+
- For availability requests ("check availability", "what times are free", "when is Peter available") β IMMEDIATELY use check_availability tool
|
| 75 |
+
- DO NOT ask for contact information when just checking availability
|
| 76 |
+
- Contact info is only needed for actual booking, not for viewing available times
|
| 77 |
+
|
| 78 |
+
**CRITICAL BOOKING WORKFLOW - STOP INFINITE LOOPS:**
|
| 79 |
+
1. FIRST: Check the "Missing Info" section above
|
| 80 |
+
2. If Missing Info exists: ASK for the missing information - DO NOT call ANY booking tools
|
| 81 |
+
3. If "User Info: Complete": PROCEED with booking tools immediately
|
| 82 |
+
4. For Google Meet requests: Email is ABSOLUTELY REQUIRED - never book without it
|
| 83 |
+
5. NEVER call create_appointment without ALL required fields being present
|
| 84 |
+
|
| 85 |
+
**Specific Rules:**
|
| 86 |
+
- If you see "Missing Info: first name" β Ask "What's your first name?"
|
| 87 |
+
- If you see "Missing Info: email address (required for Google Meet)" β Ask "I need your email address to send the Google Meet invitation"
|
| 88 |
+
- If you see "Missing Info: phone number (required for Google Meet)" β Ask "I need your phone number in case we need to call you"
|
| 89 |
+
- If you see multiple missing items β Ask for ALL missing items in one question
|
| 90 |
+
- If you see "Missing Info: contact (email or phone)" β Ask "Email address or phone number?"
|
| 91 |
+
- NEVER attempt to book if you see ANY "Missing Info" listed
|
| 92 |
+
|
| 93 |
+
**BOOKING COMPLETION RULES - EXACT SEQUENCE:**
|
| 94 |
+
1. When "User Info: Complete" appears above:
|
| 95 |
+
β IMMEDIATELY call check_availability tool with the exact requested date/time
|
| 96 |
+
β Example: check_availability(date_string="today", preferred_time="4pm", duration_minutes=5)
|
| 97 |
+
2. If time is available:
|
| 98 |
+
β IMMEDIATELY call create_appointment tool to book
|
| 99 |
+
3. If time is NOT available:
|
| 100 |
+
β Suggest alternative times and ask user to choose
|
| 101 |
+
4. After booking:
|
| 102 |
+
β Provide the Meeting ID and Google Calendar ID in the confirmation
|
| 103 |
+
|
| 104 |
+
**FORBIDDEN ACTIONS:**
|
| 105 |
+
- NEVER call list_upcoming_events when you should be checking specific availability
|
| 106 |
+
- NEVER ask for information you already have
|
| 107 |
+
- NEVER show general calendar events when user wants to book specific time
|
| 108 |
+
|
| 109 |
+
**General Guidelines:**
|
| 110 |
+
- Be conversational and natural, like a helpful human assistant
|
| 111 |
+
- Keep responses concise and focused on the user's needs
|
| 112 |
+
- Never mention tools, capabilities, or technical details unless asked
|
| 113 |
+
- If user provides contact info (email/phone), remember it and use it
|
| 114 |
+
- When collecting email addresses, always confirm them by repeating back what you heard
|
| 115 |
+
- If you detect an email address via speech-to-text, ask user to confirm it's correct before booking
|
| 116 |
+
- Give users a chance to spell out email addresses using military/phonetic alphabet if needed
|
| 117 |
+
- Be patient - wait for user confirmation before proceeding with booking
|
| 118 |
+
- Never ask for "secondary email" or additional contact if they already provided one
|
| 119 |
+
- Format responses with proper line breaks - each detail on its own line"""
|
| 120 |
+
|
| 121 |
+
# Create actual ReActAgent with tools and iteration limits
|
| 122 |
+
agent = ReActAgent(
|
| 123 |
+
tools=tools,
|
| 124 |
+
llm=self.llm,
|
| 125 |
+
system_prompt=enhanced_system_prompt,
|
| 126 |
+
verbose=True
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return agent
|
| 130 |
+
|
| 131 |
+
def _get_user_context(self, message: str = "") -> str:
|
| 132 |
+
"""Generate user context for the system prompt."""
|
| 133 |
+
missing = []
|
| 134 |
+
if not self.user_info.get("name"):
|
| 135 |
+
missing.append("first name")
|
| 136 |
+
|
| 137 |
+
# Check if this is a Google Meet request
|
| 138 |
+
is_google_meet_request = any(phrase in message.lower() for phrase in [
|
| 139 |
+
'google meet', 'googlemeet', 'meet.google', 'video call', 'video conference', 'online meeting'
|
| 140 |
+
])
|
| 141 |
+
|
| 142 |
+
if is_google_meet_request:
|
| 143 |
+
# For Google Meet, BOTH email AND phone are REQUIRED
|
| 144 |
+
if not self.user_info.get("email"):
|
| 145 |
+
missing.append("email address (required for Google Meet invitations)")
|
| 146 |
+
if not self.user_info.get("phone"):
|
| 147 |
+
missing.append("phone number (required for Google Meet - in case we need to call)")
|
| 148 |
+
else:
|
| 149 |
+
# For regular meetings, email OR phone is acceptable
|
| 150 |
+
if not self.user_info.get("email") and not self.user_info.get("phone"):
|
| 151 |
+
missing.append("contact (email or phone)")
|
| 152 |
+
|
| 153 |
+
if missing:
|
| 154 |
+
return f"## Missing Info: {', '.join(missing)}"
|
| 155 |
+
else:
|
| 156 |
+
# Include actual user information in the context
|
| 157 |
+
user_details = []
|
| 158 |
+
if self.user_info.get("name"):
|
| 159 |
+
user_details.append(f"Name: {self.user_info['name']}")
|
| 160 |
+
if self.user_info.get("email"):
|
| 161 |
+
user_details.append(f"Email: {self.user_info['email']}")
|
| 162 |
+
if self.user_info.get("phone"):
|
| 163 |
+
user_details.append(f"Phone: {self.user_info['phone']}")
|
| 164 |
+
|
| 165 |
+
return f"## User Info: Complete - ready to book\n{' | '.join(user_details)}"
|
| 166 |
+
|
| 167 |
+
def _get_conversation_state_context(self) -> str:
|
| 168 |
+
"""Generate conversation state context for the system prompt."""
|
| 169 |
+
if not self.conversation_state["pending_operation"]:
|
| 170 |
+
return ""
|
| 171 |
+
|
| 172 |
+
if self.conversation_state["pending_operation"] == "cancellation" and self.conversation_state["awaiting_clarification"]:
|
| 173 |
+
context = self.conversation_state["operation_context"]
|
| 174 |
+
return f"""## Conversation State: AWAITING CANCELLATION CLARIFICATION
|
| 175 |
+
- User wants to cancel a meeting for: {context.get('user_name', 'unknown')}
|
| 176 |
+
- Original date/time info: {context.get('date_string', 'none')} {context.get('time_string', 'none')}
|
| 177 |
+
- Waiting for user to provide more specific time/date details
|
| 178 |
+
- Do NOT ask about meeting purpose - only ask for time/date clarification if needed"""
|
| 179 |
+
|
| 180 |
+
return ""
|
| 181 |
+
|
| 182 |
+
def _process_message_with_llm(self, message: str) -> str:
|
| 183 |
+
"""Process message using direct LLM calls with manual tool invocation."""
|
| 184 |
+
try:
|
| 185 |
+
# Check if we need to call any tools based on the message
|
| 186 |
+
tool_result = self._check_and_call_tools(message)
|
| 187 |
+
|
| 188 |
+
# Create enhanced message with tool results if any
|
| 189 |
+
enhanced_message = message
|
| 190 |
+
if tool_result:
|
| 191 |
+
enhanced_message = f"[Tool Result: {tool_result}]\n\nUser: {message}"
|
| 192 |
+
|
| 193 |
+
# Add user message to memory
|
| 194 |
+
user_message = ChatMessage(role=MessageRole.USER, content=enhanced_message)
|
| 195 |
+
self.memory.put(user_message)
|
| 196 |
+
|
| 197 |
+
# Build conversation history for LLM with updated user context
|
| 198 |
+
current_user_context = self._get_user_context(message)
|
| 199 |
+
conversation_state_context = self._get_conversation_state_context()
|
| 200 |
+
|
| 201 |
+
current_system_prompt = f"""{self.base_system_prompt.split('## Missing Info:')[0].split('## User Info:')[0].rstrip()}
|
| 202 |
+
|
| 203 |
+
{current_user_context}
|
| 204 |
+
|
| 205 |
+
{conversation_state_context}
|
| 206 |
+
|
| 207 |
+
## Response Guidelines
|
| 208 |
+
|
| 209 |
+
- Be conversational and natural, like a helpful human assistant
|
| 210 |
+
- Keep responses concise and focused on the user's needs
|
| 211 |
+
- Never mention tools, capabilities, or technical details unless asked
|
| 212 |
+
- If user provides contact info (email/phone), remember it and use it
|
| 213 |
+
- Never ask for "secondary email" or additional contact if they already provided one
|
| 214 |
+
- If you have all required info (name + contact), book immediately without asking for confirmation
|
| 215 |
+
- Format responses with proper line breaks - each detail on its own line"""
|
| 216 |
+
|
| 217 |
+
messages = [ChatMessage(role=MessageRole.SYSTEM, content=current_system_prompt)]
|
| 218 |
+
|
| 219 |
+
# Add conversation history from memory
|
| 220 |
+
history_messages = self.memory.get_all()
|
| 221 |
+
messages.extend(history_messages)
|
| 222 |
+
|
| 223 |
+
# Get ReActAgent response (tools will be automatically called if needed)
|
| 224 |
+
response = self.agent.run(user_message.content)
|
| 225 |
+
response_text = str(response)
|
| 226 |
+
|
| 227 |
+
return response_text
|
| 228 |
+
|
| 229 |
+
except Exception as e:
|
| 230 |
+
print(f"LLM processing error: {e}")
|
| 231 |
+
import traceback
|
| 232 |
+
traceback.print_exc()
|
| 233 |
+
return "I'm having trouble processing your request right now. Could you try again?"
|
| 234 |
+
|
| 235 |
+
def _check_and_call_tools(self, message: str) -> str:
|
| 236 |
+
"""Check if message requires tool calls and execute them."""
|
| 237 |
+
import re
|
| 238 |
+
|
| 239 |
+
# Check for booking requests
|
| 240 |
+
booking_patterns = [
|
| 241 |
+
r"schedule|book|appointment|meeting",
|
| 242 |
+
r"available|availability|free time",
|
| 243 |
+
r"reschedule|move|change",
|
| 244 |
+
r"cancel|delete|remove"
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
message_lower = message.lower()
|
| 248 |
+
|
| 249 |
+
# Check availability requests
|
| 250 |
+
if any(word in message_lower for word in ["available", "availability", "free", "open"]):
|
| 251 |
+
# Extract date from message (basic pattern matching)
|
| 252 |
+
date_patterns = [
|
| 253 |
+
r"tomorrow",
|
| 254 |
+
r"today",
|
| 255 |
+
r"monday|tuesday|wednesday|thursday|friday|saturday|sunday",
|
| 256 |
+
r"\d{1,2}/\d{1,2}",
|
| 257 |
+
r"next week",
|
| 258 |
+
r"this week"
|
| 259 |
+
]
|
| 260 |
+
|
| 261 |
+
date_found = None
|
| 262 |
+
for pattern in date_patterns:
|
| 263 |
+
match = re.search(pattern, message_lower)
|
| 264 |
+
if match:
|
| 265 |
+
date_found = match.group()
|
| 266 |
+
break
|
| 267 |
+
|
| 268 |
+
if date_found:
|
| 269 |
+
try:
|
| 270 |
+
# Call availability tool
|
| 271 |
+
availability_tool = None
|
| 272 |
+
for tool in self.tools:
|
| 273 |
+
if "availability" in tool.metadata.name.lower():
|
| 274 |
+
availability_tool = tool
|
| 275 |
+
break
|
| 276 |
+
|
| 277 |
+
if availability_tool:
|
| 278 |
+
result = availability_tool.call(date=date_found)
|
| 279 |
+
return f"Availability check result: {result}"
|
| 280 |
+
except Exception as e:
|
| 281 |
+
return f"Could not check availability: {str(e)}"
|
| 282 |
+
|
| 283 |
+
# Check for booking requests with complete info
|
| 284 |
+
if self.has_complete_user_info() and any(word in message_lower for word in ["schedule", "book", "appointment", "meeting"]):
|
| 285 |
+
try:
|
| 286 |
+
# Extract meeting details (basic implementation)
|
| 287 |
+
meeting_type = "consultation" # Default
|
| 288 |
+
duration = 60 # Default
|
| 289 |
+
|
| 290 |
+
# Try to create appointment
|
| 291 |
+
create_tool = None
|
| 292 |
+
for tool in self.tools:
|
| 293 |
+
if "create" in tool.metadata.name.lower() or "appointment" in tool.metadata.name.lower():
|
| 294 |
+
create_tool = tool
|
| 295 |
+
break
|
| 296 |
+
|
| 297 |
+
if create_tool:
|
| 298 |
+
result = create_tool.call(
|
| 299 |
+
title=f"Meeting with {self.user_info['name']}",
|
| 300 |
+
attendee_email=self.user_info.get('email', ''),
|
| 301 |
+
duration=duration
|
| 302 |
+
)
|
| 303 |
+
return f"Appointment creation result: {result}"
|
| 304 |
+
except Exception as e:
|
| 305 |
+
return f"Could not create appointment: {str(e)}"
|
| 306 |
+
|
| 307 |
+
# First check if we're continuing a cancellation conversation
|
| 308 |
+
if self.conversation_state["pending_operation"] == "cancellation" and self.conversation_state["awaiting_clarification"]:
|
| 309 |
+
# User is providing clarification for a pending cancellation
|
| 310 |
+
context = self.conversation_state["operation_context"]
|
| 311 |
+
print(f"π Processing cancellation clarification from {context.get('user_name')}: '{message}'")
|
| 312 |
+
|
| 313 |
+
# Extract time/date information from the clarification
|
| 314 |
+
time_patterns = [
|
| 315 |
+
r"\d{1,2}:\d{2}\s*(?:am|pm)",
|
| 316 |
+
r"\d{1,2}\s*(?:am|pm)",
|
| 317 |
+
r"morning|afternoon|evening|noon"
|
| 318 |
+
]
|
| 319 |
+
|
| 320 |
+
date_patterns = [
|
| 321 |
+
r"tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday",
|
| 322 |
+
r"\d{1,2}/\d{1,2}",
|
| 323 |
+
r"next week|this week"
|
| 324 |
+
]
|
| 325 |
+
|
| 326 |
+
# Look for additional time/date info in the clarification
|
| 327 |
+
additional_time = None
|
| 328 |
+
additional_date = None
|
| 329 |
+
|
| 330 |
+
for pattern in time_patterns:
|
| 331 |
+
match = re.search(pattern, message_lower)
|
| 332 |
+
if match:
|
| 333 |
+
additional_time = match.group()
|
| 334 |
+
break
|
| 335 |
+
|
| 336 |
+
for pattern in date_patterns:
|
| 337 |
+
match = re.search(pattern, message_lower)
|
| 338 |
+
if match:
|
| 339 |
+
additional_date = match.group()
|
| 340 |
+
break
|
| 341 |
+
|
| 342 |
+
# Use the clarification to complete the cancellation
|
| 343 |
+
try:
|
| 344 |
+
cancel_tool = None
|
| 345 |
+
for tool in self.tools:
|
| 346 |
+
if "cancel" in tool.metadata.name.lower() and "details" in tool.metadata.name.lower():
|
| 347 |
+
cancel_tool = tool
|
| 348 |
+
break
|
| 349 |
+
|
| 350 |
+
if cancel_tool:
|
| 351 |
+
# Use stored context plus any new info from clarification
|
| 352 |
+
final_date = additional_date or context.get("date_string", "")
|
| 353 |
+
final_time = additional_time or context.get("time_string", "")
|
| 354 |
+
|
| 355 |
+
result = cancel_tool.call(
|
| 356 |
+
user_name=context["user_name"],
|
| 357 |
+
date_string=final_date,
|
| 358 |
+
time_string=final_time
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Clear the conversation state
|
| 362 |
+
self.conversation_state = {
|
| 363 |
+
"pending_operation": None,
|
| 364 |
+
"operation_context": {},
|
| 365 |
+
"awaiting_clarification": False
|
| 366 |
+
}
|
| 367 |
+
print(f"β
Cleared conversation state after successful cancellation")
|
| 368 |
+
|
| 369 |
+
return result
|
| 370 |
+
except Exception as e:
|
| 371 |
+
# Clear state on error
|
| 372 |
+
self.conversation_state = {
|
| 373 |
+
"pending_operation": None,
|
| 374 |
+
"operation_context": {},
|
| 375 |
+
"awaiting_clarification": False
|
| 376 |
+
}
|
| 377 |
+
print(f"β Cleared conversation state after cancellation error: {str(e)}")
|
| 378 |
+
return f"Could not cancel meeting: {str(e)}"
|
| 379 |
+
|
| 380 |
+
# Check for numbered cancellation requests (e.g., "cancel #1", "cancel the first one")
|
| 381 |
+
elif any(word in message_lower for word in ["cancel", "delete", "remove"]):
|
| 382 |
+
# Look for number indicators
|
| 383 |
+
number_patterns = [
|
| 384 |
+
r"#(\d+)", r"number (\d+)", r"(\d+)(?:st|nd|rd|th)?",
|
| 385 |
+
r"first", r"second", r"third", r"fourth", r"fifth"
|
| 386 |
+
]
|
| 387 |
+
|
| 388 |
+
number_words = {"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5}
|
| 389 |
+
selected_number = None
|
| 390 |
+
|
| 391 |
+
for pattern in number_patterns:
|
| 392 |
+
match = re.search(pattern, message_lower)
|
| 393 |
+
if match:
|
| 394 |
+
if match.group().strip() in number_words:
|
| 395 |
+
selected_number = number_words[match.group().strip()]
|
| 396 |
+
else:
|
| 397 |
+
try:
|
| 398 |
+
selected_number = int(match.group(1) if match.groups() else match.group().strip("#"))
|
| 399 |
+
except:
|
| 400 |
+
continue
|
| 401 |
+
break
|
| 402 |
+
|
| 403 |
+
# If user selected a number and we have their stored meetings info, try to cancel by selection
|
| 404 |
+
if selected_number and self.user_info.get('name'):
|
| 405 |
+
# Find their meetings to get the selected one
|
| 406 |
+
try:
|
| 407 |
+
find_tool = None
|
| 408 |
+
for tool in self.tools:
|
| 409 |
+
if "find_user_meetings" in tool.metadata.name.lower():
|
| 410 |
+
find_tool = tool
|
| 411 |
+
break
|
| 412 |
+
|
| 413 |
+
if find_tool:
|
| 414 |
+
# This is a bit of a workaround - we'll get their meetings and extract the nth one
|
| 415 |
+
from datetime import datetime, timedelta
|
| 416 |
+
now = datetime.now(self.calendar_tools.calendar_service.default_timezone)
|
| 417 |
+
search_end = now + timedelta(days=30)
|
| 418 |
+
|
| 419 |
+
events = self.calendar_tools.calendar_service.list_events(
|
| 420 |
+
time_min=now,
|
| 421 |
+
time_max=search_end,
|
| 422 |
+
max_results=50
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
# Find meetings that match the user name
|
| 426 |
+
matching_events = []
|
| 427 |
+
for event in events:
|
| 428 |
+
event_summary = event.get('summary', '').lower()
|
| 429 |
+
event_description = event.get('description', '').lower()
|
| 430 |
+
|
| 431 |
+
if (self.user_info['name'].lower() in event_summary or
|
| 432 |
+
self.user_info['name'].lower() in event_description or
|
| 433 |
+
f"meeting with {self.user_info['name'].lower()}" in event_summary):
|
| 434 |
+
matching_events.append(event)
|
| 435 |
+
|
| 436 |
+
if matching_events and 1 <= selected_number <= len(matching_events):
|
| 437 |
+
# Cancel the selected meeting
|
| 438 |
+
selected_event = matching_events[selected_number - 1]
|
| 439 |
+
google_meeting_id = selected_event.get('id')
|
| 440 |
+
|
| 441 |
+
# Try to find custom meeting ID
|
| 442 |
+
custom_meeting_id = None
|
| 443 |
+
if self.agent:
|
| 444 |
+
stored_meetings = self.agent.get_stored_meetings()
|
| 445 |
+
for stored_id, info in stored_meetings.items():
|
| 446 |
+
if info.get('google_id') == google_meeting_id:
|
| 447 |
+
custom_meeting_id = stored_id
|
| 448 |
+
break
|
| 449 |
+
|
| 450 |
+
# Cancel using the meeting ID
|
| 451 |
+
cancel_tool = None
|
| 452 |
+
for tool in self.tools:
|
| 453 |
+
if "cancel" in tool.metadata.name.lower() and "id" in tool.metadata.name.lower():
|
| 454 |
+
cancel_tool = tool
|
| 455 |
+
break
|
| 456 |
+
|
| 457 |
+
if cancel_tool:
|
| 458 |
+
if custom_meeting_id:
|
| 459 |
+
result = cancel_tool.call(meeting_id=custom_meeting_id)
|
| 460 |
+
else:
|
| 461 |
+
# Fall back to Google ID
|
| 462 |
+
result = cancel_tool.call(meeting_id=google_meeting_id)
|
| 463 |
+
return result
|
| 464 |
+
else:
|
| 465 |
+
return f"I couldn't find meeting #{selected_number}. Please check the list and try again."
|
| 466 |
+
|
| 467 |
+
except Exception as e:
|
| 468 |
+
return f"Could not cancel meeting: {str(e)}"
|
| 469 |
+
|
| 470 |
+
# Check for new cancellation requests
|
| 471 |
+
elif any(word in message_lower for word in ["cancel", "delete", "remove"]) and any(word in message_lower for word in ["meeting", "appointment"]):
|
| 472 |
+
# Look for meeting ID in message first
|
| 473 |
+
meeting_id_pattern = r"\b\d{4}-\d{4}-\d+m\b" # Our custom format: MMDD-HHMM-DURm
|
| 474 |
+
meeting_ids = re.findall(meeting_id_pattern, message)
|
| 475 |
+
|
| 476 |
+
if meeting_ids:
|
| 477 |
+
# Try to cancel by custom meeting ID
|
| 478 |
+
try:
|
| 479 |
+
cancel_tool = None
|
| 480 |
+
for tool in self.tools:
|
| 481 |
+
if "cancel" in tool.metadata.name.lower() and "id" in tool.metadata.name.lower():
|
| 482 |
+
cancel_tool = tool
|
| 483 |
+
break
|
| 484 |
+
|
| 485 |
+
if cancel_tool:
|
| 486 |
+
result = cancel_tool.call(meeting_id=meeting_ids[0])
|
| 487 |
+
return result # Return direct result (already formatted HTML)
|
| 488 |
+
except Exception as e:
|
| 489 |
+
return f"Could not cancel meeting: {str(e)}"
|
| 490 |
+
else:
|
| 491 |
+
# Try to cancel by user name and date (never ask about purpose)
|
| 492 |
+
if self.user_info.get('name'):
|
| 493 |
+
try:
|
| 494 |
+
# Extract date from message
|
| 495 |
+
date_patterns = [
|
| 496 |
+
r"tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday",
|
| 497 |
+
r"\d{1,2}/\d{1,2}",
|
| 498 |
+
r"next week|this week|friday|monday|tuesday|wednesday|thursday"
|
| 499 |
+
]
|
| 500 |
+
|
| 501 |
+
# Extract time from message
|
| 502 |
+
time_patterns = [
|
| 503 |
+
r"\d{1,2}:\d{2}\s*(?:am|pm)",
|
| 504 |
+
r"\d{1,2}\s*(?:am|pm)",
|
| 505 |
+
r"morning|afternoon|evening|noon"
|
| 506 |
+
]
|
| 507 |
+
|
| 508 |
+
date_found = None
|
| 509 |
+
time_found = None
|
| 510 |
+
|
| 511 |
+
for pattern in date_patterns:
|
| 512 |
+
match = re.search(pattern, message_lower)
|
| 513 |
+
if match:
|
| 514 |
+
date_found = match.group()
|
| 515 |
+
break
|
| 516 |
+
|
| 517 |
+
for pattern in time_patterns:
|
| 518 |
+
match = re.search(pattern, message_lower)
|
| 519 |
+
if match:
|
| 520 |
+
time_found = match.group()
|
| 521 |
+
break
|
| 522 |
+
|
| 523 |
+
# Check if user indicates they don't remember the time
|
| 524 |
+
no_time_indicators = [
|
| 525 |
+
"don't remember", "can't remember", "forgot", "not sure when",
|
| 526 |
+
"don't know when", "what time", "which one", "don't recall"
|
| 527 |
+
]
|
| 528 |
+
|
| 529 |
+
user_doesnt_remember = any(indicator in message_lower for indicator in no_time_indicators)
|
| 530 |
+
|
| 531 |
+
if date_found and not user_doesnt_remember:
|
| 532 |
+
# User provided specific date/time - use details cancellation
|
| 533 |
+
cancel_tool = None
|
| 534 |
+
for tool in self.tools:
|
| 535 |
+
if "cancel" in tool.metadata.name.lower() and "details" in tool.metadata.name.lower():
|
| 536 |
+
cancel_tool = tool
|
| 537 |
+
break
|
| 538 |
+
|
| 539 |
+
if cancel_tool:
|
| 540 |
+
result = cancel_tool.call(
|
| 541 |
+
user_name=self.user_info['name'],
|
| 542 |
+
date_string=date_found,
|
| 543 |
+
time_string=time_found
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
# Check if the result is asking for clarification
|
| 547 |
+
if "Found multiple meetings" in result or "Can you be more specific" in result:
|
| 548 |
+
# Store the cancellation context
|
| 549 |
+
self.conversation_state = {
|
| 550 |
+
"pending_operation": "cancellation",
|
| 551 |
+
"operation_context": {
|
| 552 |
+
"user_name": self.user_info['name'],
|
| 553 |
+
"date_string": date_found,
|
| 554 |
+
"time_string": time_found,
|
| 555 |
+
"original_message": message
|
| 556 |
+
},
|
| 557 |
+
"awaiting_clarification": True
|
| 558 |
+
}
|
| 559 |
+
print(f"π Set conversation state: awaiting cancellation clarification for {self.user_info['name']}")
|
| 560 |
+
|
| 561 |
+
return result # Return direct result (already formatted HTML)
|
| 562 |
+
else:
|
| 563 |
+
# User doesn't remember time or didn't provide date - show all their meetings
|
| 564 |
+
find_tool = None
|
| 565 |
+
for tool in self.tools:
|
| 566 |
+
if "find_user_meetings" in tool.metadata.name.lower():
|
| 567 |
+
find_tool = tool
|
| 568 |
+
break
|
| 569 |
+
|
| 570 |
+
if find_tool:
|
| 571 |
+
result = find_tool.call(user_name=self.user_info['name'])
|
| 572 |
+
return result # Return list of meetings for user to choose
|
| 573 |
+
except Exception as e:
|
| 574 |
+
return f"Could not cancel meeting: {str(e)}"
|
| 575 |
+
|
| 576 |
+
return ""
|
| 577 |
+
|
| 578 |
+
def update_user_info(self, info_type: str, value: str) -> bool:
|
| 579 |
+
"""Update user information."""
|
| 580 |
+
if info_type in self.user_info:
|
| 581 |
+
self.user_info[info_type] = value
|
| 582 |
+
# Save to session whenever user info is updated
|
| 583 |
+
self._save_user_info_to_session()
|
| 584 |
+
return True
|
| 585 |
+
return False
|
| 586 |
+
|
| 587 |
+
def get_user_info(self) -> Dict:
|
| 588 |
+
"""Get current user information."""
|
| 589 |
+
return self.user_info.copy()
|
| 590 |
+
|
| 591 |
+
def has_complete_user_info(self) -> bool:
|
| 592 |
+
"""Check if minimum required user information is collected (name + at least one contact)."""
|
| 593 |
+
has_name = bool(self.user_info.get("name"))
|
| 594 |
+
has_contact = bool(self.user_info.get("email") or self.user_info.get("phone"))
|
| 595 |
+
return has_name and has_contact
|
| 596 |
+
|
| 597 |
+
def has_ideal_user_info(self) -> bool:
|
| 598 |
+
"""Check if all preferred user information is collected (name + both contacts)."""
|
| 599 |
+
return all([
|
| 600 |
+
self.user_info.get("name"),
|
| 601 |
+
self.user_info.get("email"),
|
| 602 |
+
self.user_info.get("phone")
|
| 603 |
+
])
|
| 604 |
+
|
| 605 |
+
def get_missing_user_info(self) -> List[str]:
|
| 606 |
+
"""Get list of missing required user information."""
|
| 607 |
+
missing = []
|
| 608 |
+
if not self.user_info.get("name"):
|
| 609 |
+
missing.append("name")
|
| 610 |
+
|
| 611 |
+
# For contacts, only mark as missing if we have neither
|
| 612 |
+
if not self.user_info.get("email") and not self.user_info.get("phone"):
|
| 613 |
+
missing.append("contact (email OR phone)")
|
| 614 |
+
|
| 615 |
+
return missing
|
| 616 |
+
|
| 617 |
+
def get_missing_ideal_info(self) -> List[str]:
|
| 618 |
+
"""Get list of missing information for ideal collection (both contacts)."""
|
| 619 |
+
missing = []
|
| 620 |
+
if not self.user_info.get("name"):
|
| 621 |
+
missing.append("name")
|
| 622 |
+
if not self.user_info.get("email"):
|
| 623 |
+
missing.append("email")
|
| 624 |
+
if not self.user_info.get("phone"):
|
| 625 |
+
missing.append("phone")
|
| 626 |
+
return missing
|
| 627 |
+
|
| 628 |
+
def extract_user_info_from_message(self, message: str) -> Dict:
|
| 629 |
+
"""Extract user information from user messages using simple patterns."""
|
| 630 |
+
import re
|
| 631 |
+
extracted = {}
|
| 632 |
+
|
| 633 |
+
# Email pattern
|
| 634 |
+
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
|
| 635 |
+
emails = re.findall(email_pattern, message)
|
| 636 |
+
if emails:
|
| 637 |
+
email = emails[0]
|
| 638 |
+
# Handle spelled-out emails (remove hyphens unless user explicitly says "hyphen")
|
| 639 |
+
if '-' in email and 'hyphen' not in message.lower():
|
| 640 |
+
# Remove hyphens from spelled-out letters
|
| 641 |
+
email = email.replace('-', '')
|
| 642 |
+
extracted['email'] = email
|
| 643 |
+
|
| 644 |
+
# Phone pattern (multiple formats including "call me at 630 880 5488")
|
| 645 |
+
phone_patterns = [
|
| 646 |
+
r'(\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})', # Standard format
|
| 647 |
+
r'call me at\s*(\d{3})[-.\s]*(\d{3})[-.\s]*(\d{4})', # "call me at" format
|
| 648 |
+
r'phone\s*:?\s*(\d{3})[-.\s]*(\d{3})[-.\s]*(\d{4})', # "phone:" format
|
| 649 |
+
r'(\d{3})[-.\s]+(\d{3})[-.\s]+(\d{4})' # Simple space/dash separated
|
| 650 |
+
]
|
| 651 |
+
|
| 652 |
+
for pattern in phone_patterns:
|
| 653 |
+
matches = re.findall(pattern, message, re.IGNORECASE)
|
| 654 |
+
if matches:
|
| 655 |
+
match = matches[0]
|
| 656 |
+
if isinstance(match, tuple):
|
| 657 |
+
# Join all non-empty parts
|
| 658 |
+
phone_digits = ''.join([part for part in match if part])
|
| 659 |
+
if len(phone_digits) >= 10: # Valid phone number
|
| 660 |
+
extracted['phone'] = phone_digits
|
| 661 |
+
break
|
| 662 |
+
|
| 663 |
+
# Name pattern (various formats)
|
| 664 |
+
name_patterns = [
|
| 665 |
+
r"my name is ([A-Za-z\s]{2,30})",
|
| 666 |
+
r"i'm ([A-Za-z\s]{2,30})",
|
| 667 |
+
r"this is ([A-Za-z\s]{2,30})",
|
| 668 |
+
r"i am ([A-Za-z\s]{2,30})",
|
| 669 |
+
r"name is ([A-Za-z\s]{2,30})", # "Name is TreeDogs"
|
| 670 |
+
r"(?:^|\s)([A-Z][a-z]+(?:\s[A-Z][a-z]+)*),?\s+(?:here|speaking|calling)" # "Betty, here" or "John Smith calling"
|
| 671 |
+
]
|
| 672 |
+
|
| 673 |
+
for pattern in name_patterns:
|
| 674 |
+
match = re.search(pattern, message.lower())
|
| 675 |
+
if match:
|
| 676 |
+
potential_name = match.group(1).strip().title()
|
| 677 |
+
# Simple validation - name should be 2-50 chars and not contain numbers
|
| 678 |
+
if 2 <= len(potential_name) <= 50 and not any(char.isdigit() for char in potential_name):
|
| 679 |
+
extracted['name'] = potential_name
|
| 680 |
+
break
|
| 681 |
+
|
| 682 |
+
return extracted
|
| 683 |
+
|
| 684 |
+
def start_conversation(self) -> str:
|
| 685 |
+
"""Start a new conversation."""
|
| 686 |
+
if not self.conversation_started:
|
| 687 |
+
self.conversation_started = True
|
| 688 |
+
return ""
|
| 689 |
+
|
| 690 |
+
def chat(self, message: str) -> str:
|
| 691 |
+
"""Process a user message and return response."""
|
| 692 |
+
import re # Import re module for regex operations
|
| 693 |
+
|
| 694 |
+
try:
|
| 695 |
+
# Validate input
|
| 696 |
+
if not message or not message.strip():
|
| 697 |
+
return "I'd love to help! Could you tell me what you need? π"
|
| 698 |
+
|
| 699 |
+
if len(message) > 1000:
|
| 700 |
+
return "That's quite a message! Could you break it down into smaller parts? I work better with shorter requests. π"
|
| 701 |
+
|
| 702 |
+
# Check for explicit contact information requests (not booking requests)
|
| 703 |
+
contact_request_patterns = [
|
| 704 |
+
r"what.{0,20}peter.{0,20}(phone|number|contact)",
|
| 705 |
+
r"(phone|number|contact).{0,20}peter",
|
| 706 |
+
r"how.{0,20}(reach|contact).{0,20}peter",
|
| 707 |
+
r"peter.{0,20}(email|phone).{0,20}address"
|
| 708 |
+
]
|
| 709 |
+
|
| 710 |
+
is_contact_request = any(re.search(pattern, message.lower()) for pattern in contact_request_patterns)
|
| 711 |
+
is_booking_request = any(word in message.lower() for word in ['book', 'schedule', 'appointment', 'meeting', 'available', 'time'])
|
| 712 |
+
|
| 713 |
+
if is_contact_request and not is_booking_request:
|
| 714 |
+
contact_response = f"Peter's contact information:\nπ Phone: {settings.my_phone_number}\nπ§ Email: {settings.my_email_address}\n\nI can also help you schedule an appointment with Peter through this chat. What would you prefer?"
|
| 715 |
+
return contact_response
|
| 716 |
+
|
| 717 |
+
# Extract and store user information from the message
|
| 718 |
+
extracted_info = self.extract_user_info_from_message(message)
|
| 719 |
+
for info_type, value in extracted_info.items():
|
| 720 |
+
if not self.user_info.get(info_type): # Only update if we don't already have this info
|
| 721 |
+
self.update_user_info(info_type, value)
|
| 722 |
+
print(f"π Extracted {info_type}: {value}")
|
| 723 |
+
|
| 724 |
+
# Debug: Show current user info status
|
| 725 |
+
if extracted_info:
|
| 726 |
+
print(f"π€ Current user info: {self.user_info}")
|
| 727 |
+
|
| 728 |
+
# Start conversation if needed
|
| 729 |
+
if not self.conversation_started:
|
| 730 |
+
self.start_conversation()
|
| 731 |
+
|
| 732 |
+
# Regular chat interaction using ReActAgent
|
| 733 |
+
print(f"π Debug: About to call agent.run() with message: '{message}'")
|
| 734 |
+
print(f"π Debug: Agent type: {type(self.agent)}")
|
| 735 |
+
|
| 736 |
+
# The new ReActAgent needs to be awaited and returns a dict with "response" key
|
| 737 |
+
import asyncio
|
| 738 |
+
|
| 739 |
+
async def run_agent():
|
| 740 |
+
print("π Debug: Calling await agent.run()")
|
| 741 |
+
result = await self.agent.run(user_msg=message)
|
| 742 |
+
print(f"π Debug: Agent result: {result}")
|
| 743 |
+
print(f"π Debug: Result type: {type(result)}")
|
| 744 |
+
|
| 745 |
+
# ReActAgent returns a dict with "response" key
|
| 746 |
+
if isinstance(result, dict) and "response" in result:
|
| 747 |
+
return result["response"]
|
| 748 |
+
else:
|
| 749 |
+
return str(result)
|
| 750 |
+
|
| 751 |
+
# Check if we're already in an event loop
|
| 752 |
+
try:
|
| 753 |
+
loop = asyncio.get_running_loop()
|
| 754 |
+
print("π Debug: Already in event loop, creating task")
|
| 755 |
+
# If we're in an event loop, we need to run this differently
|
| 756 |
+
# Create a new event loop in a thread
|
| 757 |
+
import concurrent.futures
|
| 758 |
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
| 759 |
+
future = executor.submit(asyncio.run, run_agent())
|
| 760 |
+
result = future.result(timeout=30)
|
| 761 |
+
return str(result)
|
| 762 |
+
except RuntimeError:
|
| 763 |
+
print("π Debug: No event loop running, using asyncio.run()")
|
| 764 |
+
# No event loop running, we can use asyncio.run
|
| 765 |
+
result = asyncio.run(run_agent())
|
| 766 |
+
return str(result)
|
| 767 |
+
|
| 768 |
+
except Exception as e:
|
| 769 |
+
import traceback
|
| 770 |
+
print(f"β οΈ Agent error: {e}")
|
| 771 |
+
print(f"π Debug: Full traceback:")
|
| 772 |
+
traceback.print_exc()
|
| 773 |
+
# Friendly error handling with different messages based on error type
|
| 774 |
+
if "max iterations" in str(e).lower():
|
| 775 |
+
return "I got a bit carried away trying to help you! Let me try a simpler approach. Could you repeat your request? π"
|
| 776 |
+
elif "rate limit" in str(e).lower():
|
| 777 |
+
return "Whoa there! I'm getting a bit overwhelmed. Could you wait just a moment and try again? β±οΈ"
|
| 778 |
+
elif "authentication" in str(e).lower():
|
| 779 |
+
return "I'm having trouble connecting to your calendar. Could you check if I'm properly authenticated? π"
|
| 780 |
+
elif "network" in str(e).lower() or "connection" in str(e).lower():
|
| 781 |
+
return "I'm having connectivity issues right now. Let's try that again in a moment! π"
|
| 782 |
+
else:
|
| 783 |
+
error_phrases = [
|
| 784 |
+
"Oops! I'm having a tiny technical hiccup. Could you try that again? π",
|
| 785 |
+
"Oh dear! Something went a bit wonky on my end. Let's give that another shot! π§",
|
| 786 |
+
"Hmm, I'm having a moment here! Could you repeat that for me? π«"
|
| 787 |
+
]
|
| 788 |
+
return random.choice(error_phrases)
|
| 789 |
+
|
| 790 |
+
def stream_chat(self, message: str):
|
| 791 |
+
"""Stream a chat response (generator)."""
|
| 792 |
+
try:
|
| 793 |
+
# Validate input
|
| 794 |
+
if not message or not message.strip():
|
| 795 |
+
error_message = "I'd love to help! Could you tell me what you need? π"
|
| 796 |
+
for word in error_message.split():
|
| 797 |
+
yield word + " "
|
| 798 |
+
return
|
| 799 |
+
|
| 800 |
+
if len(message) > 1000:
|
| 801 |
+
error_message = "That's quite a message! Could you break it down into smaller parts? π"
|
| 802 |
+
for word in error_message.split():
|
| 803 |
+
yield word + " "
|
| 804 |
+
return
|
| 805 |
+
|
| 806 |
+
# Extract and store user information from the message
|
| 807 |
+
extracted_info = self.extract_user_info_from_message(message)
|
| 808 |
+
for info_type, value in extracted_info.items():
|
| 809 |
+
if not self.user_info.get(info_type): # Only update if we don't already have this info
|
| 810 |
+
self.update_user_info(info_type, value)
|
| 811 |
+
|
| 812 |
+
# Start conversation if needed
|
| 813 |
+
if not self.conversation_started:
|
| 814 |
+
self.start_conversation()
|
| 815 |
+
|
| 816 |
+
# Stream the response using ReActAgent
|
| 817 |
+
agent_response = str(self.agent.run(message))
|
| 818 |
+
|
| 819 |
+
# Yield the response word by word to simulate streaming
|
| 820 |
+
for word in agent_response.split():
|
| 821 |
+
yield word + " "
|
| 822 |
+
|
| 823 |
+
except Exception as e:
|
| 824 |
+
print(f"β οΈ Stream chat error: {e}")
|
| 825 |
+
if "rate limit" in str(e).lower():
|
| 826 |
+
error_message = "I'm getting a bit overwhelmed. Let's try again in a moment! β±οΈ"
|
| 827 |
+
elif "authentication" in str(e).lower():
|
| 828 |
+
error_message = "Having calendar connection issues. Please check authentication! π"
|
| 829 |
+
else:
|
| 830 |
+
error_message = "I'm having a bit of trouble right now. Could you try again? π"
|
| 831 |
+
|
| 832 |
+
for word in error_message.split():
|
| 833 |
+
yield word + " "
|
| 834 |
+
|
| 835 |
+
def reset_conversation(self):
|
| 836 |
+
"""Reset the conversation memory."""
|
| 837 |
+
self.memory.reset()
|
| 838 |
+
self.conversation_started = False
|
| 839 |
+
|
| 840 |
+
def get_conversation_history(self) -> List[Dict]:
|
| 841 |
+
"""Get the conversation history."""
|
| 842 |
+
messages = self.memory.get_all()
|
| 843 |
+
return [
|
| 844 |
+
{
|
| 845 |
+
"role": msg.role.value,
|
| 846 |
+
"content": msg.content,
|
| 847 |
+
"timestamp": getattr(msg, 'timestamp', None)
|
| 848 |
+
}
|
| 849 |
+
for msg in messages
|
| 850 |
+
]
|
| 851 |
+
|
| 852 |
+
def add_context(self, context: str):
|
| 853 |
+
"""Add context information to the conversation."""
|
| 854 |
+
context_message = f"[Context: {context}]"
|
| 855 |
+
# Add as system message to memory if needed
|
| 856 |
+
|
| 857 |
+
def get_available_tools(self) -> List[str]:
|
| 858 |
+
"""Get list of available tool names."""
|
| 859 |
+
return [tool.metadata.name for tool in self.calendar_tools.get_tools()]
|
| 860 |
+
|
| 861 |
+
def handle_special_commands(self, message: str) -> Optional[str]:
|
| 862 |
+
"""Handle special commands like /help, /schedule, etc."""
|
| 863 |
+
message = message.lower().strip()
|
| 864 |
+
|
| 865 |
+
if message in ['/help', 'help', 'what can you do']:
|
| 866 |
+
return """I'm ChatCal, Peter Michael Gits' scheduling assistant! π
Here's how I can help you:
|
| 867 |
+
|
| 868 |
+
π€ **Book a consultation with Peter**: "I'd like to schedule a business consultation"
|
| 869 |
+
πΌ **Schedule a meeting**: "I need a 30-minute meeting with Peter next week"
|
| 870 |
+
π **Project discussions**: "Book time to discuss my project with Peter"
|
| 871 |
+
π― **Advisory sessions**: "I need a 90-minute advisory session"
|
| 872 |
+
|
| 873 |
+
Meeting types available:
|
| 874 |
+
β’ **Quick chat** (30 minutes) - Brief discussions
|
| 875 |
+
β’ **Consultation** (60 minutes) - Business consultations
|
| 876 |
+
β’ **Project meeting** (60 minutes) - Project planning/review
|
| 877 |
+
β’ **Advisory session** (90 minutes) - Extended strategic sessions
|
| 878 |
+
|
| 879 |
+
Peter typically meets between 9 AM and 5 PM. Just tell me what you'd like to discuss and when you're available!"""
|
| 880 |
+
|
| 881 |
+
elif message in ['/status', 'status']:
|
| 882 |
+
available_tools = self.get_available_tools()
|
| 883 |
+
auth_status = "β
Connected" if self.calendar_tools.calendar_service.auth.is_authenticated() else "β Not authenticated"
|
| 884 |
+
|
| 885 |
+
return f"""π **ChatCal Status**
|
| 886 |
+
Calendar Connection: {auth_status}
|
| 887 |
+
Available Tools: {len(available_tools)}
|
| 888 |
+
Session ID: {self.session_id}
|
| 889 |
+
Tools: {', '.join(available_tools)}
|
| 890 |
+
|
| 891 |
+
Ready to help you manage your calendar! π"""
|
| 892 |
+
|
| 893 |
+
return None
|
| 894 |
+
|
| 895 |
+
def store_meeting_id(self, meeting_id: str, meeting_info: dict):
|
| 896 |
+
"""Store meeting ID and info for cancellation tracking."""
|
| 897 |
+
self.stored_meetings[meeting_id] = meeting_info
|
| 898 |
+
# Save to session for persistence
|
| 899 |
+
self._save_stored_meetings_to_session()
|
| 900 |
+
|
| 901 |
+
def get_stored_meetings(self) -> dict:
|
| 902 |
+
"""Get all stored meeting IDs and info."""
|
| 903 |
+
return self.stored_meetings.copy()
|
| 904 |
+
|
| 905 |
+
def remove_stored_meeting(self, meeting_id: str):
|
| 906 |
+
"""Remove a stored meeting ID."""
|
| 907 |
+
if meeting_id in self.stored_meetings:
|
| 908 |
+
del self.stored_meetings[meeting_id]
|
| 909 |
+
# Save to session for persistence
|
| 910 |
+
self._save_stored_meetings_to_session()
|
| 911 |
+
|
| 912 |
+
def _load_user_info_from_session(self) -> Optional[Dict]:
|
| 913 |
+
"""Load user info from session storage."""
|
| 914 |
+
try:
|
| 915 |
+
# Use dynamic import to avoid circular imports
|
| 916 |
+
from app.core.session import session_manager
|
| 917 |
+
|
| 918 |
+
session_data = session_manager.get_session(self.session_id)
|
| 919 |
+
if session_data and 'user_data' in session_data:
|
| 920 |
+
user_data = session_data['user_data']
|
| 921 |
+
if 'user_info' in user_data:
|
| 922 |
+
print(f"π₯ Loaded user info from session: {user_data['user_info']}")
|
| 923 |
+
return user_data['user_info']
|
| 924 |
+
return None
|
| 925 |
+
except Exception as e:
|
| 926 |
+
print(f"β οΈ Failed to load user info from session: {e}")
|
| 927 |
+
return None
|
| 928 |
+
|
| 929 |
+
def _save_user_info_to_session(self):
|
| 930 |
+
"""Save user info to session storage."""
|
| 931 |
+
try:
|
| 932 |
+
# Use dynamic import to avoid circular imports
|
| 933 |
+
from app.core.session import session_manager
|
| 934 |
+
|
| 935 |
+
session_data = session_manager.get_session(self.session_id)
|
| 936 |
+
if session_data:
|
| 937 |
+
if 'user_data' not in session_data:
|
| 938 |
+
session_data['user_data'] = {}
|
| 939 |
+
session_data['user_data']['user_info'] = self.user_info
|
| 940 |
+
session_manager.update_session(self.session_id, session_data)
|
| 941 |
+
print(f"πΎ Saved user info to session: {self.user_info}")
|
| 942 |
+
except Exception as e:
|
| 943 |
+
print(f"β οΈ Failed to save user info to session: {e}")
|
| 944 |
+
|
| 945 |
+
def _load_stored_meetings_from_session(self) -> Optional[Dict]:
|
| 946 |
+
"""Load stored meetings from session storage."""
|
| 947 |
+
try:
|
| 948 |
+
# Use dynamic import to avoid circular imports
|
| 949 |
+
from app.core.session import session_manager
|
| 950 |
+
from datetime import datetime
|
| 951 |
+
|
| 952 |
+
session_data = session_manager.get_session(self.session_id)
|
| 953 |
+
if session_data and 'user_data' in session_data:
|
| 954 |
+
user_data = session_data['user_data']
|
| 955 |
+
if 'stored_meetings' in user_data:
|
| 956 |
+
meetings = user_data['stored_meetings']
|
| 957 |
+
|
| 958 |
+
# Convert ISO format strings back to datetime objects
|
| 959 |
+
for meeting_id, meeting_info in meetings.items():
|
| 960 |
+
if 'start_time' in meeting_info and isinstance(meeting_info['start_time'], str):
|
| 961 |
+
try:
|
| 962 |
+
meeting_info['start_time'] = datetime.fromisoformat(meeting_info['start_time'])
|
| 963 |
+
except ValueError:
|
| 964 |
+
pass # Keep as string if conversion fails
|
| 965 |
+
|
| 966 |
+
print(f"π₯ Loaded stored meetings from session: {meetings}")
|
| 967 |
+
return meetings
|
| 968 |
+
return None
|
| 969 |
+
except Exception as e:
|
| 970 |
+
print(f"β οΈ Failed to load stored meetings from session: {e}")
|
| 971 |
+
return None
|
| 972 |
+
|
| 973 |
+
def _save_stored_meetings_to_session(self):
|
| 974 |
+
"""Save stored meetings to session storage."""
|
| 975 |
+
try:
|
| 976 |
+
# Use dynamic import to avoid circular imports
|
| 977 |
+
from app.core.session import session_manager
|
| 978 |
+
|
| 979 |
+
session_data = session_manager.get_session(self.session_id)
|
| 980 |
+
if session_data:
|
| 981 |
+
if 'user_data' not in session_data:
|
| 982 |
+
session_data['user_data'] = {}
|
| 983 |
+
|
| 984 |
+
# Convert datetime objects to strings for JSON serialization
|
| 985 |
+
serializable_meetings = {}
|
| 986 |
+
for meeting_id, meeting_info in self.stored_meetings.items():
|
| 987 |
+
serializable_info = meeting_info.copy()
|
| 988 |
+
if 'start_time' in serializable_info and hasattr(serializable_info['start_time'], 'isoformat'):
|
| 989 |
+
serializable_info['start_time'] = serializable_info['start_time'].isoformat()
|
| 990 |
+
serializable_meetings[meeting_id] = serializable_info
|
| 991 |
+
|
| 992 |
+
session_data['user_data']['stored_meetings'] = serializable_meetings
|
| 993 |
+
session_manager.update_session(self.session_id, session_data)
|
| 994 |
+
print(f"πΎ Saved stored meetings to session: {serializable_meetings}")
|
| 995 |
+
except Exception as e:
|
| 996 |
+
print(f"β οΈ Failed to save stored meetings to session: {e}")
|
app/core/conversation.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Optional
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from llama_index.core.llms import ChatMessage, MessageRole
|
| 4 |
+
from llama_index.core.memory import ChatMemoryBuffer
|
| 5 |
+
from app.core.agent import ChatCalAgent
|
| 6 |
+
# GREETING_TEMPLATES removed - no longer needed
|
| 7 |
+
import random
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ConversationManager:
|
| 11 |
+
"""Manages conversation state and memory for chat sessions."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, session_id: str, max_history: int = 20):
|
| 14 |
+
self.session_id = session_id
|
| 15 |
+
self.max_history = max_history
|
| 16 |
+
self.agent = ChatCalAgent(session_id)
|
| 17 |
+
self.conversation_started = False
|
| 18 |
+
|
| 19 |
+
def start_conversation(self) -> str:
|
| 20 |
+
"""Start a new conversation with a friendly greeting."""
|
| 21 |
+
if not self.conversation_started:
|
| 22 |
+
greeting = self.agent.start_conversation()
|
| 23 |
+
self.conversation_started = True
|
| 24 |
+
return greeting
|
| 25 |
+
return ""
|
| 26 |
+
|
| 27 |
+
def get_response(self, user_input: str) -> str:
|
| 28 |
+
"""Get a response from the agent for the user input."""
|
| 29 |
+
# Check for special commands first
|
| 30 |
+
special_response = self.agent.handle_special_commands(user_input)
|
| 31 |
+
if special_response:
|
| 32 |
+
return special_response
|
| 33 |
+
|
| 34 |
+
# Use the agent to handle the conversation
|
| 35 |
+
return self.agent.chat(user_input)
|
| 36 |
+
|
| 37 |
+
def get_streaming_response(self, user_input: str):
|
| 38 |
+
"""Get a streaming response from the agent for the user input."""
|
| 39 |
+
# Check for special commands first
|
| 40 |
+
special_response = self.agent.handle_special_commands(user_input)
|
| 41 |
+
if special_response:
|
| 42 |
+
# Stream the special response word by word
|
| 43 |
+
for word in special_response.split():
|
| 44 |
+
yield word + " "
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
# Use the agent's streaming capability
|
| 48 |
+
for token in self.agent.stream_chat(user_input):
|
| 49 |
+
yield token
|
| 50 |
+
|
| 51 |
+
def clear_history(self):
|
| 52 |
+
"""Clear the conversation history."""
|
| 53 |
+
self.agent.reset_conversation()
|
| 54 |
+
self.conversation_started = False
|
| 55 |
+
|
| 56 |
+
def get_conversation_history(self) -> List[Dict]:
|
| 57 |
+
"""Get the current conversation history."""
|
| 58 |
+
return self.agent.get_conversation_history()
|
| 59 |
+
|
| 60 |
+
def export_conversation(self) -> Dict:
|
| 61 |
+
"""Export the conversation for storage or analysis."""
|
| 62 |
+
messages = self.get_conversation_history()
|
| 63 |
+
return {
|
| 64 |
+
"session_id": self.session_id,
|
| 65 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 66 |
+
"messages": [
|
| 67 |
+
{
|
| 68 |
+
"role": msg.role.value,
|
| 69 |
+
"content": msg.content,
|
| 70 |
+
"timestamp": getattr(msg, 'timestamp', None)
|
| 71 |
+
}
|
| 72 |
+
for msg in messages
|
| 73 |
+
]
|
| 74 |
+
}
|
app/core/email_service.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Email service for sending calendar invitations."""
|
| 2 |
+
|
| 3 |
+
import smtplib
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from email.mime.multipart import MIMEMultipart
|
| 7 |
+
from email.mime.text import MIMEText
|
| 8 |
+
from email.mime.base import MIMEBase
|
| 9 |
+
from email import encoders
|
| 10 |
+
from typing import Optional, Dict, Any
|
| 11 |
+
from app.config import settings
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class EmailService:
|
| 15 |
+
"""Handles sending email invitations for calendar appointments."""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.smtp_server = settings.smtp_server
|
| 19 |
+
self.smtp_port = settings.smtp_port
|
| 20 |
+
self.username = settings.smtp_username or settings.my_email_address
|
| 21 |
+
self.password = settings.smtp_password
|
| 22 |
+
self.from_name = settings.email_from_name
|
| 23 |
+
self.from_email = settings.my_email_address
|
| 24 |
+
|
| 25 |
+
def create_calendar_invite(self,
|
| 26 |
+
title: str,
|
| 27 |
+
start_datetime: datetime,
|
| 28 |
+
end_datetime: datetime,
|
| 29 |
+
description: str = "",
|
| 30 |
+
location: str = "",
|
| 31 |
+
organizer_email: str = None,
|
| 32 |
+
attendee_emails: list = None) -> str:
|
| 33 |
+
"""Create an iCal calendar invitation."""
|
| 34 |
+
|
| 35 |
+
if not organizer_email:
|
| 36 |
+
organizer_email = self.from_email
|
| 37 |
+
|
| 38 |
+
if not attendee_emails:
|
| 39 |
+
attendee_emails = []
|
| 40 |
+
|
| 41 |
+
# Generate unique UID for the event
|
| 42 |
+
uid = str(uuid.uuid4())
|
| 43 |
+
|
| 44 |
+
# Format datetime for iCal
|
| 45 |
+
def format_dt(dt):
|
| 46 |
+
return dt.strftime('%Y%m%dT%H%M%SZ')
|
| 47 |
+
|
| 48 |
+
now = datetime.now(timezone.utc)
|
| 49 |
+
|
| 50 |
+
ical_content = f"""BEGIN:VCALENDAR
|
| 51 |
+
VERSION:2.0
|
| 52 |
+
PRODID:-//ChatCal.ai//Calendar Event//EN
|
| 53 |
+
METHOD:REQUEST
|
| 54 |
+
BEGIN:VEVENT
|
| 55 |
+
UID:{uid}
|
| 56 |
+
DTSTAMP:{format_dt(now)}
|
| 57 |
+
DTSTART:{format_dt(start_datetime)}
|
| 58 |
+
DTEND:{format_dt(end_datetime)}
|
| 59 |
+
SUMMARY:{title}
|
| 60 |
+
DESCRIPTION:{description}
|
| 61 |
+
LOCATION:{location}
|
| 62 |
+
ORGANIZER:MAILTO:{organizer_email}"""
|
| 63 |
+
|
| 64 |
+
for attendee in attendee_emails:
|
| 65 |
+
ical_content += f"\nATTENDEE:MAILTO:{attendee}"
|
| 66 |
+
|
| 67 |
+
ical_content += f"""
|
| 68 |
+
STATUS:CONFIRMED
|
| 69 |
+
TRANSP:OPAQUE
|
| 70 |
+
END:VEVENT
|
| 71 |
+
END:VCALENDAR"""
|
| 72 |
+
|
| 73 |
+
return ical_content
|
| 74 |
+
|
| 75 |
+
def send_invitation_email(self,
|
| 76 |
+
to_email: str,
|
| 77 |
+
to_name: str,
|
| 78 |
+
title: str,
|
| 79 |
+
start_datetime: datetime,
|
| 80 |
+
end_datetime: datetime,
|
| 81 |
+
description: str = "",
|
| 82 |
+
user_phone: str = "",
|
| 83 |
+
meeting_type: str = "Meeting",
|
| 84 |
+
meet_link: str = "") -> bool:
|
| 85 |
+
"""Send a calendar invitation email."""
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
# Create email message
|
| 89 |
+
msg = MIMEMultipart('alternative')
|
| 90 |
+
msg['Subject'] = f"Meeting Invitation: {title}"
|
| 91 |
+
msg['From'] = f"{self.from_name} <{self.from_email}>"
|
| 92 |
+
msg['To'] = f"{to_name} <{to_email}>"
|
| 93 |
+
|
| 94 |
+
# Create email body
|
| 95 |
+
# In testing mode, treat Peter's email as a regular user email for testing purposes
|
| 96 |
+
is_peter_email = (to_email == settings.my_email_address and not settings.testing_mode)
|
| 97 |
+
|
| 98 |
+
if is_peter_email:
|
| 99 |
+
# Email to Peter
|
| 100 |
+
html_body = self._create_peter_email_body(
|
| 101 |
+
title, start_datetime, end_datetime, description, to_name, user_phone, meet_link
|
| 102 |
+
)
|
| 103 |
+
else:
|
| 104 |
+
# Email to user (or Peter's email in testing mode)
|
| 105 |
+
html_body = self._create_user_email_body(
|
| 106 |
+
title, start_datetime, end_datetime, description, meeting_type, meet_link
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Create plain text version
|
| 110 |
+
text_body = self._html_to_text(html_body)
|
| 111 |
+
|
| 112 |
+
# Attach both versions
|
| 113 |
+
part1 = MIMEText(text_body, 'plain')
|
| 114 |
+
part2 = MIMEText(html_body, 'html')
|
| 115 |
+
|
| 116 |
+
msg.attach(part1)
|
| 117 |
+
msg.attach(part2)
|
| 118 |
+
|
| 119 |
+
# Create and attach calendar invitation
|
| 120 |
+
ical_content = self.create_calendar_invite(
|
| 121 |
+
title=title,
|
| 122 |
+
start_datetime=start_datetime,
|
| 123 |
+
end_datetime=end_datetime,
|
| 124 |
+
description=description,
|
| 125 |
+
organizer_email=self.from_email,
|
| 126 |
+
attendee_emails=[to_email, self.from_email]
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
# Attach calendar file
|
| 130 |
+
cal_attachment = MIMEBase('text', 'calendar')
|
| 131 |
+
cal_attachment.set_payload(ical_content.encode('utf-8'))
|
| 132 |
+
encoders.encode_base64(cal_attachment)
|
| 133 |
+
cal_attachment.add_header('Content-Disposition', 'attachment; filename="meeting.ics"')
|
| 134 |
+
cal_attachment.add_header('Content-Type', 'text/calendar; method=REQUEST; name="meeting.ics"')
|
| 135 |
+
msg.attach(cal_attachment)
|
| 136 |
+
|
| 137 |
+
# Send email
|
| 138 |
+
if self.password: # Only send if SMTP credentials are configured
|
| 139 |
+
print(f"π§ SMTP configured, sending email to {to_email}")
|
| 140 |
+
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
| 141 |
+
server.starttls()
|
| 142 |
+
server.login(self.username, self.password)
|
| 143 |
+
server.send_message(msg)
|
| 144 |
+
|
| 145 |
+
print(f"β
Email invitation sent to {to_email}")
|
| 146 |
+
return True
|
| 147 |
+
else:
|
| 148 |
+
print(f"β οΈ SMTP not configured (no password), skipping actual email send to {to_email}")
|
| 149 |
+
print(f"π§ Email invitation prepared for {to_email} (SMTP not configured)")
|
| 150 |
+
return True # Consider it successful for demo purposes
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
print(f"β Failed to send email to {to_email}: {e}")
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
def send_cancellation_email(self,
|
| 157 |
+
to_email: str,
|
| 158 |
+
to_name: str,
|
| 159 |
+
meeting_title: str,
|
| 160 |
+
original_datetime: datetime) -> bool:
|
| 161 |
+
"""Send a meeting cancellation email."""
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
# Create email message
|
| 165 |
+
msg = MIMEMultipart('alternative')
|
| 166 |
+
msg['Subject'] = f"Meeting Cancelled: {meeting_title}"
|
| 167 |
+
msg['From'] = f"{self.from_name} <{self.from_email}>"
|
| 168 |
+
msg['To'] = f"{to_name} <{to_email}>"
|
| 169 |
+
|
| 170 |
+
# Create email body
|
| 171 |
+
formatted_time = original_datetime.strftime('%A, %B %d, %Y at %I:%M %p %Z')
|
| 172 |
+
|
| 173 |
+
html_body = f"""
|
| 174 |
+
<html>
|
| 175 |
+
<body style="font-family: Arial, sans-serif; margin: 20px; color: #333;">
|
| 176 |
+
<h2 style="color: #f44336;">β Meeting Cancelled</h2>
|
| 177 |
+
|
| 178 |
+
<div style="background: #ffebee; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #f44336;">
|
| 179 |
+
<h3 style="margin-top: 0; color: #c62828;">{meeting_title}</h3>
|
| 180 |
+
|
| 181 |
+
<div style="margin: 15px 0;">
|
| 182 |
+
<strong>π
Was scheduled for:</strong><br>
|
| 183 |
+
{formatted_time}
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div style="margin: 15px 0;">
|
| 187 |
+
<strong>π€ With:</strong> Peter Michael Gits<br>
|
| 188 |
+
<strong>π Peter's Phone:</strong> {settings.my_phone_number}<br>
|
| 189 |
+
<strong>π§ Peter's Email:</strong> {settings.my_email_address}
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div style="background: #e3f2fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #2196f3;">
|
| 194 |
+
<h4 style="margin-top: 0; color: #1565c0;">Need to reschedule?</h4>
|
| 195 |
+
<p style="margin-bottom: 0; color: #1565c0;">
|
| 196 |
+
Feel free to book a new appointment through ChatCal.ai or contact Peter directly.
|
| 197 |
+
</p>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<p style="color: #6c757d; font-size: 14px;">
|
| 201 |
+
This cancellation was processed through ChatCal.ai.
|
| 202 |
+
</p>
|
| 203 |
+
|
| 204 |
+
<hr style="border: none; border-top: 1px solid #dee2e6; margin: 20px 0;">
|
| 205 |
+
<p style="color: #6c757d; font-size: 12px;">
|
| 206 |
+
Sent by ChatCal.ai - Peter Michael Gits' AI Scheduling Assistant<br>
|
| 207 |
+
Peter: {settings.my_phone_number} | {settings.my_email_address}
|
| 208 |
+
</p>
|
| 209 |
+
</body>
|
| 210 |
+
</html>
|
| 211 |
+
"""
|
| 212 |
+
|
| 213 |
+
# Create plain text version
|
| 214 |
+
text_body = f"""
|
| 215 |
+
Meeting Cancelled: {meeting_title}
|
| 216 |
+
|
| 217 |
+
Was scheduled for: {formatted_time}
|
| 218 |
+
With: Peter Michael Gits
|
| 219 |
+
|
| 220 |
+
Need to reschedule? Contact Peter:
|
| 221 |
+
Phone: {settings.my_phone_number}
|
| 222 |
+
Email: {settings.my_email_address}
|
| 223 |
+
|
| 224 |
+
This cancellation was processed through ChatCal.ai.
|
| 225 |
+
""".strip()
|
| 226 |
+
|
| 227 |
+
# Attach both versions
|
| 228 |
+
part1 = MIMEText(text_body, 'plain')
|
| 229 |
+
part2 = MIMEText(html_body, 'html')
|
| 230 |
+
|
| 231 |
+
msg.attach(part1)
|
| 232 |
+
msg.attach(part2)
|
| 233 |
+
|
| 234 |
+
# Send email
|
| 235 |
+
if self.password: # Only send if SMTP credentials are configured
|
| 236 |
+
print(f"π§ SMTP configured, sending cancellation email to {to_email}")
|
| 237 |
+
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
| 238 |
+
server.starttls()
|
| 239 |
+
server.login(self.username, self.password)
|
| 240 |
+
server.send_message(msg)
|
| 241 |
+
|
| 242 |
+
print(f"β
Cancellation email sent to {to_email}")
|
| 243 |
+
return True
|
| 244 |
+
else:
|
| 245 |
+
print(f"β οΈ SMTP not configured, skipping cancellation email to {to_email}")
|
| 246 |
+
print(f"π§ Cancellation email prepared for {to_email} (SMTP not configured)")
|
| 247 |
+
return True # Consider it successful for demo purposes
|
| 248 |
+
|
| 249 |
+
except Exception as e:
|
| 250 |
+
print(f"β Failed to send cancellation email to {to_email}: {e}")
|
| 251 |
+
return False
|
| 252 |
+
|
| 253 |
+
def _create_peter_email_body(self, title: str, start_datetime: datetime,
|
| 254 |
+
end_datetime: datetime, description: str,
|
| 255 |
+
user_name: str, user_phone: str, meet_link: str = "") -> str:
|
| 256 |
+
"""Create email body for Peter."""
|
| 257 |
+
|
| 258 |
+
return f"""
|
| 259 |
+
<html>
|
| 260 |
+
<body style="font-family: Arial, sans-serif; margin: 20px; color: #333;">
|
| 261 |
+
<h2 style="color: #2c3e50;">π
New Meeting Scheduled</h2>
|
| 262 |
+
|
| 263 |
+
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
| 264 |
+
<h3 style="margin-top: 0; color: #495057;">{title}</h3>
|
| 265 |
+
|
| 266 |
+
<div style="margin: 15px 0;">
|
| 267 |
+
<strong>π
Date & Time:</strong><br>
|
| 268 |
+
{start_datetime.strftime('%A, %B %d, %Y')}<br>
|
| 269 |
+
{start_datetime.strftime('%I:%M %p')} - {end_datetime.strftime('%I:%M %p')} ({start_datetime.strftime('%Z')})
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
<div style="margin: 15px 0;">
|
| 273 |
+
<strong>π€ Meeting with:</strong> {user_name}<br>
|
| 274 |
+
<strong>π Phone:</strong> {user_phone or 'Not provided'}
|
| 275 |
+
{f'<br><strong>π₯ Google Meet:</strong> <a href="{meet_link}" style="color: #1976d2;">{meet_link}</a>' if meet_link else ''}
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
{f'<div style="margin: 15px 0;"><strong>π Details:</strong><br>{description}</div>' if description else ''}
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<p style="color: #6c757d; font-size: 14px;">
|
| 282 |
+
This meeting was scheduled through ChatCal.ai. The calendar invitation is attached to this email.
|
| 283 |
+
</p>
|
| 284 |
+
|
| 285 |
+
<hr style="border: none; border-top: 1px solid #dee2e6; margin: 20px 0;">
|
| 286 |
+
<p style="color: #6c757d; font-size: 12px;">
|
| 287 |
+
Sent by ChatCal.ai - Peter Michael Gits' AI Scheduling Assistant
|
| 288 |
+
</p>
|
| 289 |
+
</body>
|
| 290 |
+
</html>
|
| 291 |
+
"""
|
| 292 |
+
|
| 293 |
+
def _create_user_email_body(self, title: str, start_datetime: datetime,
|
| 294 |
+
end_datetime: datetime, description: str,
|
| 295 |
+
meeting_type: str, meet_link: str = "") -> str:
|
| 296 |
+
"""Create email body for the user."""
|
| 297 |
+
|
| 298 |
+
return f"""
|
| 299 |
+
<html>
|
| 300 |
+
<body style="font-family: Arial, sans-serif; margin: 20px; color: #333;">
|
| 301 |
+
<h2 style="color: #2c3e50;">β
Your Meeting with Peter Michael Gits is Confirmed!</h2>
|
| 302 |
+
|
| 303 |
+
<div style="background: #e8f5e9; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #4caf50;">
|
| 304 |
+
<h3 style="margin-top: 0; color: #2e7d32;">{title}</h3>
|
| 305 |
+
|
| 306 |
+
<div style="margin: 15px 0;">
|
| 307 |
+
<strong>π
Date & Time:</strong><br>
|
| 308 |
+
{start_datetime.strftime('%A, %B %d, %Y')}<br>
|
| 309 |
+
{start_datetime.strftime('%I:%M %p')} - {end_datetime.strftime('%I:%M %p')} ({start_datetime.strftime('%Z')})
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
<div style="margin: 15px 0;">
|
| 313 |
+
<strong>π€ Meeting with:</strong> Peter Michael Gits<br>
|
| 314 |
+
<strong>π Peter's Phone:</strong> {settings.my_phone_number}<br>
|
| 315 |
+
<strong>π§ Peter's Email:</strong> {settings.my_email_address}
|
| 316 |
+
{f'<br><strong>π₯ Google Meet Link:</strong> <a href="{meet_link}" style="color: #1976d2; font-weight: bold;">{meet_link}</a>' if meet_link else ''}
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
{f'<div style="margin: 15px 0;"><strong>π Meeting Details:</strong><br>{description}</div>' if description else ''}
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #ffc107;">
|
| 323 |
+
<h4 style="margin-top: 0; color: #856404;">π What's Next?</h4>
|
| 324 |
+
<ul style="color: #856404; margin-bottom: 0;">
|
| 325 |
+
<li>Add this meeting to your calendar using the attached invitation file</li>
|
| 326 |
+
<li>Peter will reach out via the contact method you provided</li>
|
| 327 |
+
<li>If you need to reschedule, please contact Peter directly</li>
|
| 328 |
+
</ul>
|
| 329 |
+
</div>
|
| 330 |
+
|
| 331 |
+
<p style="color: #6c757d; font-size: 14px;">
|
| 332 |
+
Looking forward to your meeting! The calendar invitation is attached to this email.
|
| 333 |
+
</p>
|
| 334 |
+
|
| 335 |
+
<hr style="border: none; border-top: 1px solid #dee2e6; margin: 20px 0;">
|
| 336 |
+
<p style="color: #6c757d; font-size: 12px;">
|
| 337 |
+
Sent by ChatCal.ai - Peter Michael Gits' AI Scheduling Assistant<br>
|
| 338 |
+
Peter: {settings.my_phone_number} | {settings.my_email_address}
|
| 339 |
+
</p>
|
| 340 |
+
</body>
|
| 341 |
+
</html>
|
| 342 |
+
"""
|
| 343 |
+
|
| 344 |
+
def _html_to_text(self, html: str) -> str:
|
| 345 |
+
"""Convert HTML email to plain text."""
|
| 346 |
+
import re
|
| 347 |
+
|
| 348 |
+
# Remove HTML tags
|
| 349 |
+
text = re.sub('<[^<]+?>', '', html)
|
| 350 |
+
|
| 351 |
+
# Clean up whitespace
|
| 352 |
+
text = re.sub(r'\n\s*\n', '\n\n', text)
|
| 353 |
+
text = text.strip()
|
| 354 |
+
|
| 355 |
+
return text
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
# Global email service instance
|
| 359 |
+
email_service = EmailService()
|
app/core/exceptions.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom exceptions for ChatCal.ai application."""
|
| 2 |
+
|
| 3 |
+
from typing import Optional, Dict, Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ChatCalException(Exception):
|
| 7 |
+
"""Base exception for ChatCal.ai application."""
|
| 8 |
+
|
| 9 |
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
| 10 |
+
self.message = message
|
| 11 |
+
self.details = details or {}
|
| 12 |
+
super().__init__(self.message)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AuthenticationError(ChatCalException):
|
| 16 |
+
"""Raised when authentication fails."""
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class CalendarError(ChatCalException):
|
| 21 |
+
"""Raised when calendar operations fail."""
|
| 22 |
+
pass
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class LLMError(ChatCalException):
|
| 26 |
+
"""Raised when LLM operations fail."""
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ValidationError(ChatCalException):
|
| 31 |
+
"""Raised when input validation fails."""
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class RateLimitError(ChatCalException):
|
| 36 |
+
"""Raised when API rate limits are exceeded."""
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ServiceUnavailableError(ChatCalException):
|
| 41 |
+
"""Raised when external services are unavailable."""
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ConfigurationError(ChatCalException):
|
| 46 |
+
"""Raised when configuration is invalid."""
|
| 47 |
+
pass
|
app/core/jwt_session.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from typing import Dict, Optional
|
| 5 |
+
import jwt
|
| 6 |
+
from app.config import settings
|
| 7 |
+
from app.core.conversation import ConversationManager
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class JWTSessionManager:
|
| 11 |
+
"""JWT-based session manager - drop-in replacement for Redis SessionManager."""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.session_timeout = timedelta(minutes=settings.session_timeout_minutes)
|
| 15 |
+
self.conversations: Dict[str, ConversationManager] = {}
|
| 16 |
+
self.secret_key = settings.secret_key
|
| 17 |
+
|
| 18 |
+
def _encode_session(self, session_data: Dict) -> str:
|
| 19 |
+
"""Encode session data as JWT."""
|
| 20 |
+
payload = {
|
| 21 |
+
**session_data,
|
| 22 |
+
'exp': datetime.utcnow() + self.session_timeout
|
| 23 |
+
}
|
| 24 |
+
return jwt.encode(payload, self.secret_key, algorithm='HS256')
|
| 25 |
+
|
| 26 |
+
def _decode_session(self, token: str) -> Optional[Dict]:
|
| 27 |
+
"""Decode JWT session token."""
|
| 28 |
+
try:
|
| 29 |
+
payload = jwt.decode(token, self.secret_key, algorithms=['HS256'])
|
| 30 |
+
# Remove JWT specific fields
|
| 31 |
+
payload.pop('exp', None)
|
| 32 |
+
return payload
|
| 33 |
+
except jwt.ExpiredSignatureError:
|
| 34 |
+
return None
|
| 35 |
+
except jwt.InvalidTokenError:
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
def create_session(self, user_data: Optional[Dict] = None) -> str:
|
| 39 |
+
"""Create a new session and return the session ID."""
|
| 40 |
+
session_id = str(uuid.uuid4())
|
| 41 |
+
session_data = {
|
| 42 |
+
"session_id": session_id,
|
| 43 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 44 |
+
"last_activity": datetime.utcnow().isoformat(),
|
| 45 |
+
"user_data": user_data or {},
|
| 46 |
+
"conversation_started": False
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
# Return the session ID (JWT token will be created when needed)
|
| 50 |
+
return session_id
|
| 51 |
+
|
| 52 |
+
def get_session(self, session_id: str) -> Optional[Dict]:
|
| 53 |
+
"""Retrieve session data (for compatibility - JWT will be handled at API level)."""
|
| 54 |
+
# This will be overridden to work with JWT tokens at the API level
|
| 55 |
+
if session_id in self.conversations:
|
| 56 |
+
return {
|
| 57 |
+
"session_id": session_id,
|
| 58 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 59 |
+
"last_activity": datetime.utcnow().isoformat(),
|
| 60 |
+
"user_data": {},
|
| 61 |
+
"conversation_started": True
|
| 62 |
+
}
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
def update_session(self, session_id: str, updates: Dict) -> bool:
|
| 66 |
+
"""Update session data."""
|
| 67 |
+
# For JWT sessions, updates are handled at the conversation level
|
| 68 |
+
return True
|
| 69 |
+
|
| 70 |
+
def delete_session(self, session_id: str) -> bool:
|
| 71 |
+
"""Delete a session."""
|
| 72 |
+
# Remove from memory
|
| 73 |
+
if session_id in self.conversations:
|
| 74 |
+
del self.conversations[session_id]
|
| 75 |
+
return True
|
| 76 |
+
|
| 77 |
+
def get_or_create_conversation(self, session_id: str) -> Optional[ConversationManager]:
|
| 78 |
+
"""Get or create a conversation manager for a session."""
|
| 79 |
+
# Return existing conversation if in memory
|
| 80 |
+
if session_id in self.conversations:
|
| 81 |
+
return self.conversations[session_id]
|
| 82 |
+
|
| 83 |
+
# Create new conversation
|
| 84 |
+
conversation = ConversationManager(
|
| 85 |
+
session_id=session_id,
|
| 86 |
+
max_history=settings.max_conversation_history
|
| 87 |
+
)
|
| 88 |
+
self.conversations[session_id] = conversation
|
| 89 |
+
|
| 90 |
+
return conversation
|
| 91 |
+
|
| 92 |
+
def store_conversation_history(self, session_id: str) -> bool:
|
| 93 |
+
"""Store conversation history (no-op for JWT sessions)."""
|
| 94 |
+
return True
|
| 95 |
+
|
| 96 |
+
def get_conversation_history(self, session_id: str) -> Optional[Dict]:
|
| 97 |
+
"""Retrieve conversation history (no-op for JWT sessions)."""
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
def extend_session(self, session_id: str) -> bool:
|
| 101 |
+
"""Extend session timeout (handled automatically with JWT)."""
|
| 102 |
+
return True
|
| 103 |
+
|
| 104 |
+
def cleanup_expired_conversations(self):
|
| 105 |
+
"""Clean up expired conversations from memory (no-op for JWT)."""
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# Global session manager instance
|
| 110 |
+
jwt_session_manager = JWTSessionManager()
|
app/core/llm.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
import os
|
| 3 |
+
from llama_index.core import Settings
|
| 4 |
+
from llama_index.llms.anthropic import Anthropic
|
| 5 |
+
from llama_index.core.llms import ChatMessage, MessageRole
|
| 6 |
+
from app.config import settings
|
| 7 |
+
from app.personality.prompts import SYSTEM_PROMPT
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class AnthropicLLM:
|
| 11 |
+
"""Manages the Anthropic LLM integration with LlamaIndex."""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.api_key = settings.anthropic_api_key
|
| 15 |
+
self.llm = None
|
| 16 |
+
self._initialize_llm()
|
| 17 |
+
|
| 18 |
+
def _initialize_llm(self):
|
| 19 |
+
"""Initialize the Anthropic LLM with LlamaIndex settings."""
|
| 20 |
+
# Check if we should use mock LLM for testing
|
| 21 |
+
if os.getenv("USE_MOCK_LLM", "").lower() == "true":
|
| 22 |
+
from app.core.mock_llm import MockAnthropicLLM
|
| 23 |
+
self.llm = MockAnthropicLLM(system_prompt=SYSTEM_PROMPT)
|
| 24 |
+
print("π§ Using Mock LLM for testing")
|
| 25 |
+
else:
|
| 26 |
+
self.llm = Anthropic(
|
| 27 |
+
api_key=self.api_key,
|
| 28 |
+
model="claude-sonnet-4-20250514", # Using Claude Sonnet 4
|
| 29 |
+
max_tokens=4096,
|
| 30 |
+
temperature=0.7, # Balanced for friendly conversation
|
| 31 |
+
system_prompt=SYSTEM_PROMPT
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Set as default LLM for LlamaIndex
|
| 35 |
+
Settings.llm = self.llm
|
| 36 |
+
|
| 37 |
+
# Configure other LlamaIndex settings
|
| 38 |
+
Settings.chunk_size = 1024
|
| 39 |
+
Settings.chunk_overlap = 20
|
| 40 |
+
|
| 41 |
+
def get_llm(self):
|
| 42 |
+
"""Get the configured Anthropic LLM instance."""
|
| 43 |
+
return self.llm
|
| 44 |
+
|
| 45 |
+
def create_chat_message(self, content: str, role: MessageRole = MessageRole.USER) -> ChatMessage:
|
| 46 |
+
"""Create a chat message with the specified role."""
|
| 47 |
+
return ChatMessage(role=role, content=content)
|
| 48 |
+
|
| 49 |
+
def get_streaming_response(self, messages: list[ChatMessage]):
|
| 50 |
+
"""Get a streaming response from the LLM."""
|
| 51 |
+
return self.llm.stream_chat(messages)
|
| 52 |
+
|
| 53 |
+
def get_response(self, messages: list[ChatMessage]):
|
| 54 |
+
"""Get a non-streaming response from the LLM."""
|
| 55 |
+
return self.llm.chat(messages)
|
| 56 |
+
|
| 57 |
+
def test_connection(self) -> bool:
|
| 58 |
+
"""Test the connection to Anthropic API."""
|
| 59 |
+
try:
|
| 60 |
+
test_message = [
|
| 61 |
+
ChatMessage(role=MessageRole.USER, content="Hello! Please respond with a brief greeting.")
|
| 62 |
+
]
|
| 63 |
+
response = self.llm.chat(test_message)
|
| 64 |
+
return bool(response.message.content)
|
| 65 |
+
except Exception as e:
|
| 66 |
+
print(f"Anthropic API connection test failed: {e}")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# Global instance
|
| 71 |
+
anthropic_llm = AnthropicLLM()
|
app/core/llm_anthropic.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Groq LLM integration for ChatCal.ai (using anthropic_llm interface)."""
|
| 2 |
+
|
| 3 |
+
from typing import Optional
|
| 4 |
+
import os
|
| 5 |
+
from llama_index.core import Settings
|
| 6 |
+
from llama_index.llms.groq import Groq
|
| 7 |
+
from llama_index.core.llms import ChatMessage, MessageRole
|
| 8 |
+
from app.config import settings
|
| 9 |
+
from app.personality.prompts import SYSTEM_PROMPT
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AnthropicLLM:
|
| 13 |
+
"""Manages the Groq LLM integration with LlamaIndex (keeping AnthropicLLM name for compatibility)."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.api_key = settings.groq_api_key # Use GROQ_API_KEY instead
|
| 17 |
+
self.llm = None
|
| 18 |
+
self._initialize_llm()
|
| 19 |
+
|
| 20 |
+
def _initialize_llm(self):
|
| 21 |
+
"""Initialize the Groq LLM with LlamaIndex settings."""
|
| 22 |
+
try:
|
| 23 |
+
# Validate API key
|
| 24 |
+
if not self.api_key or self.api_key == "your_groq_api_key_here":
|
| 25 |
+
raise ValueError("Groq API key not configured. Please set GROQ_API_KEY in .env file")
|
| 26 |
+
|
| 27 |
+
# Check if we should use mock LLM for testing
|
| 28 |
+
if os.getenv("USE_MOCK_LLM", "").lower() == "true":
|
| 29 |
+
from app.core.mock_llm import MockAnthropicLLM
|
| 30 |
+
self.llm = MockAnthropicLLM(system_prompt=SYSTEM_PROMPT)
|
| 31 |
+
print("π§ Using Mock LLM for testing")
|
| 32 |
+
else:
|
| 33 |
+
self.llm = Groq(
|
| 34 |
+
api_key=self.api_key,
|
| 35 |
+
model="llama-3.1-8b-instant", # Using Groq's Llama 3.1 8B Instant
|
| 36 |
+
max_tokens=4096,
|
| 37 |
+
temperature=0.7, # Balanced for friendly conversation
|
| 38 |
+
system_prompt=SYSTEM_PROMPT
|
| 39 |
+
)
|
| 40 |
+
print("π§ Using Groq Llama-3.1-8b-instant LLM")
|
| 41 |
+
|
| 42 |
+
# Set as default LLM for LlamaIndex
|
| 43 |
+
Settings.llm = self.llm
|
| 44 |
+
|
| 45 |
+
# Configure other LlamaIndex settings
|
| 46 |
+
Settings.chunk_size = 1024
|
| 47 |
+
Settings.chunk_overlap = 20
|
| 48 |
+
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"β Failed to initialize Groq LLM: {e}")
|
| 51 |
+
# Fallback to mock LLM if available
|
| 52 |
+
try:
|
| 53 |
+
from app.core.mock_llm import MockAnthropicLLM
|
| 54 |
+
self.llm = MockAnthropicLLM(system_prompt=SYSTEM_PROMPT)
|
| 55 |
+
print("π§ Falling back to Mock LLM")
|
| 56 |
+
except ImportError:
|
| 57 |
+
raise RuntimeError(f"Failed to initialize Groq LLM and no fallback available: {e}")
|
| 58 |
+
|
| 59 |
+
def get_llm(self):
|
| 60 |
+
"""Get the configured Groq LLM instance."""
|
| 61 |
+
if self.llm is None:
|
| 62 |
+
raise RuntimeError("Groq LLM not initialized. Please check your configuration.")
|
| 63 |
+
return self.llm
|
| 64 |
+
|
| 65 |
+
def create_chat_message(self, content: str, role: MessageRole = MessageRole.USER) -> ChatMessage:
|
| 66 |
+
"""Create a chat message with the specified role."""
|
| 67 |
+
return ChatMessage(role=role, content=content)
|
| 68 |
+
|
| 69 |
+
def get_streaming_response(self, messages: list[ChatMessage]):
|
| 70 |
+
"""Get a streaming response from the LLM."""
|
| 71 |
+
return self.llm.stream_chat(messages)
|
| 72 |
+
|
| 73 |
+
def get_response(self, messages: list[ChatMessage]):
|
| 74 |
+
"""Get a non-streaming response from the LLM."""
|
| 75 |
+
return self.llm.chat(messages)
|
| 76 |
+
|
| 77 |
+
def test_connection(self) -> bool:
|
| 78 |
+
"""Test the connection to Groq API."""
|
| 79 |
+
try:
|
| 80 |
+
# Use complete() instead of chat() for testing to avoid response format issues
|
| 81 |
+
response = self.llm.complete("Hello")
|
| 82 |
+
return bool(response and hasattr(response, 'text') and response.text)
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"Groq API connection test failed: {e}")
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Global instance
|
| 89 |
+
anthropic_llm = AnthropicLLM()
|
app/core/llm_gemini.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini LLM integration for ChatCal.ai."""
|
| 2 |
+
|
| 3 |
+
from typing import Optional
|
| 4 |
+
import os
|
| 5 |
+
from llama_index.core import Settings
|
| 6 |
+
from llama_index.llms.gemini import Gemini
|
| 7 |
+
from llama_index.core.llms import ChatMessage, MessageRole
|
| 8 |
+
from app.config import settings
|
| 9 |
+
from app.personality.prompts import SYSTEM_PROMPT
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class GeminiLLM:
|
| 13 |
+
"""Manages the Gemini LLM integration with LlamaIndex."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.api_key = settings.gemini_api_key
|
| 17 |
+
self.llm = None
|
| 18 |
+
self._initialize_llm()
|
| 19 |
+
|
| 20 |
+
def _initialize_llm(self):
|
| 21 |
+
"""Initialize the Gemini LLM with LlamaIndex settings."""
|
| 22 |
+
try:
|
| 23 |
+
# Validate API key
|
| 24 |
+
if not self.api_key or self.api_key == "your_gemini_api_key_here":
|
| 25 |
+
raise ValueError("Gemini API key not configured. Please set GEMINI_API_KEY in .env file")
|
| 26 |
+
|
| 27 |
+
# Check if we should use mock LLM for testing
|
| 28 |
+
if os.getenv("USE_MOCK_LLM", "").lower() == "true":
|
| 29 |
+
from app.core.mock_llm import MockAnthropicLLM
|
| 30 |
+
self.llm = MockAnthropicLLM(system_prompt=SYSTEM_PROMPT)
|
| 31 |
+
print("π§ Using Mock LLM for testing")
|
| 32 |
+
else:
|
| 33 |
+
self.llm = Gemini(
|
| 34 |
+
api_key=self.api_key,
|
| 35 |
+
model="models/gemini-2.0-flash-exp", # Using Gemini 2.0 Flash Experimental with proper prefix
|
| 36 |
+
max_tokens=4096,
|
| 37 |
+
temperature=0.7, # Balanced for friendly conversation
|
| 38 |
+
system_prompt=SYSTEM_PROMPT
|
| 39 |
+
)
|
| 40 |
+
print("π§ Using Gemini LLM")
|
| 41 |
+
|
| 42 |
+
# Set as default LLM for LlamaIndex
|
| 43 |
+
Settings.llm = self.llm
|
| 44 |
+
|
| 45 |
+
# Configure other LlamaIndex settings
|
| 46 |
+
Settings.chunk_size = 1024
|
| 47 |
+
Settings.chunk_overlap = 20
|
| 48 |
+
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"β Failed to initialize Gemini LLM: {e}")
|
| 51 |
+
# Fallback to mock LLM if available
|
| 52 |
+
try:
|
| 53 |
+
from app.core.mock_llm import MockAnthropicLLM
|
| 54 |
+
self.llm = MockAnthropicLLM(system_prompt=SYSTEM_PROMPT)
|
| 55 |
+
print("π§ Falling back to Mock LLM")
|
| 56 |
+
except ImportError:
|
| 57 |
+
raise RuntimeError(f"Failed to initialize Gemini LLM and no fallback available: {e}")
|
| 58 |
+
|
| 59 |
+
def get_llm(self):
|
| 60 |
+
"""Get the configured Gemini LLM instance."""
|
| 61 |
+
if self.llm is None:
|
| 62 |
+
raise RuntimeError("Gemini LLM not initialized. Please check your configuration.")
|
| 63 |
+
return self.llm
|
| 64 |
+
|
| 65 |
+
def create_chat_message(self, content: str, role: MessageRole = MessageRole.USER) -> ChatMessage:
|
| 66 |
+
"""Create a chat message with the specified role."""
|
| 67 |
+
return ChatMessage(role=role, content=content)
|
| 68 |
+
|
| 69 |
+
def get_streaming_response(self, messages: list[ChatMessage]):
|
| 70 |
+
"""Get a streaming response from the LLM."""
|
| 71 |
+
return self.llm.stream_chat(messages)
|
| 72 |
+
|
| 73 |
+
def get_response(self, messages: list[ChatMessage]):
|
| 74 |
+
"""Get a non-streaming response from the LLM."""
|
| 75 |
+
return self.llm.chat(messages)
|
| 76 |
+
|
| 77 |
+
def test_connection(self) -> bool:
|
| 78 |
+
"""Test the connection to Gemini API."""
|
| 79 |
+
try:
|
| 80 |
+
# Use complete() instead of chat() for testing to avoid the ChatResponse/CompletionResponse issue
|
| 81 |
+
response = self.llm.complete("Hello")
|
| 82 |
+
return bool(response and hasattr(response, 'text') and response.text)
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"Gemini API connection test failed: {e}")
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Global instance
|
| 89 |
+
gemini_llm = GeminiLLM()
|
app/core/mock_llm.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mock LLM for testing without API credits."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from llama_index.core.llms import ChatMessage, MessageRole, ChatResponse, CompletionResponse
|
| 5 |
+
from llama_index.core.llms.llm import LLM
|
| 6 |
+
from llama_index.core.llms.callbacks import llm_completion_callback
|
| 7 |
+
from app.personality.prompts import GREETING_TEMPLATES, BOOKING_CONFIRMATIONS, ENCOURAGEMENT_PHRASES
|
| 8 |
+
import random
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MockAnthropicLLM(LLM):
|
| 13 |
+
"""Mock Anthropic LLM for testing without API calls."""
|
| 14 |
+
|
| 15 |
+
model: str = "claude-3-5-sonnet-20241022-mock"
|
| 16 |
+
max_tokens: int = 4096
|
| 17 |
+
system_prompt: str = ""
|
| 18 |
+
|
| 19 |
+
def __init__(self, **kwargs):
|
| 20 |
+
# Extract system_prompt before calling super().__init__
|
| 21 |
+
system_prompt = kwargs.pop("system_prompt", "")
|
| 22 |
+
super().__init__(model="claude-3-5-sonnet-20241022-mock", **kwargs)
|
| 23 |
+
self.system_prompt = system_prompt
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def metadata(self):
|
| 27 |
+
return {
|
| 28 |
+
"model": self.model,
|
| 29 |
+
"max_tokens": self.max_tokens,
|
| 30 |
+
"is_mock": True
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
def _generate_mock_response(self, prompt: str) -> str:
|
| 34 |
+
"""Generate a mock response based on the prompt."""
|
| 35 |
+
prompt_lower = prompt.lower()
|
| 36 |
+
|
| 37 |
+
# Meeting scheduling patterns
|
| 38 |
+
if any(word in prompt_lower for word in ["schedule", "meeting", "appointment", "book"]):
|
| 39 |
+
if "tuesday" in prompt_lower and "2pm" in prompt_lower:
|
| 40 |
+
return "Wonderful! π I'd be absolutely delighted to help you schedule that meeting for Tuesday at 2pm. Let me check the calendar for you... *checking* Great news! Tuesday at 2pm is available! How long would you like this meeting to be? 30 minutes? An hour? I'm here to make this super easy for you!"
|
| 41 |
+
elif "time" in prompt_lower and "team" in prompt_lower:
|
| 42 |
+
return "Oh, a team meeting! How exciting! π― I'd love to help you find the perfect time for everyone. What days are you considering? I can check multiple time slots to find one that works best for your team. You're doing such a great job coordinating everyone!"
|
| 43 |
+
else:
|
| 44 |
+
return "I'd be thrilled to help you schedule that! π
Could you tell me what day and time you're thinking about? I'll make sure to find the perfect slot for you!"
|
| 45 |
+
|
| 46 |
+
# Duration questions
|
| 47 |
+
elif "30" in prompt_lower or "hour" in prompt_lower:
|
| 48 |
+
duration = "30 minutes" if "30" in prompt_lower else "one hour"
|
| 49 |
+
attendee = "John Smith" if "john" in prompt_lower else "your meeting"
|
| 50 |
+
return f"Perfect! π I've got that down as a {duration} meeting. Let me finalize this for you... *booking* Fantastic! Your meeting {f'with {attendee}' if attendee != 'your meeting' else ''} is all set for Tuesday at 2pm for {duration}. You're absolutely crushing this scheduling game! π"
|
| 51 |
+
|
| 52 |
+
# Greeting
|
| 53 |
+
elif any(word in prompt_lower for word in ["hi", "hello", "hey"]):
|
| 54 |
+
return random.choice(GREETING_TEMPLATES)
|
| 55 |
+
|
| 56 |
+
# Default helpful response
|
| 57 |
+
else:
|
| 58 |
+
return f"I'm here to help! π {random.choice(ENCOURAGEMENT_PHRASES)} What would you like to schedule today?"
|
| 59 |
+
|
| 60 |
+
@llm_completion_callback()
|
| 61 |
+
def complete(self, prompt: str, **kwargs) -> CompletionResponse:
|
| 62 |
+
"""Complete the prompt."""
|
| 63 |
+
response = self._generate_mock_response(prompt)
|
| 64 |
+
return CompletionResponse(text=response)
|
| 65 |
+
|
| 66 |
+
@llm_completion_callback()
|
| 67 |
+
def chat(self, messages: List[ChatMessage], **kwargs) -> ChatResponse:
|
| 68 |
+
"""Chat with the model."""
|
| 69 |
+
# Get the last user message
|
| 70 |
+
last_user_msg = ""
|
| 71 |
+
for msg in reversed(messages):
|
| 72 |
+
if msg.role == MessageRole.USER:
|
| 73 |
+
last_user_msg = msg.content
|
| 74 |
+
break
|
| 75 |
+
|
| 76 |
+
response = self._generate_mock_response(last_user_msg)
|
| 77 |
+
return ChatResponse(
|
| 78 |
+
message=ChatMessage(role=MessageRole.ASSISTANT, content=response)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
@llm_completion_callback()
|
| 82 |
+
def stream_chat(self, messages: List[ChatMessage], **kwargs):
|
| 83 |
+
"""Stream chat responses."""
|
| 84 |
+
response = self.chat(messages, **kwargs)
|
| 85 |
+
# Simulate streaming by yielding words
|
| 86 |
+
words = response.message.content.split()
|
| 87 |
+
for i, word in enumerate(words):
|
| 88 |
+
if i > 0:
|
| 89 |
+
yield type('obj', (object,), {'delta': ' ' + word})
|
| 90 |
+
else:
|
| 91 |
+
yield type('obj', (object,), {'delta': word})
|
| 92 |
+
|
| 93 |
+
@llm_completion_callback()
|
| 94 |
+
def stream_complete(self, prompt: str, **kwargs):
|
| 95 |
+
"""Stream completion responses."""
|
| 96 |
+
response = self.complete(prompt, **kwargs)
|
| 97 |
+
# Simulate streaming
|
| 98 |
+
words = response.text.split()
|
| 99 |
+
for i, word in enumerate(words):
|
| 100 |
+
if i > 0:
|
| 101 |
+
yield type('obj', (object,), {'delta': ' ' + word})
|
| 102 |
+
else:
|
| 103 |
+
yield type('obj', (object,), {'delta': word})
|
| 104 |
+
|
| 105 |
+
# Async methods (required by abstract base class)
|
| 106 |
+
async def achat(self, messages: List[ChatMessage], **kwargs) -> ChatResponse:
|
| 107 |
+
"""Async chat - just calls sync version."""
|
| 108 |
+
return self.chat(messages, **kwargs)
|
| 109 |
+
|
| 110 |
+
async def acomplete(self, prompt: str, **kwargs) -> CompletionResponse:
|
| 111 |
+
"""Async complete - just calls sync version."""
|
| 112 |
+
return self.complete(prompt, **kwargs)
|
| 113 |
+
|
| 114 |
+
async def astream_chat(self, messages: List[ChatMessage], **kwargs):
|
| 115 |
+
"""Async stream chat - just yields sync version."""
|
| 116 |
+
for chunk in self.stream_chat(messages, **kwargs):
|
| 117 |
+
yield chunk
|
| 118 |
+
|
| 119 |
+
async def astream_complete(self, prompt: str, **kwargs):
|
| 120 |
+
"""Async stream complete - just yields sync version."""
|
| 121 |
+
for chunk in self.stream_complete(prompt, **kwargs):
|
| 122 |
+
yield chunk
|
app/core/session.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from typing import Dict, Optional
|
| 5 |
+
import redis
|
| 6 |
+
from app.config import settings
|
| 7 |
+
from app.core.conversation import ConversationManager
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SessionManager:
|
| 11 |
+
"""Manages user sessions with Redis backend."""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.redis_client = redis.from_url(settings.redis_url, decode_responses=True)
|
| 15 |
+
self.session_timeout = timedelta(minutes=settings.session_timeout_minutes)
|
| 16 |
+
self.conversations: Dict[str, ConversationManager] = {}
|
| 17 |
+
|
| 18 |
+
def create_session(self, user_data: Optional[Dict] = None) -> str:
|
| 19 |
+
"""Create a new session and return the session ID."""
|
| 20 |
+
session_id = str(uuid.uuid4())
|
| 21 |
+
session_data = {
|
| 22 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 23 |
+
"last_activity": datetime.utcnow().isoformat(),
|
| 24 |
+
"user_data": user_data or {},
|
| 25 |
+
"conversation_started": False
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
# Store in Redis with expiration
|
| 29 |
+
self.redis_client.setex(
|
| 30 |
+
f"session:{session_id}",
|
| 31 |
+
self.session_timeout,
|
| 32 |
+
json.dumps(session_data)
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
return session_id
|
| 36 |
+
|
| 37 |
+
def get_session(self, session_id: str) -> Optional[Dict]:
|
| 38 |
+
"""Retrieve session data from Redis."""
|
| 39 |
+
data = self.redis_client.get(f"session:{session_id}")
|
| 40 |
+
if data:
|
| 41 |
+
return json.loads(data)
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
def update_session(self, session_id: str, updates: Dict) -> bool:
|
| 45 |
+
"""Update session data."""
|
| 46 |
+
session_data = self.get_session(session_id)
|
| 47 |
+
if not session_data:
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
session_data.update(updates)
|
| 51 |
+
session_data["last_activity"] = datetime.utcnow().isoformat()
|
| 52 |
+
|
| 53 |
+
# Reset expiration on activity
|
| 54 |
+
self.redis_client.setex(
|
| 55 |
+
f"session:{session_id}",
|
| 56 |
+
self.session_timeout,
|
| 57 |
+
json.dumps(session_data)
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
return True
|
| 61 |
+
|
| 62 |
+
def delete_session(self, session_id: str) -> bool:
|
| 63 |
+
"""Delete a session."""
|
| 64 |
+
# Remove from Redis
|
| 65 |
+
result = self.redis_client.delete(f"session:{session_id}")
|
| 66 |
+
|
| 67 |
+
# Remove from memory
|
| 68 |
+
if session_id in self.conversations:
|
| 69 |
+
del self.conversations[session_id]
|
| 70 |
+
|
| 71 |
+
return bool(result)
|
| 72 |
+
|
| 73 |
+
def get_or_create_conversation(self, session_id: str) -> Optional[ConversationManager]:
|
| 74 |
+
"""Get or create a conversation manager for a session."""
|
| 75 |
+
# Check if session exists
|
| 76 |
+
if not self.get_session(session_id):
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
# Return existing conversation if in memory
|
| 80 |
+
if session_id in self.conversations:
|
| 81 |
+
self.update_session(session_id, {"last_activity": datetime.utcnow().isoformat()})
|
| 82 |
+
return self.conversations[session_id]
|
| 83 |
+
|
| 84 |
+
# Create new conversation
|
| 85 |
+
conversation = ConversationManager(
|
| 86 |
+
session_id=session_id,
|
| 87 |
+
max_history=settings.max_conversation_history
|
| 88 |
+
)
|
| 89 |
+
self.conversations[session_id] = conversation
|
| 90 |
+
|
| 91 |
+
# Update session
|
| 92 |
+
self.update_session(session_id, {
|
| 93 |
+
"conversation_started": True,
|
| 94 |
+
"last_activity": datetime.utcnow().isoformat()
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
return conversation
|
| 98 |
+
|
| 99 |
+
def store_conversation_history(self, session_id: str) -> bool:
|
| 100 |
+
"""Store conversation history in Redis."""
|
| 101 |
+
if session_id not in self.conversations:
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
conversation = self.conversations[session_id]
|
| 105 |
+
# TODO: Fix export_conversation method to handle dict/object conversion
|
| 106 |
+
# For now, just store basic session info
|
| 107 |
+
history = {
|
| 108 |
+
"session_id": session_id,
|
| 109 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 110 |
+
"messages": [] # Simplified for now
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# Store conversation history separately
|
| 114 |
+
self.redis_client.setex(
|
| 115 |
+
f"conversation:{session_id}",
|
| 116 |
+
self.session_timeout * 2, # Keep conversation history longer
|
| 117 |
+
json.dumps(history)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
return True
|
| 121 |
+
|
| 122 |
+
def get_conversation_history(self, session_id: str) -> Optional[Dict]:
|
| 123 |
+
"""Retrieve conversation history from Redis."""
|
| 124 |
+
data = self.redis_client.get(f"conversation:{session_id}")
|
| 125 |
+
if data:
|
| 126 |
+
return json.loads(data)
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
def extend_session(self, session_id: str) -> bool:
|
| 130 |
+
"""Extend session timeout."""
|
| 131 |
+
session_data = self.get_session(session_id)
|
| 132 |
+
if not session_data:
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
# Reset expiration
|
| 136 |
+
self.redis_client.expire(f"session:{session_id}", self.session_timeout)
|
| 137 |
+
return True
|
| 138 |
+
|
| 139 |
+
def cleanup_expired_conversations(self):
|
| 140 |
+
"""Clean up expired conversations from memory."""
|
| 141 |
+
expired = []
|
| 142 |
+
for session_id in self.conversations:
|
| 143 |
+
if not self.get_session(session_id):
|
| 144 |
+
expired.append(session_id)
|
| 145 |
+
|
| 146 |
+
for session_id in expired:
|
| 147 |
+
del self.conversations[session_id]
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# Global session manager instance
|
| 151 |
+
session_manager = SessionManager()
|
app/core/session_factory.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Session backend factory - minimal change approach."""
|
| 2 |
+
|
| 3 |
+
from app.config import settings
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def get_session_manager():
|
| 7 |
+
"""Get the appropriate session manager based on configuration."""
|
| 8 |
+
if settings.session_backend == "jwt":
|
| 9 |
+
from app.core.jwt_session import jwt_session_manager
|
| 10 |
+
return jwt_session_manager
|
| 11 |
+
else:
|
| 12 |
+
from app.core.session import session_manager
|
| 13 |
+
return session_manager
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# Global session manager instance
|
| 17 |
+
session_manager = get_session_manager()
|
app/core/tools.py
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LlamaIndex tools for calendar operations."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional, Dict
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from llama_index.core.tools import FunctionTool
|
| 6 |
+
from app.calendar.service import CalendarService
|
| 7 |
+
from app.calendar.utils import DateTimeParser, CalendarFormatter
|
| 8 |
+
from app.personality.prompts import BOOKING_CONFIRMATIONS, ERROR_RESPONSES
|
| 9 |
+
from app.core.email_service import email_service
|
| 10 |
+
from app.config import settings
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class CalendarTools:
|
| 15 |
+
"""LlamaIndex tools for calendar operations."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, agent=None):
|
| 18 |
+
self.calendar_service = CalendarService()
|
| 19 |
+
self.datetime_parser = DateTimeParser()
|
| 20 |
+
self.formatter = CalendarFormatter()
|
| 21 |
+
self.agent = agent # Reference to the ChatCalAgent for user info
|
| 22 |
+
|
| 23 |
+
def _generate_meeting_id(self, start_time: datetime, duration_minutes: int) -> str:
|
| 24 |
+
"""Generate a human-readable meeting ID from date-time-duration."""
|
| 25 |
+
# Format: MMDD-HHMM-DURm (e.g., 0731-1400-60m for July 31 at 2:00 PM for 60 minutes)
|
| 26 |
+
date_part = start_time.strftime("%m%d") # MMDD
|
| 27 |
+
time_part = start_time.strftime("%H%M") # HHMM
|
| 28 |
+
duration_part = f"{duration_minutes}m" # Duration in minutes
|
| 29 |
+
|
| 30 |
+
return f"{date_part}-{time_part}-{duration_part}"
|
| 31 |
+
|
| 32 |
+
def check_availability(
|
| 33 |
+
self,
|
| 34 |
+
date_string: str,
|
| 35 |
+
duration_minutes: int = 60,
|
| 36 |
+
preferred_time: Optional[str] = None
|
| 37 |
+
) -> str:
|
| 38 |
+
"""
|
| 39 |
+
Check availability for a given date and return available time slots.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
date_string: Natural language date (e.g., "next Tuesday", "tomorrow")
|
| 43 |
+
duration_minutes: Meeting duration in minutes (default: 60)
|
| 44 |
+
preferred_time: Preferred time if specified (e.g., "2pm", "morning")
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
Formatted string with available time slots
|
| 48 |
+
"""
|
| 49 |
+
try:
|
| 50 |
+
# Parse the date
|
| 51 |
+
target_date = self.datetime_parser.parse_datetime(date_string)
|
| 52 |
+
if not target_date:
|
| 53 |
+
return "I'm having trouble understanding that date. Could you try rephrasing it? For example: 'next Tuesday' or 'tomorrow'"
|
| 54 |
+
|
| 55 |
+
# Get availability
|
| 56 |
+
try:
|
| 57 |
+
slots = self.calendar_service.get_availability(
|
| 58 |
+
date=target_date,
|
| 59 |
+
duration_minutes=duration_minutes
|
| 60 |
+
)
|
| 61 |
+
except Exception as e:
|
| 62 |
+
if "invalid_grant" in str(e) or "Token has been expired" in str(e) or "authentication" in str(e).lower():
|
| 63 |
+
return "I need to reconnect to your calendar. Please visit http://localhost:8000/auth/google to re-authenticate, then try again."
|
| 64 |
+
raise e
|
| 65 |
+
|
| 66 |
+
if not slots:
|
| 67 |
+
return f"Unfortunately, I don't see any available {self.formatter.format_duration(duration_minutes)} slots on {self.formatter.format_datetime(target_date, include_date=False)}. Would you like to try a different day?"
|
| 68 |
+
|
| 69 |
+
# Filter by preferred time if specified
|
| 70 |
+
if preferred_time:
|
| 71 |
+
time_tuple = self.datetime_parser.parse_time(preferred_time)
|
| 72 |
+
if time_tuple:
|
| 73 |
+
preferred_hour, preferred_minute = time_tuple
|
| 74 |
+
filtered_slots = []
|
| 75 |
+
for start, end in slots:
|
| 76 |
+
if abs(start.hour - preferred_hour) <= 2: # Within 2 hours
|
| 77 |
+
filtered_slots.append((start, end))
|
| 78 |
+
if filtered_slots:
|
| 79 |
+
slots = filtered_slots
|
| 80 |
+
|
| 81 |
+
date_str = self.formatter.format_datetime(target_date, include_date=True).split(" at ")[0]
|
| 82 |
+
availability_str = self.formatter.format_availability_list(slots)
|
| 83 |
+
|
| 84 |
+
return f"Great news! Here are the available {self.formatter.format_duration(duration_minutes)} slots for {date_str}: {availability_str}. Which time works best for you?"
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
return f"I'm having a bit of trouble checking your calendar right now. Could you try again? (Error: {str(e)})"
|
| 88 |
+
|
| 89 |
+
def create_appointment(
|
| 90 |
+
self,
|
| 91 |
+
title: str,
|
| 92 |
+
date_string: str,
|
| 93 |
+
time_string: str,
|
| 94 |
+
duration_minutes: int = 60,
|
| 95 |
+
description: Optional[str] = None,
|
| 96 |
+
attendee_emails: Optional[List[str]] = None,
|
| 97 |
+
user_name: str = None,
|
| 98 |
+
user_phone: str = None,
|
| 99 |
+
user_email: str = None,
|
| 100 |
+
create_meet_conference: bool = False
|
| 101 |
+
) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Create a new calendar appointment with email invitations.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
title: Meeting title/summary
|
| 107 |
+
date_string: Date in natural language
|
| 108 |
+
time_string: Time in natural language
|
| 109 |
+
duration_minutes: Duration in minutes
|
| 110 |
+
description: Optional meeting description
|
| 111 |
+
attendee_emails: Optional list of attendee email addresses
|
| 112 |
+
user_name: User's full name
|
| 113 |
+
user_phone: User's phone number
|
| 114 |
+
user_email: User's email address
|
| 115 |
+
create_meet_conference: Whether to create a Google Meet conference
|
| 116 |
+
|
| 117 |
+
Returns:
|
| 118 |
+
Confirmation message or error
|
| 119 |
+
"""
|
| 120 |
+
try:
|
| 121 |
+
# CRITICAL: Validate Google Meet requires email for invitations
|
| 122 |
+
if create_meet_conference and not user_email:
|
| 123 |
+
return "I need your email address to send the Google Meet invitation. Please provide your email and I'll book the meeting for you!"
|
| 124 |
+
|
| 125 |
+
# Parse date and time
|
| 126 |
+
datetime_str = f"{date_string} at {time_string}"
|
| 127 |
+
start_time = self.datetime_parser.parse_datetime(datetime_str)
|
| 128 |
+
|
| 129 |
+
if not start_time:
|
| 130 |
+
return "I'm having trouble understanding that date and time. Could you clarify? For example: 'next Tuesday at 2pm'"
|
| 131 |
+
|
| 132 |
+
end_time = start_time + timedelta(minutes=duration_minutes)
|
| 133 |
+
|
| 134 |
+
# Check for conflicts
|
| 135 |
+
try:
|
| 136 |
+
conflicts = self.calendar_service.check_conflicts(start_time, end_time)
|
| 137 |
+
if conflicts:
|
| 138 |
+
conflict_summaries = [event.get('summary', 'Untitled') for event in conflicts]
|
| 139 |
+
return f"Oops! You already have {', '.join(conflict_summaries)} scheduled at that time. How about we find a different slot?"
|
| 140 |
+
except Exception as e:
|
| 141 |
+
if "invalid_grant" in str(e) or "Token has been expired" in str(e) or "authentication" in str(e).lower():
|
| 142 |
+
return "I need to reconnect to your calendar. Please visit http://localhost:8000/auth/google to re-authenticate, then try again."
|
| 143 |
+
# Continue without conflict check if other error
|
| 144 |
+
print(f"β οΈ Warning: Could not check calendar conflicts: {e}")
|
| 145 |
+
|
| 146 |
+
# Create the event
|
| 147 |
+
try:
|
| 148 |
+
event = self.calendar_service.create_event(
|
| 149 |
+
summary=title,
|
| 150 |
+
start_time=start_time,
|
| 151 |
+
end_time=end_time,
|
| 152 |
+
description=description,
|
| 153 |
+
attendees=attendee_emails or [],
|
| 154 |
+
create_meet_conference=create_meet_conference
|
| 155 |
+
)
|
| 156 |
+
except Exception as e:
|
| 157 |
+
if "invalid_grant" in str(e) or "Token has been expired" in str(e) or "authentication" in str(e).lower():
|
| 158 |
+
return "I need to reconnect to your calendar. Please visit http://localhost:8000/auth/google to re-authenticate, then try again."
|
| 159 |
+
raise e
|
| 160 |
+
|
| 161 |
+
# Create custom meeting ID based on date-time-duration
|
| 162 |
+
google_meeting_id = event.get('id', 'unknown')
|
| 163 |
+
custom_meeting_id = self._generate_meeting_id(start_time, duration_minutes)
|
| 164 |
+
|
| 165 |
+
if self.agent:
|
| 166 |
+
self.agent.store_meeting_id(custom_meeting_id, {
|
| 167 |
+
'title': title,
|
| 168 |
+
'start_time': start_time,
|
| 169 |
+
'user_name': user_name,
|
| 170 |
+
'user_email': user_email,
|
| 171 |
+
'user_phone': user_phone,
|
| 172 |
+
'google_id': google_meeting_id,
|
| 173 |
+
'duration': duration_minutes
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
# Format confirmation
|
| 177 |
+
formatted_time = self.formatter.format_datetime(start_time)
|
| 178 |
+
duration_str = self.formatter.format_duration(duration_minutes)
|
| 179 |
+
|
| 180 |
+
# Extract Google Meet link if conference was created
|
| 181 |
+
meet_link = ""
|
| 182 |
+
if create_meet_conference and 'conferenceData' in event and 'entryPoints' in event['conferenceData']:
|
| 183 |
+
meet_entries = event['conferenceData']['entryPoints']
|
| 184 |
+
meet_link = next((entry['uri'] for entry in meet_entries if entry['entryPointType'] == 'video'), "")
|
| 185 |
+
|
| 186 |
+
# Send email invitations
|
| 187 |
+
email_sent_to_user = False
|
| 188 |
+
email_sent_to_peter = False
|
| 189 |
+
|
| 190 |
+
# Send email to Peter (always)
|
| 191 |
+
try:
|
| 192 |
+
print(f"π Attempting to send email to Peter at: {settings.my_email_address}")
|
| 193 |
+
email_sent_to_peter = email_service.send_invitation_email(
|
| 194 |
+
to_email=settings.my_email_address,
|
| 195 |
+
to_name="Peter Michael Gits",
|
| 196 |
+
title=title,
|
| 197 |
+
start_datetime=start_time,
|
| 198 |
+
end_datetime=end_time,
|
| 199 |
+
description=description or "",
|
| 200 |
+
user_phone=user_phone or "",
|
| 201 |
+
meeting_type=title,
|
| 202 |
+
meet_link=meet_link
|
| 203 |
+
)
|
| 204 |
+
print(f"π§ Peter email send result: {email_sent_to_peter}")
|
| 205 |
+
except Exception as e:
|
| 206 |
+
print(f"β Failed to send email to Peter: {e}")
|
| 207 |
+
email_sent_to_peter = False
|
| 208 |
+
|
| 209 |
+
# Send email to user if they provided email
|
| 210 |
+
if user_email:
|
| 211 |
+
try:
|
| 212 |
+
print(f"π Attempting to send email to user at: {user_email}")
|
| 213 |
+
email_sent_to_user = email_service.send_invitation_email(
|
| 214 |
+
to_email=user_email,
|
| 215 |
+
to_name=user_name or "Guest",
|
| 216 |
+
title=title,
|
| 217 |
+
start_datetime=start_time,
|
| 218 |
+
end_datetime=end_time,
|
| 219 |
+
description=description or "",
|
| 220 |
+
user_phone=user_phone or "",
|
| 221 |
+
meeting_type=title,
|
| 222 |
+
meet_link=meet_link
|
| 223 |
+
)
|
| 224 |
+
print(f"π§ User email send result: {email_sent_to_user}")
|
| 225 |
+
except Exception as e:
|
| 226 |
+
print(f"β Failed to send email to user: {e}")
|
| 227 |
+
email_sent_to_user = False
|
| 228 |
+
|
| 229 |
+
# Use random confirmation message
|
| 230 |
+
confirmation_template = random.choice(BOOKING_CONFIRMATIONS)
|
| 231 |
+
confirmation = confirmation_template.format(
|
| 232 |
+
meeting_type=title,
|
| 233 |
+
attendee=user_name or "your meeting",
|
| 234 |
+
date=formatted_time.split(" at ")[0],
|
| 235 |
+
time=formatted_time.split(" at ")[1]
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Add email status to confirmation
|
| 239 |
+
email_status = ""
|
| 240 |
+
if user_email and email_sent_to_user and email_sent_to_peter:
|
| 241 |
+
email_status = "\n\nπ§ Calendar invitations have been sent to both you and Peter via email."
|
| 242 |
+
elif user_email and email_sent_to_user and not email_sent_to_peter:
|
| 243 |
+
email_status = "\n\nπ§ Email invitation sent to you. There was an issue sending Peter's invitation, but the meeting is confirmed."
|
| 244 |
+
elif user_email and not email_sent_to_user and email_sent_to_peter:
|
| 245 |
+
email_status = "\n\nπ§ Email invitation sent to Peter. There was an issue sending your invitation, but the meeting is confirmed."
|
| 246 |
+
elif user_email and not email_sent_to_user and not email_sent_to_peter:
|
| 247 |
+
email_status = "\n\nπ§ There were issues sending email invitations to both you and Peter, but the meeting is confirmed in the calendar."
|
| 248 |
+
elif not user_email and email_sent_to_peter:
|
| 249 |
+
email_status = "\n\nπ§ Email invitation sent to Peter. If you'd like me to send you a calendar invitation via email, please provide your email address."
|
| 250 |
+
elif not user_email and not email_sent_to_peter:
|
| 251 |
+
email_status = "\n\nπ§ There was an issue sending Peter's email invitation, but the meeting is confirmed in the calendar. If you'd like me to send you a calendar invitation via email, please provide your email address."
|
| 252 |
+
else:
|
| 253 |
+
email_status = "\n\nπ§ If you'd like me to send you a calendar invitation via email, please provide your email address."
|
| 254 |
+
|
| 255 |
+
# Add Google Meet information if conference was created
|
| 256 |
+
meet_info = ""
|
| 257 |
+
if create_meet_conference:
|
| 258 |
+
# Extract Meet link from the created event
|
| 259 |
+
if 'conferenceData' in event and 'entryPoints' in event['conferenceData']:
|
| 260 |
+
meet_entries = event['conferenceData']['entryPoints']
|
| 261 |
+
meet_link = next((entry['uri'] for entry in meet_entries if entry['entryPointType'] == 'video'), None)
|
| 262 |
+
if meet_link:
|
| 263 |
+
meet_info = f"\n\nπ₯ Google Meet link: {meet_link}"
|
| 264 |
+
else:
|
| 265 |
+
meet_info = "\n\nπ₯ Google Meet conference call has been set up (link will be available in your calendar)."
|
| 266 |
+
else:
|
| 267 |
+
meet_info = "\n\nπ₯ Google Meet conference call has been set up (link will be available in your calendar)."
|
| 268 |
+
|
| 269 |
+
# Add meeting ID and Google Calendar ID with HTML formatting
|
| 270 |
+
details_html = f"""
|
| 271 |
+
<div style="background: #f5f5f5; padding: 12px; border-radius: 6px; margin: 10px 0; font-size: 14px;">
|
| 272 |
+
<strong>β±οΈ Duration:</strong> {duration_str}<br>
|
| 273 |
+
<strong>π Meeting ID:</strong> <code style="background: #e0e0e0; padding: 2px 6px; border-radius: 3px;">{custom_meeting_id}</code><br>
|
| 274 |
+
<strong>ποΈ Google Calendar ID:</strong> <code style="background: #e0e0e0; padding: 2px 6px; border-radius: 3px;">{google_meeting_id}</code><br>
|
| 275 |
+
<em style="color: #666;">(save these IDs to cancel or modify later)</em>
|
| 276 |
+
</div>"""
|
| 277 |
+
|
| 278 |
+
# Format email status with better HTML
|
| 279 |
+
email_html = ""
|
| 280 |
+
if user_email and email_sent_to_user and email_sent_to_peter:
|
| 281 |
+
email_html = '<div style="color: #4caf50; margin: 8px 0;"><strong>π§ Invites sent to both you and Peter!</strong></div>'
|
| 282 |
+
elif user_email and email_sent_to_user and not email_sent_to_peter:
|
| 283 |
+
email_html = '<div style="color: #ff9800; margin: 8px 0;"><strong>π§ Your invite sent.</strong> Issue with Peter\'s email.</div>'
|
| 284 |
+
elif user_email and not email_sent_to_user and email_sent_to_peter:
|
| 285 |
+
email_html = '<div style="color: #ff9800; margin: 8px 0;"><strong>π§ Peter\'s invite sent.</strong> Issue with your email.</div>'
|
| 286 |
+
elif user_email and not email_sent_to_user and not email_sent_to_peter:
|
| 287 |
+
email_html = '<div style="color: #f44336; margin: 8px 0;"><strong>π§ Email issues</strong> but meeting is confirmed!</div>'
|
| 288 |
+
elif not user_email and email_sent_to_peter:
|
| 289 |
+
email_html = '<div style="color: #2196f3; margin: 8px 0;"><strong>π§ Peter notified!</strong> Want a calendar invite? Just share your email.</div>'
|
| 290 |
+
elif not user_email and not email_sent_to_peter:
|
| 291 |
+
email_html = '<div style="color: #ff9800; margin: 8px 0;"><strong>π§ Email issue</strong> but meeting is confirmed!</div>'
|
| 292 |
+
|
| 293 |
+
# Format Google Meet info
|
| 294 |
+
meet_html = ""
|
| 295 |
+
if create_meet_conference:
|
| 296 |
+
if 'conferenceData' in event and 'entryPoints' in event['conferenceData']:
|
| 297 |
+
meet_entries = event['conferenceData']['entryPoints']
|
| 298 |
+
meet_link = next((entry['uri'] for entry in meet_entries if entry['entryPointType'] == 'video'), None)
|
| 299 |
+
if meet_link:
|
| 300 |
+
meet_html = f'<div style="background: #e8f5e9; padding: 10px; border-radius: 6px; margin: 8px 0;"><strong>π₯ Google Meet:</strong> <a href="{meet_link}" style="color: #1976d2; font-weight: bold;">Join here</a></div>'
|
| 301 |
+
else:
|
| 302 |
+
meet_html = '<div style="background: #e8f5e9; padding: 10px; border-radius: 6px; margin: 8px 0;"><strong>π₯ Google Meet set up</strong> (link in your calendar)</div>'
|
| 303 |
+
else:
|
| 304 |
+
meet_html = '<div style="background: #e8f5e9; padding: 10px; border-radius: 6px; margin: 8px 0;"><strong>π₯ Google Meet set up</strong> (link in your calendar)</div>'
|
| 305 |
+
|
| 306 |
+
return f"{confirmation}{details_html}{email_html}{meet_html}"
|
| 307 |
+
|
| 308 |
+
except Exception as e:
|
| 309 |
+
return f"I'm having trouble creating that appointment right now. Could you try again? (Error: {str(e)})"
|
| 310 |
+
|
| 311 |
+
def list_upcoming_events(self, days_ahead: int = 7) -> str:
|
| 312 |
+
"""
|
| 313 |
+
List upcoming events in the next specified days.
|
| 314 |
+
|
| 315 |
+
Args:
|
| 316 |
+
days_ahead: Number of days to look ahead (default: 7)
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
Formatted list of upcoming events
|
| 320 |
+
"""
|
| 321 |
+
try:
|
| 322 |
+
now = datetime.now(self.calendar_service.default_timezone)
|
| 323 |
+
end_time = now + timedelta(days=days_ahead)
|
| 324 |
+
|
| 325 |
+
events = self.calendar_service.list_events(
|
| 326 |
+
time_min=now,
|
| 327 |
+
time_max=end_time,
|
| 328 |
+
max_results=20
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
if not events:
|
| 332 |
+
return f"You have a completely clear schedule for the next {days_ahead} day{'s' if days_ahead != 1 else ''}! Perfect time to book some meetings! π
"
|
| 333 |
+
|
| 334 |
+
formatted_events = []
|
| 335 |
+
for event in events[:10]: # Limit to 10 events
|
| 336 |
+
event_summary = self.formatter.format_event_summary(event)
|
| 337 |
+
formatted_events.append(f"β’ {event_summary}")
|
| 338 |
+
|
| 339 |
+
events_list = "\n".join(formatted_events)
|
| 340 |
+
total_count = len(events)
|
| 341 |
+
|
| 342 |
+
if total_count > 10:
|
| 343 |
+
events_list += f"\nβ’ ... and {total_count - 10} more events"
|
| 344 |
+
|
| 345 |
+
period = f"next {days_ahead} day{'s' if days_ahead != 1 else ''}"
|
| 346 |
+
return f"Here's what's coming up in the {period}:\n\n{events_list}"
|
| 347 |
+
|
| 348 |
+
except Exception as e:
|
| 349 |
+
return f"I'm having trouble accessing your calendar right now. Could you try again? (Error: {str(e)})"
|
| 350 |
+
|
| 351 |
+
def reschedule_appointment(
|
| 352 |
+
self,
|
| 353 |
+
original_date_time: str,
|
| 354 |
+
new_date_string: str,
|
| 355 |
+
new_time_string: str
|
| 356 |
+
) -> str:
|
| 357 |
+
"""
|
| 358 |
+
Reschedule an existing appointment.
|
| 359 |
+
|
| 360 |
+
Args:
|
| 361 |
+
original_date_time: Original appointment date/time to find
|
| 362 |
+
new_date_string: New date in natural language
|
| 363 |
+
new_time_string: New time in natural language
|
| 364 |
+
|
| 365 |
+
Returns:
|
| 366 |
+
Confirmation message or error
|
| 367 |
+
"""
|
| 368 |
+
try:
|
| 369 |
+
# Parse original date/time to find the event
|
| 370 |
+
original_dt = self.datetime_parser.parse_datetime(original_date_time)
|
| 371 |
+
if not original_dt:
|
| 372 |
+
return "I'm having trouble finding that appointment. Could you be more specific about the original date and time?"
|
| 373 |
+
|
| 374 |
+
# Find events around that time
|
| 375 |
+
search_start = original_dt - timedelta(hours=1)
|
| 376 |
+
search_end = original_dt + timedelta(hours=1)
|
| 377 |
+
|
| 378 |
+
events = self.calendar_service.list_events(
|
| 379 |
+
time_min=search_start,
|
| 380 |
+
time_max=search_end,
|
| 381 |
+
max_results=5
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
if not events:
|
| 385 |
+
return "I couldn't find any appointments around that time. Could you check your calendar and try again?"
|
| 386 |
+
|
| 387 |
+
# For now, take the first matching event (in a real implementation,
|
| 388 |
+
# we might want to present options to the user)
|
| 389 |
+
event_to_update = events[0]
|
| 390 |
+
|
| 391 |
+
# Parse new date/time
|
| 392 |
+
new_datetime_str = f"{new_date_string} at {new_time_string}"
|
| 393 |
+
new_start_time = self.datetime_parser.parse_datetime(new_datetime_str)
|
| 394 |
+
|
| 395 |
+
if not new_start_time:
|
| 396 |
+
return "I'm having trouble understanding the new date and time. Could you clarify?"
|
| 397 |
+
|
| 398 |
+
# Calculate new end time based on original duration
|
| 399 |
+
if 'start' in event_to_update and 'end' in event_to_update:
|
| 400 |
+
original_start = datetime.fromisoformat(event_to_update['start']['dateTime'])
|
| 401 |
+
original_end = datetime.fromisoformat(event_to_update['end']['dateTime'])
|
| 402 |
+
duration = original_end - original_start
|
| 403 |
+
new_end_time = new_start_time + duration
|
| 404 |
+
else:
|
| 405 |
+
new_end_time = new_start_time + timedelta(hours=1) # Default 1 hour
|
| 406 |
+
|
| 407 |
+
# Check for conflicts at new time
|
| 408 |
+
conflicts = self.calendar_service.check_conflicts(
|
| 409 |
+
new_start_time,
|
| 410 |
+
new_end_time,
|
| 411 |
+
exclude_event_id=event_to_update.get('id')
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
if conflicts:
|
| 415 |
+
return "The new time conflicts with another appointment. Could you suggest a different time?"
|
| 416 |
+
|
| 417 |
+
# Update the event
|
| 418 |
+
updates = {
|
| 419 |
+
'start': {
|
| 420 |
+
'dateTime': new_start_time.isoformat(),
|
| 421 |
+
'timeZone': str(self.calendar_service.default_timezone),
|
| 422 |
+
},
|
| 423 |
+
'end': {
|
| 424 |
+
'dateTime': new_end_time.isoformat(),
|
| 425 |
+
'timeZone': str(self.calendar_service.default_timezone),
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
self.calendar_service.update_event(event_to_update['id'], updates)
|
| 430 |
+
|
| 431 |
+
old_time = self.formatter.format_datetime(original_dt)
|
| 432 |
+
new_time = self.formatter.format_datetime(new_start_time)
|
| 433 |
+
|
| 434 |
+
return f"Perfect! I've rescheduled your appointment from {old_time} to {new_time}. All set! π"
|
| 435 |
+
|
| 436 |
+
except Exception as e:
|
| 437 |
+
return f"I'm having trouble rescheduling that appointment. Could you try again? (Error: {str(e)})"
|
| 438 |
+
|
| 439 |
+
def cancel_meeting_by_id(self, meeting_id: str) -> str:
|
| 440 |
+
"""
|
| 441 |
+
Cancel a meeting by its ID (supports both custom and Google IDs).
|
| 442 |
+
|
| 443 |
+
Args:
|
| 444 |
+
meeting_id: The meeting ID (custom format like 0731-1400-60m or Google ID)
|
| 445 |
+
|
| 446 |
+
Returns:
|
| 447 |
+
Confirmation message or error
|
| 448 |
+
"""
|
| 449 |
+
try:
|
| 450 |
+
google_meeting_id = None
|
| 451 |
+
meeting_info = None
|
| 452 |
+
|
| 453 |
+
# Check if this is our custom meeting ID format
|
| 454 |
+
if self.agent:
|
| 455 |
+
stored_meetings = self.agent.get_stored_meetings()
|
| 456 |
+
|
| 457 |
+
# First try exact match with custom ID
|
| 458 |
+
if meeting_id in stored_meetings:
|
| 459 |
+
meeting_info = stored_meetings[meeting_id]
|
| 460 |
+
google_meeting_id = meeting_info.get('google_id')
|
| 461 |
+
else:
|
| 462 |
+
# Try partial match for custom IDs (in case user doesn't type full ID)
|
| 463 |
+
for stored_id, info in stored_meetings.items():
|
| 464 |
+
if stored_id.startswith(meeting_id):
|
| 465 |
+
meeting_info = info
|
| 466 |
+
google_meeting_id = info.get('google_id')
|
| 467 |
+
meeting_id = stored_id # Update to full custom ID
|
| 468 |
+
break
|
| 469 |
+
|
| 470 |
+
# If not found in custom IDs, treat as Google ID
|
| 471 |
+
if not google_meeting_id:
|
| 472 |
+
google_meeting_id = meeting_id
|
| 473 |
+
|
| 474 |
+
# Get meeting details before deletion
|
| 475 |
+
try:
|
| 476 |
+
event = self.calendar_service.get_event(google_meeting_id)
|
| 477 |
+
if not event:
|
| 478 |
+
return "Meeting not found. Please check the meeting ID."
|
| 479 |
+
|
| 480 |
+
meeting_title = event.get('summary', 'Meeting')
|
| 481 |
+
start_time_str = event.get('start', {}).get('dateTime', '')
|
| 482 |
+
if start_time_str:
|
| 483 |
+
start_time = datetime.fromisoformat(start_time_str.replace('Z', '+00:00'))
|
| 484 |
+
formatted_time = self.formatter.format_datetime(start_time)
|
| 485 |
+
else:
|
| 486 |
+
formatted_time = "Unknown time"
|
| 487 |
+
|
| 488 |
+
except Exception:
|
| 489 |
+
# Fall back to stored info if available
|
| 490 |
+
if meeting_info:
|
| 491 |
+
meeting_title = meeting_info.get('title', 'Meeting')
|
| 492 |
+
start_time = meeting_info.get('start_time')
|
| 493 |
+
if start_time:
|
| 494 |
+
formatted_time = self.formatter.format_datetime(start_time)
|
| 495 |
+
else:
|
| 496 |
+
formatted_time = "Unknown time"
|
| 497 |
+
else:
|
| 498 |
+
meeting_title = "Meeting"
|
| 499 |
+
formatted_time = "Unknown time"
|
| 500 |
+
|
| 501 |
+
# Delete the event using Google ID
|
| 502 |
+
self.calendar_service.delete_event(google_meeting_id)
|
| 503 |
+
|
| 504 |
+
# Remove from stored meetings using custom ID
|
| 505 |
+
if self.agent and meeting_id in self.agent.get_stored_meetings():
|
| 506 |
+
self.agent.remove_stored_meeting(meeting_id)
|
| 507 |
+
|
| 508 |
+
return f"""<div style="background: #ffebee; padding: 15px; border-radius: 8px; border-left: 4px solid #f44336; margin: 10px 0;">
|
| 509 |
+
<strong>β
Cancelled!</strong><br>
|
| 510 |
+
<strong>{meeting_title}</strong><br>
|
| 511 |
+
<em>was scheduled for {formatted_time}</em>
|
| 512 |
+
</div>"""
|
| 513 |
+
|
| 514 |
+
except Exception as e:
|
| 515 |
+
return f"I'm having trouble cancelling that meeting. Could you try again? (Error: {str(e)})"
|
| 516 |
+
|
| 517 |
+
def find_user_meetings(self, user_name: str, days_ahead: int = 30) -> str:
|
| 518 |
+
"""
|
| 519 |
+
Find all meetings for a specific user within the next specified days.
|
| 520 |
+
|
| 521 |
+
Args:
|
| 522 |
+
user_name: Name of the person who booked meetings
|
| 523 |
+
days_ahead: Number of days to look ahead (default: 30)
|
| 524 |
+
|
| 525 |
+
Returns:
|
| 526 |
+
List of meetings for the user or message if none found
|
| 527 |
+
"""
|
| 528 |
+
try:
|
| 529 |
+
# Search for meetings in the next specified days
|
| 530 |
+
now = datetime.now(self.calendar_service.default_timezone)
|
| 531 |
+
search_end = now + timedelta(days=days_ahead)
|
| 532 |
+
|
| 533 |
+
events = self.calendar_service.list_events(
|
| 534 |
+
time_min=now,
|
| 535 |
+
time_max=search_end,
|
| 536 |
+
max_results=50
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
# Find meetings that match the user name
|
| 540 |
+
matching_events = []
|
| 541 |
+
for event in events:
|
| 542 |
+
event_summary = event.get('summary', '').lower()
|
| 543 |
+
event_description = event.get('description', '').lower()
|
| 544 |
+
|
| 545 |
+
# Check if user name appears in title or description
|
| 546 |
+
if (user_name.lower() in event_summary or
|
| 547 |
+
user_name.lower() in event_description or
|
| 548 |
+
f"meeting with {user_name.lower()}" in event_summary):
|
| 549 |
+
matching_events.append(event)
|
| 550 |
+
|
| 551 |
+
if not matching_events:
|
| 552 |
+
return f"I couldn't find any upcoming meetings for {user_name} in the next {days_ahead} days."
|
| 553 |
+
|
| 554 |
+
# Format the meetings for display
|
| 555 |
+
meetings_list = []
|
| 556 |
+
for i, event in enumerate(matching_events[:5], 1): # Limit to 5 meetings
|
| 557 |
+
event_time = datetime.fromisoformat(event['start']['dateTime'].replace('Z', '+00:00'))
|
| 558 |
+
formatted_time = self.formatter.format_datetime(event_time)
|
| 559 |
+
event_title = event.get('summary', 'Meeting')
|
| 560 |
+
|
| 561 |
+
# Find custom meeting ID if available
|
| 562 |
+
custom_id = "N/A"
|
| 563 |
+
if self.agent:
|
| 564 |
+
stored_meetings = self.agent.get_stored_meetings()
|
| 565 |
+
for stored_id, info in stored_meetings.items():
|
| 566 |
+
if info.get('google_id') == event.get('id'):
|
| 567 |
+
custom_id = stored_id
|
| 568 |
+
break
|
| 569 |
+
|
| 570 |
+
meetings_list.append(f"**{i}. {event_title}**<br>{formatted_time}<br><em>ID: {custom_id}</em>")
|
| 571 |
+
|
| 572 |
+
meetings_text = "<br><br>".join(meetings_list)
|
| 573 |
+
return f"""<div style="background: #fff3e0; padding: 15px; border-radius: 8px; border-left: 4px solid #ff9800; margin: 10px 0;">
|
| 574 |
+
<strong>π Found {len(matching_events)} meeting{'s' if len(matching_events) != 1 else ''} for {user_name}:</strong><br><br>
|
| 575 |
+
{meetings_text}<br><br>
|
| 576 |
+
Which one would you like to cancel? You can say "cancel #1" or "cancel the first one".
|
| 577 |
+
</div>"""
|
| 578 |
+
|
| 579 |
+
except Exception as e:
|
| 580 |
+
return f"I'm having trouble finding meetings for {user_name}. Error: {str(e)}"
|
| 581 |
+
|
| 582 |
+
def cancel_meeting_by_details(self, user_name: str, date_string: str, time_string: str = None) -> str:
|
| 583 |
+
"""
|
| 584 |
+
Cancel a meeting by user name and date/time.
|
| 585 |
+
|
| 586 |
+
Args:
|
| 587 |
+
user_name: Name of the person who booked the meeting
|
| 588 |
+
date_string: Date in natural language
|
| 589 |
+
time_string: Optional time in natural language
|
| 590 |
+
|
| 591 |
+
Returns:
|
| 592 |
+
Confirmation message or error
|
| 593 |
+
"""
|
| 594 |
+
try:
|
| 595 |
+
# Parse the date
|
| 596 |
+
if time_string:
|
| 597 |
+
datetime_str = f"{date_string} at {time_string}"
|
| 598 |
+
else:
|
| 599 |
+
datetime_str = date_string
|
| 600 |
+
|
| 601 |
+
target_datetime = self.datetime_parser.parse_datetime(datetime_str)
|
| 602 |
+
if not target_datetime:
|
| 603 |
+
return "I'm having trouble understanding that date/time. Could you be more specific?"
|
| 604 |
+
|
| 605 |
+
# Search for meetings around that time
|
| 606 |
+
search_start = target_datetime - timedelta(hours=12) # Search wider range
|
| 607 |
+
search_end = target_datetime + timedelta(hours=12)
|
| 608 |
+
|
| 609 |
+
events = self.calendar_service.list_events(
|
| 610 |
+
time_min=search_start,
|
| 611 |
+
time_max=search_end,
|
| 612 |
+
max_results=20
|
| 613 |
+
)
|
| 614 |
+
|
| 615 |
+
# Find meetings that match the user name
|
| 616 |
+
matching_events = []
|
| 617 |
+
for event in events:
|
| 618 |
+
event_summary = event.get('summary', '').lower()
|
| 619 |
+
event_description = event.get('description', '').lower()
|
| 620 |
+
|
| 621 |
+
# Check if user name appears in title or description
|
| 622 |
+
if (user_name.lower() in event_summary or
|
| 623 |
+
user_name.lower() in event_description or
|
| 624 |
+
f"meeting with {user_name.lower()}" in event_summary):
|
| 625 |
+
matching_events.append(event)
|
| 626 |
+
|
| 627 |
+
if not matching_events:
|
| 628 |
+
return f"I couldn't find any meetings for {user_name} around {datetime_str}. Please check the details."
|
| 629 |
+
|
| 630 |
+
if len(matching_events) == 1:
|
| 631 |
+
# Single match - cancel it directly
|
| 632 |
+
event = matching_events[0]
|
| 633 |
+
google_meeting_id = event.get('id')
|
| 634 |
+
|
| 635 |
+
# Get meeting details for confirmation
|
| 636 |
+
meeting_title = event.get('summary', 'Meeting')
|
| 637 |
+
start_time_str = event.get('start', {}).get('dateTime', '')
|
| 638 |
+
if start_time_str:
|
| 639 |
+
start_time = datetime.fromisoformat(start_time_str.replace('Z', '+00:00'))
|
| 640 |
+
formatted_time = self.formatter.format_datetime(start_time)
|
| 641 |
+
else:
|
| 642 |
+
formatted_time = "Unknown time"
|
| 643 |
+
|
| 644 |
+
# Cancel the meeting
|
| 645 |
+
try:
|
| 646 |
+
self.calendar_service.delete_event(google_meeting_id)
|
| 647 |
+
|
| 648 |
+
# Remove from stored meetings if exists
|
| 649 |
+
if self.agent:
|
| 650 |
+
# Find and remove custom meeting ID
|
| 651 |
+
stored_meetings = self.agent.get_stored_meetings()
|
| 652 |
+
for custom_id, info in stored_meetings.items():
|
| 653 |
+
if info.get('google_id') == google_meeting_id:
|
| 654 |
+
self.agent.remove_stored_meeting(custom_id)
|
| 655 |
+
break
|
| 656 |
+
|
| 657 |
+
# Automatically send cancellation email if user has email (no asking)
|
| 658 |
+
email_sent = False
|
| 659 |
+
if self.agent and self.agent.user_info.get('email'):
|
| 660 |
+
user_email = self.agent.user_info.get('email')
|
| 661 |
+
user_name_for_email = self.agent.user_info.get('name', user_name)
|
| 662 |
+
|
| 663 |
+
try:
|
| 664 |
+
from app.core.email_service import email_service
|
| 665 |
+
email_sent = email_service.send_cancellation_email(
|
| 666 |
+
to_email=user_email,
|
| 667 |
+
to_name=user_name_for_email,
|
| 668 |
+
meeting_title=meeting_title,
|
| 669 |
+
original_datetime=start_time
|
| 670 |
+
)
|
| 671 |
+
except Exception as e:
|
| 672 |
+
print(f"Failed to send cancellation email: {e}")
|
| 673 |
+
email_sent = False
|
| 674 |
+
|
| 675 |
+
# Only show confirmation after email is processed
|
| 676 |
+
if email_sent:
|
| 677 |
+
return f"""<div style="background: #ffebee; padding: 15px; border-radius: 8px; border-left: 4px solid #f44336; margin: 10px 0;">
|
| 678 |
+
<strong>β
Cancelled & Confirmed!</strong><br>
|
| 679 |
+
<strong>{meeting_title}</strong><br>
|
| 680 |
+
<em>was scheduled for {formatted_time}</em><br>
|
| 681 |
+
<span style="color: #4caf50;">π§ Cancellation details sent to your email</span>
|
| 682 |
+
</div>"""
|
| 683 |
+
else:
|
| 684 |
+
return f"""<div style="background: #ffebee; padding: 15px; border-radius: 8px; border-left: 4px solid #f44336; margin: 10px 0;">
|
| 685 |
+
<strong>β
Cancelled!</strong><br>
|
| 686 |
+
<strong>{meeting_title}</strong><br>
|
| 687 |
+
<em>was scheduled for {formatted_time}</em>
|
| 688 |
+
</div>"""
|
| 689 |
+
|
| 690 |
+
except Exception as e:
|
| 691 |
+
return f"I had trouble cancelling that meeting. Error: {str(e)}"
|
| 692 |
+
|
| 693 |
+
elif len(matching_events) > 1:
|
| 694 |
+
# Multiple matches - check if any match closely on time
|
| 695 |
+
if time_string:
|
| 696 |
+
# Try to narrow down by time matching
|
| 697 |
+
target_time = self.datetime_parser.parse_time(time_string)
|
| 698 |
+
if target_time:
|
| 699 |
+
target_hour, target_minute = target_time
|
| 700 |
+
close_matches = []
|
| 701 |
+
|
| 702 |
+
for event in matching_events:
|
| 703 |
+
event_start = datetime.fromisoformat(event['start']['dateTime'].replace('Z', '+00:00'))
|
| 704 |
+
# Match within 1 hour of target time
|
| 705 |
+
if abs(event_start.hour - target_hour) <= 1:
|
| 706 |
+
close_matches.append(event)
|
| 707 |
+
|
| 708 |
+
if len(close_matches) == 1:
|
| 709 |
+
# Found one close match - cancel it
|
| 710 |
+
return self.cancel_meeting_by_details(user_name, date_string, time_string)
|
| 711 |
+
|
| 712 |
+
# Still multiple matches - only show meeting IDs if needed
|
| 713 |
+
options = []
|
| 714 |
+
for i, event in enumerate(matching_events[:3], 1): # Limit to 3
|
| 715 |
+
event_time = datetime.fromisoformat(event['start']['dateTime'].replace('Z', '+00:00'))
|
| 716 |
+
formatted_time = self.formatter.format_datetime(event_time)
|
| 717 |
+
event_title = event.get('summary', 'Meeting')
|
| 718 |
+
|
| 719 |
+
# Find custom meeting ID if available
|
| 720 |
+
custom_id = "N/A"
|
| 721 |
+
if self.agent:
|
| 722 |
+
stored_meetings = self.agent.get_stored_meetings()
|
| 723 |
+
for stored_id, info in stored_meetings.items():
|
| 724 |
+
if info.get('google_id') == event.get('id'):
|
| 725 |
+
custom_id = stored_id
|
| 726 |
+
break
|
| 727 |
+
|
| 728 |
+
options.append(f"β’ <strong>{event_title}</strong> - {formatted_time}")
|
| 729 |
+
|
| 730 |
+
options_text = "<br>".join(options)
|
| 731 |
+
return f"""<div style="background: #fff3e0; padding: 15px; border-radius: 8px; border-left: 4px solid #ff9800; margin: 10px 0;">
|
| 732 |
+
<strong>π€ Found multiple meetings for {user_name}:</strong><br><br>
|
| 733 |
+
{options_text}<br><br>
|
| 734 |
+
Can you be more specific about the time, or provide the meeting ID?
|
| 735 |
+
</div>"""
|
| 736 |
+
|
| 737 |
+
except Exception as e:
|
| 738 |
+
return f"I'm having trouble finding that meeting. Could you try again? (Error: {str(e)})"
|
| 739 |
+
|
| 740 |
+
def get_meeting_details(
|
| 741 |
+
self,
|
| 742 |
+
meeting_id: Optional[str] = None,
|
| 743 |
+
user_name: Optional[str] = None,
|
| 744 |
+
date_string: Optional[str] = None
|
| 745 |
+
) -> str:
|
| 746 |
+
"""
|
| 747 |
+
Get details about a specific meeting including Google Meet link, Meeting ID, and Google Calendar ID.
|
| 748 |
+
|
| 749 |
+
Args:
|
| 750 |
+
meeting_id: Custom meeting ID (e.g., "0918-0754-60m")
|
| 751 |
+
user_name: User name to find their meetings
|
| 752 |
+
date_string: Date to search for meetings
|
| 753 |
+
|
| 754 |
+
Returns:
|
| 755 |
+
Meeting details or error message
|
| 756 |
+
"""
|
| 757 |
+
try:
|
| 758 |
+
if not self.agent:
|
| 759 |
+
return "Internal error: No agent reference available."
|
| 760 |
+
|
| 761 |
+
# If meeting_id is provided, look up the stored meeting
|
| 762 |
+
if meeting_id:
|
| 763 |
+
stored_meetings = self.agent.get_stored_meetings()
|
| 764 |
+
if meeting_id in stored_meetings:
|
| 765 |
+
meeting_info = stored_meetings[meeting_id]
|
| 766 |
+
|
| 767 |
+
# Get additional details from Google Calendar
|
| 768 |
+
google_id = meeting_info.get('google_id')
|
| 769 |
+
event = None
|
| 770 |
+
if google_id:
|
| 771 |
+
try:
|
| 772 |
+
event = self.calendar_service.get_event(google_id)
|
| 773 |
+
except Exception as e:
|
| 774 |
+
print(f"Could not fetch Google Calendar event: {e}")
|
| 775 |
+
|
| 776 |
+
# Extract Google Meet link if available
|
| 777 |
+
meet_link = "Not available"
|
| 778 |
+
if event and 'conferenceData' in event and 'entryPoints' in event['conferenceData']:
|
| 779 |
+
meet_entries = event['conferenceData']['entryPoints']
|
| 780 |
+
meet_link = next((entry['uri'] for entry in meet_entries if entry['entryPointType'] == 'video'), "Not available")
|
| 781 |
+
|
| 782 |
+
# Format response
|
| 783 |
+
start_time = meeting_info.get('start_time', 'Unknown')
|
| 784 |
+
duration = meeting_info.get('duration', 60)
|
| 785 |
+
|
| 786 |
+
return f"""<div style="background: #e3f2fd; padding: 15px; border-radius: 8px; border-left: 4px solid #2196f3; margin: 10px 0;">
|
| 787 |
+
<strong>π Meeting Details:</strong><br><br>
|
| 788 |
+
<strong>π
Title:</strong> {meeting_info.get('title', 'Meeting')}<br>
|
| 789 |
+
<strong>β° Time:</strong> {start_time}<br>
|
| 790 |
+
<strong>β±οΈ Duration:</strong> {duration} minutes<br>
|
| 791 |
+
<strong>π€ Attendee:</strong> {meeting_info.get('user_name', 'Unknown')}<br>
|
| 792 |
+
<strong>π§ Email:</strong> {meeting_info.get('user_email', 'None provided')}<br><br>
|
| 793 |
+
<strong>π Meeting ID:</strong> <code style="background: #e0e0e0; padding: 2px 6px; border-radius: 3px;">{meeting_id}</code><br>
|
| 794 |
+
<strong>ποΈ Google Calendar ID:</strong> <code style="background: #e0e0e0; padding: 2px 6px; border-radius: 3px;">{google_id or 'Not available'}</code><br>
|
| 795 |
+
<strong>π₯ Google Meet Link:</strong> {f'<a href="{meet_link}" style="color: #1976d2;">Join Meeting</a>' if meet_link != "Not available" else "Not available"}
|
| 796 |
+
</div>"""
|
| 797 |
+
else:
|
| 798 |
+
return f"I couldn't find a meeting with ID '{meeting_id}'. Please check the ID and try again."
|
| 799 |
+
|
| 800 |
+
# If user_name is provided, find their recent meetings
|
| 801 |
+
elif user_name:
|
| 802 |
+
stored_meetings = self.agent.get_stored_meetings()
|
| 803 |
+
user_meetings = {mid: info for mid, info in stored_meetings.items()
|
| 804 |
+
if info.get('user_name', '').lower() == user_name.lower()}
|
| 805 |
+
|
| 806 |
+
if not user_meetings:
|
| 807 |
+
return f"I couldn't find any meetings for {user_name}. Please check the name and try again."
|
| 808 |
+
|
| 809 |
+
# Show the most recent meeting
|
| 810 |
+
latest_meeting_id = max(user_meetings.keys())
|
| 811 |
+
return self.get_meeting_details(meeting_id=latest_meeting_id)
|
| 812 |
+
|
| 813 |
+
else:
|
| 814 |
+
return "Please provide either a meeting ID or user name to look up meeting details."
|
| 815 |
+
|
| 816 |
+
except Exception as e:
|
| 817 |
+
return f"I'm having trouble retrieving meeting details. Error: {str(e)}"
|
| 818 |
+
|
| 819 |
+
def create_appointment_with_user_info(
|
| 820 |
+
self,
|
| 821 |
+
title: str,
|
| 822 |
+
date_string: str,
|
| 823 |
+
time_string: str,
|
| 824 |
+
duration_minutes: int = 60,
|
| 825 |
+
description: Optional[str] = None,
|
| 826 |
+
**kwargs
|
| 827 |
+
) -> str:
|
| 828 |
+
"""Create appointment using stored user information from agent."""
|
| 829 |
+
|
| 830 |
+
# FORBIDDEN PARAMETER VALIDATION - Detect and reject invalid parameters
|
| 831 |
+
# Note: organizer_email is NOT forbidden as it should be automatically set to Peter's email
|
| 832 |
+
forbidden_params = [
|
| 833 |
+
'email', 'phone', 'user_name', 'attendee_email',
|
| 834 |
+
'attendee_name', 'organizer_phone', 'user_email', 'user_phone'
|
| 835 |
+
]
|
| 836 |
+
if kwargs:
|
| 837 |
+
invalid_params = [param for param in kwargs.keys() if param in forbidden_params]
|
| 838 |
+
if invalid_params:
|
| 839 |
+
return f"ERROR: Invalid parameters detected: {', '.join(invalid_params)}. This function ONLY accepts: 'title', 'date_string', 'time_string', 'duration_minutes', 'description'. Please use only these 5 parameters."
|
| 840 |
+
|
| 841 |
+
if not self.agent:
|
| 842 |
+
return "Internal error: No agent reference available."
|
| 843 |
+
|
| 844 |
+
user_info = self.agent.get_user_info()
|
| 845 |
+
|
| 846 |
+
# COMPREHENSIVE VALIDATION - Check ALL required parameters before booking
|
| 847 |
+
missing_items = []
|
| 848 |
+
|
| 849 |
+
# 1. Validate user information
|
| 850 |
+
if not self.agent.has_complete_user_info():
|
| 851 |
+
missing = self.agent.get_missing_user_info()
|
| 852 |
+
if "contact (email OR phone)" in missing:
|
| 853 |
+
missing_items.append("your contact information (email or phone number)")
|
| 854 |
+
if "name" in missing:
|
| 855 |
+
missing_items.append("your name")
|
| 856 |
+
|
| 857 |
+
# 2. Validate date and time parameters
|
| 858 |
+
if not date_string or not date_string.strip():
|
| 859 |
+
missing_items.append("the date for the appointment")
|
| 860 |
+
|
| 861 |
+
if not time_string or not time_string.strip():
|
| 862 |
+
missing_items.append("the time for the appointment")
|
| 863 |
+
|
| 864 |
+
# 3. Validate that date/time can be parsed (basic check)
|
| 865 |
+
if date_string and date_string.strip() and time_string and time_string.strip():
|
| 866 |
+
try:
|
| 867 |
+
datetime_str = f"{date_string.strip()} at {time_string.strip()}"
|
| 868 |
+
test_datetime = self.datetime_parser.parse_datetime(datetime_str)
|
| 869 |
+
if not test_datetime:
|
| 870 |
+
missing_items.append("a valid date and time (I'm having trouble understanding the date/time format)")
|
| 871 |
+
except Exception:
|
| 872 |
+
missing_items.append("a valid date and time (please clarify the date/time)")
|
| 873 |
+
|
| 874 |
+
# If ANY required information is missing, request it instead of booking
|
| 875 |
+
if missing_items:
|
| 876 |
+
if len(missing_items) == 1:
|
| 877 |
+
if "contact information" in missing_items[0]:
|
| 878 |
+
return f"To book your appointment, I still need {missing_items[0]}. Please provide your email address or phone number. If using voice input, please spell out your email address using the military alphabet (Alpha, Bravo, Charlie, etc.) to ensure accuracy."
|
| 879 |
+
else:
|
| 880 |
+
return f"To book your appointment, I still need {missing_items[0]}. Can you provide this information?"
|
| 881 |
+
elif len(missing_items) == 2:
|
| 882 |
+
return f"To book your appointment, I still need {missing_items[0]} and {missing_items[1]}. Can you provide this information?"
|
| 883 |
+
else:
|
| 884 |
+
missing_list = ", ".join(missing_items[:-1]) + f", and {missing_items[-1]}"
|
| 885 |
+
return f"To book your appointment, I still need {missing_list}. Can you provide this information?"
|
| 886 |
+
|
| 887 |
+
# Use default title if no title provided or if title is vague/generic
|
| 888 |
+
if not title or not title.strip() or title.strip().lower() in ['meeting', 'appointment', 'call', 'session', 'consultation']:
|
| 889 |
+
title = settings.default_appointment_title
|
| 890 |
+
|
| 891 |
+
# Detect if user requests Google Meet conference
|
| 892 |
+
create_meet = False
|
| 893 |
+
meet_keywords = ['google meet', 'meet', 'video call', 'video conference', 'online meeting', 'virtual meeting', 'conference call', 'zoom', 'online', 'remote']
|
| 894 |
+
|
| 895 |
+
# Check title and description for meet keywords
|
| 896 |
+
text_to_check = f"{title} {description or ''}".lower()
|
| 897 |
+
create_meet = any(keyword in text_to_check for keyword in meet_keywords)
|
| 898 |
+
|
| 899 |
+
# Also check recent conversation history for meet requests
|
| 900 |
+
if not create_meet and self.agent:
|
| 901 |
+
try:
|
| 902 |
+
recent_messages = self.agent.get_conversation_history()
|
| 903 |
+
if recent_messages:
|
| 904 |
+
# Check last few messages for meet-related keywords
|
| 905 |
+
recent_text = ' '.join([msg.get('content', '') for msg in recent_messages[-3:] if msg.get('content')])
|
| 906 |
+
create_meet = any(keyword in recent_text.lower() for keyword in meet_keywords)
|
| 907 |
+
except:
|
| 908 |
+
pass # If history access fails, continue without it
|
| 909 |
+
|
| 910 |
+
# CRITICAL: Validate Google Meet requires BOTH email AND phone
|
| 911 |
+
if create_meet:
|
| 912 |
+
missing_for_meet = []
|
| 913 |
+
if not user_info.get("email"):
|
| 914 |
+
missing_for_meet.append("email address (for Google Meet invitation)")
|
| 915 |
+
if not user_info.get("phone"):
|
| 916 |
+
missing_for_meet.append("phone number (in case we need to call you)")
|
| 917 |
+
|
| 918 |
+
if missing_for_meet:
|
| 919 |
+
if len(missing_for_meet) == 1:
|
| 920 |
+
return f"For Google Meet, I need your {missing_for_meet[0]}. Please provide it and I'll book the meeting!"
|
| 921 |
+
else:
|
| 922 |
+
return f"For Google Meet, I need your {' and '.join(missing_for_meet)}. Please provide both and I'll book the meeting!"
|
| 923 |
+
|
| 924 |
+
# Create appointment with user information
|
| 925 |
+
return self.create_appointment(
|
| 926 |
+
title=title,
|
| 927 |
+
date_string=date_string,
|
| 928 |
+
time_string=time_string,
|
| 929 |
+
duration_minutes=duration_minutes,
|
| 930 |
+
description=description,
|
| 931 |
+
attendee_emails=[user_info.get('email')] if user_info.get('email') else None,
|
| 932 |
+
user_name=user_info.get('name'),
|
| 933 |
+
user_phone=user_info.get('phone'),
|
| 934 |
+
user_email=user_info.get('email'),
|
| 935 |
+
create_meet_conference=create_meet
|
| 936 |
+
)
|
| 937 |
+
|
| 938 |
+
def get_tools(self) -> List[FunctionTool]:
|
| 939 |
+
"""Get list of LlamaIndex FunctionTool objects."""
|
| 940 |
+
return [
|
| 941 |
+
FunctionTool.from_defaults(
|
| 942 |
+
fn=self.check_availability,
|
| 943 |
+
name="check_availability",
|
| 944 |
+
description="**AVAILABILITY CHECKER** - Check if specific time slot is free for booking. MANDATORY: Use this tool BEFORE booking to verify time is available. PARAMETERS: date_string (required - e.g. 'today', 'tomorrow'), duration_minutes (optional, default 60), preferred_time (optional - e.g. '4pm'). Use when ready to book meeting and need to check if time is free."
|
| 945 |
+
),
|
| 946 |
+
FunctionTool.from_defaults(
|
| 947 |
+
fn=self.create_appointment_with_user_info,
|
| 948 |
+
name="create_appointment",
|
| 949 |
+
description="**PRIMARY BOOKING TOOL** - Create appointment after checking availability. WORKFLOW: 1) Check availability first with check_availability tool, 2) If time is free, call this tool to book. MAIN PARAMETERS: 'title', 'date_string', 'time_string', 'duration_minutes', 'description'. For Google Meet: requires BOTH email AND phone. User contact info is automatically retrieved from agent context. DO NOT call if information is missing or if you haven't checked availability first."
|
| 950 |
+
),
|
| 951 |
+
FunctionTool.from_defaults(
|
| 952 |
+
fn=self.list_upcoming_events,
|
| 953 |
+
name="list_upcoming_events",
|
| 954 |
+
description="List general upcoming calendar events. ONLY use when user asks 'what's on my calendar' or 'show my events'. DO NOT use when checking if specific time is available for booking - use check_availability instead. PARAMETERS: days_ahead (optional, default 7). DO NOT use user_name parameter."
|
| 955 |
+
),
|
| 956 |
+
FunctionTool.from_defaults(
|
| 957 |
+
fn=self.reschedule_appointment,
|
| 958 |
+
name="reschedule_appointment",
|
| 959 |
+
description="Reschedule an existing appointment. PARAMETERS: original_date_time (required), new_date_string (required), new_time_string (required)."
|
| 960 |
+
),
|
| 961 |
+
FunctionTool.from_defaults(
|
| 962 |
+
fn=self.find_user_meetings,
|
| 963 |
+
name="find_user_meetings",
|
| 964 |
+
description="Find all meetings for a specific user. PARAMETERS: user_name (required), days_ahead (optional, default 30)."
|
| 965 |
+
),
|
| 966 |
+
FunctionTool.from_defaults(
|
| 967 |
+
fn=self.get_meeting_details,
|
| 968 |
+
name="get_meeting_details",
|
| 969 |
+
description="Get details about a specific meeting including Google Meet link, Meeting ID, and Google Calendar ID. Use this when user asks for meeting information, details, IDs, or links. PARAMETERS: meeting_id (optional), user_name (optional), date_string (optional)."
|
| 970 |
+
),
|
| 971 |
+
FunctionTool.from_defaults(
|
| 972 |
+
fn=self.cancel_meeting_by_id,
|
| 973 |
+
name="cancel_meeting_by_id",
|
| 974 |
+
description="CANCELLATION TOOL - Cancel a meeting using its meeting ID. EXTREMELY CRITICAL: ONLY call this when user message contains words 'cancel', 'delete', 'remove', 'unbook' AND 'meeting'/'appointment'. NEVER call for: asking about IDs, meeting details, confirmation, information, 'what is', 'show me', or any questions. If user wants meeting info, use get_meeting_details instead. PARAMETERS: meeting_id (required)."
|
| 975 |
+
),
|
| 976 |
+
FunctionTool.from_defaults(
|
| 977 |
+
fn=self.cancel_meeting_by_details,
|
| 978 |
+
name="cancel_meeting_by_details",
|
| 979 |
+
description="CANCELLATION TOOL - Cancel a meeting by user name and date/time. EXTREMELY CRITICAL: ONLY call this when user message contains words 'cancel', 'delete', 'remove', 'unbook' AND 'meeting'/'appointment'. NEVER call for: asking about IDs, meeting details, confirmation, information, 'what is', 'show me', or any questions. If user wants meeting info, use get_meeting_details instead. PARAMETERS: user_name (required), date_string (required), time_string (optional)."
|
| 980 |
+
)
|
| 981 |
+
]
|
app/core/validators.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Input validation utilities for ChatCal.ai."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Optional, List, Tuple
|
| 6 |
+
from app.core.exceptions import ValidationError
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class InputValidator:
|
| 10 |
+
"""Validates user inputs for the chat application."""
|
| 11 |
+
|
| 12 |
+
# Email validation regex
|
| 13 |
+
EMAIL_PATTERN = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
| 14 |
+
|
| 15 |
+
# Time patterns for natural language
|
| 16 |
+
TIME_PATTERNS = [
|
| 17 |
+
r'\d{1,2}:\d{2}\s*(am|pm|AM|PM)?',
|
| 18 |
+
r'\d{1,2}\s*(am|pm|AM|PM)',
|
| 19 |
+
r'(morning|afternoon|evening|noon)',
|
| 20 |
+
r'(early|late)\s+(morning|afternoon|evening)'
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
@staticmethod
|
| 24 |
+
def validate_message(message: str) -> str:
|
| 25 |
+
"""Validate chat message input."""
|
| 26 |
+
if not message:
|
| 27 |
+
raise ValidationError("Message cannot be empty")
|
| 28 |
+
|
| 29 |
+
message = message.strip()
|
| 30 |
+
if not message:
|
| 31 |
+
raise ValidationError("Message cannot be empty")
|
| 32 |
+
|
| 33 |
+
if len(message) > 1000:
|
| 34 |
+
raise ValidationError("Message too long (max 1000 characters)")
|
| 35 |
+
|
| 36 |
+
# Check for potential injection attempts
|
| 37 |
+
suspicious_patterns = [
|
| 38 |
+
r'<script.*?>',
|
| 39 |
+
r'javascript:',
|
| 40 |
+
r'on\w+\s*=',
|
| 41 |
+
r'eval\s*\(',
|
| 42 |
+
r'exec\s*\('
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
for pattern in suspicious_patterns:
|
| 46 |
+
if re.search(pattern, message, re.IGNORECASE):
|
| 47 |
+
raise ValidationError("Message contains potentially harmful content")
|
| 48 |
+
|
| 49 |
+
return message
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
def validate_email(email: str) -> str:
|
| 53 |
+
"""Validate email address."""
|
| 54 |
+
if not email:
|
| 55 |
+
raise ValidationError("Email cannot be empty")
|
| 56 |
+
|
| 57 |
+
email = email.strip().lower()
|
| 58 |
+
if not InputValidator.EMAIL_PATTERN.match(email):
|
| 59 |
+
raise ValidationError("Invalid email format")
|
| 60 |
+
|
| 61 |
+
if len(email) > 254: # RFC 5321 limit
|
| 62 |
+
raise ValidationError("Email address too long")
|
| 63 |
+
|
| 64 |
+
return email
|
| 65 |
+
|
| 66 |
+
@staticmethod
|
| 67 |
+
def validate_email_list(emails: List[str]) -> List[str]:
|
| 68 |
+
"""Validate a list of email addresses."""
|
| 69 |
+
if not emails:
|
| 70 |
+
return []
|
| 71 |
+
|
| 72 |
+
if len(emails) > 50: # Reasonable limit
|
| 73 |
+
raise ValidationError("Too many attendees (max 50)")
|
| 74 |
+
|
| 75 |
+
validated_emails = []
|
| 76 |
+
for email in emails:
|
| 77 |
+
validated_emails.append(InputValidator.validate_email(email))
|
| 78 |
+
|
| 79 |
+
return validated_emails
|
| 80 |
+
|
| 81 |
+
@staticmethod
|
| 82 |
+
def validate_session_id(session_id: str) -> str:
|
| 83 |
+
"""Validate session ID format."""
|
| 84 |
+
if not session_id:
|
| 85 |
+
raise ValidationError("Session ID cannot be empty")
|
| 86 |
+
|
| 87 |
+
session_id = session_id.strip()
|
| 88 |
+
|
| 89 |
+
# Check for valid UUID-like format or similar
|
| 90 |
+
if not re.match(r'^[a-zA-Z0-9\-_]{8,64}$', session_id):
|
| 91 |
+
raise ValidationError("Invalid session ID format")
|
| 92 |
+
|
| 93 |
+
return session_id
|
| 94 |
+
|
| 95 |
+
@staticmethod
|
| 96 |
+
def validate_duration(duration_minutes: int) -> int:
|
| 97 |
+
"""Validate meeting duration."""
|
| 98 |
+
if duration_minutes < 15:
|
| 99 |
+
raise ValidationError("Meeting duration must be at least 15 minutes")
|
| 100 |
+
|
| 101 |
+
if duration_minutes > 480: # 8 hours
|
| 102 |
+
raise ValidationError("Meeting duration cannot exceed 8 hours")
|
| 103 |
+
|
| 104 |
+
# Return as-is for exact durations, round others to nearest 15 minutes
|
| 105 |
+
if duration_minutes % 15 == 0:
|
| 106 |
+
return duration_minutes
|
| 107 |
+
return ((duration_minutes + 7) // 15) * 15
|
| 108 |
+
|
| 109 |
+
@staticmethod
|
| 110 |
+
def validate_title(title: str) -> str:
|
| 111 |
+
"""Validate meeting title."""
|
| 112 |
+
if not title:
|
| 113 |
+
raise ValidationError("Meeting title cannot be empty")
|
| 114 |
+
|
| 115 |
+
title = title.strip()
|
| 116 |
+
if not title:
|
| 117 |
+
raise ValidationError("Meeting title cannot be empty")
|
| 118 |
+
|
| 119 |
+
if len(title) > 200:
|
| 120 |
+
raise ValidationError("Meeting title too long (max 200 characters)")
|
| 121 |
+
|
| 122 |
+
return title
|
| 123 |
+
|
| 124 |
+
@staticmethod
|
| 125 |
+
def validate_description(description: Optional[str]) -> Optional[str]:
|
| 126 |
+
"""Validate meeting description."""
|
| 127 |
+
if not description:
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
description = description.strip()
|
| 131 |
+
if not description:
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
if len(description) > 1000:
|
| 135 |
+
raise ValidationError("Meeting description too long (max 1000 characters)")
|
| 136 |
+
|
| 137 |
+
return description
|
| 138 |
+
|
| 139 |
+
@staticmethod
|
| 140 |
+
def sanitize_user_input(text: str) -> str:
|
| 141 |
+
"""Sanitize user input for safe processing."""
|
| 142 |
+
if not text:
|
| 143 |
+
return ""
|
| 144 |
+
|
| 145 |
+
# Remove potentially harmful characters
|
| 146 |
+
text = re.sub(r'[<>&"\'`]', '', text)
|
| 147 |
+
|
| 148 |
+
# Normalize whitespace
|
| 149 |
+
text = ' '.join(text.split())
|
| 150 |
+
|
| 151 |
+
return text.strip()
|
| 152 |
+
|
| 153 |
+
@staticmethod
|
| 154 |
+
def validate_time_range(start_time: datetime, end_time: datetime) -> Tuple[datetime, datetime]:
|
| 155 |
+
"""Validate time range for calendar events."""
|
| 156 |
+
if start_time >= end_time:
|
| 157 |
+
raise ValidationError("Start time must be before end time")
|
| 158 |
+
|
| 159 |
+
# Check if event is too far in the future (2 years)
|
| 160 |
+
max_future = datetime.now() + timedelta(days=730)
|
| 161 |
+
if start_time > max_future:
|
| 162 |
+
raise ValidationError("Cannot schedule events more than 2 years in advance")
|
| 163 |
+
|
| 164 |
+
# Check if event is in the past (with 5 minute buffer)
|
| 165 |
+
min_time = datetime.now() - timedelta(minutes=5)
|
| 166 |
+
if start_time < min_time:
|
| 167 |
+
raise ValidationError("Cannot schedule events in the past")
|
| 168 |
+
|
| 169 |
+
# Check maximum event duration (24 hours)
|
| 170 |
+
if end_time - start_time > timedelta(hours=24):
|
| 171 |
+
raise ValidationError("Event duration cannot exceed 24 hours")
|
| 172 |
+
|
| 173 |
+
return start_time, end_time
|
app/personality/__init__.py
ADDED
|
File without changes
|
app/personality/prompts.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SYSTEM_PROMPT = """You are ChatCal, a professional and friendly AI assistant for scheduling appointments with Peter Michael Gits. Be concise and helpful.
|
| 2 |
+
|
| 3 |
+
π― **Primary Mission**: Help visitors book consultations, meetings, and appointments with Peter Michael Gits
|
| 4 |
+
πΌ **Professional & Warm**: Be welcoming while maintaining a professional demeanor
|
| 5 |
+
π
**Efficient Scheduler**: Make booking appointments with Peter quick and easy
|
| 6 |
+
π€ **Clear Communicator**: Ensure all appointment details are crystal clear
|
| 7 |
+
|
| 8 |
+
**CRITICAL RESPONSE STYLE**:
|
| 9 |
+
- Keep responses brief, conversational, and natural
|
| 10 |
+
- NEVER list available tools or mention tool capabilities unless specifically asked
|
| 11 |
+
- Don't provide excessive detail or explanations
|
| 12 |
+
- Respond like a helpful human assistant, not a technical system
|
| 13 |
+
- **SPEAK DIRECTLY TO THE USER**: Say "I need your name" NOT "I need to ask the user for their name"
|
| 14 |
+
- Always address the person you're talking to directly - you ARE the booking agent talking TO them
|
| 15 |
+
- If user provides email, NEVER ask for a "secondary email" - use what they gave you
|
| 16 |
+
- Format responses in HTML with proper styling for readability
|
| 17 |
+
- Use <strong>, <em>, and line breaks to make information easy to scan
|
| 18 |
+
- When cancelling meetings, NEVER ask about the meeting purpose - only ask for time/date if unclear
|
| 19 |
+
|
| 20 |
+
## Peter's Contact Information (Always Available):
|
| 21 |
+
π **Peter's Phone**: {my_phone_number}
|
| 22 |
+
π§ **Peter's Email**: {my_email_address}
|
| 23 |
+
|
| 24 |
+
Use this information when users ask for Peter's contact details or when offering alternatives to calendar booking.
|
| 25 |
+
|
| 26 |
+
Your approach:
|
| 27 |
+
- Greet visitors warmly and explain you're here to help them schedule time with Peter
|
| 28 |
+
- Ask about the purpose of their meeting to suggest appropriate time slots
|
| 29 |
+
- Be knowledgeable about Peter's availability and preferences
|
| 30 |
+
- Collect and remember user contact information throughout the conversation
|
| 31 |
+
- Confirm all details clearly before booking
|
| 32 |
+
- Provide professional confirmations with all necessary information
|
| 33 |
+
- When users ask for Peter's contact info, provide his phone and email above
|
| 34 |
+
|
| 35 |
+
## User Information Collection:
|
| 36 |
+
**REQUIRED INFORMATION BEFORE BOOKING:**
|
| 37 |
+
1. **First name only** (if missing): "What's your first name?"
|
| 38 |
+
2. **Contact Information** (if missing): "Email address?" OR "Phone number?"
|
| 39 |
+
|
| 40 |
+
**STRICT RULE**: Need first name AND (email OR phone) before booking.
|
| 41 |
+
|
| 42 |
+
**GOOGLE MEET SPECIAL REQUIREMENT**:
|
| 43 |
+
- For Google Meet/video call requests, email address is REQUIRED (not optional)
|
| 44 |
+
- Say: "I need your email address to send the Google Meet invitation"
|
| 45 |
+
- Cannot book Google Meet without email - calendar invitations are essential
|
| 46 |
+
|
| 47 |
+
**EMAIL INVITATION FEATURE**: After booking, if user doesn't have email, ask:
|
| 48 |
+
- "If you'd like me to send you a calendar invitation via email, please provide your email address."
|
| 49 |
+
- "I can send both you and Peter email invitations with calendar attachments - just need your email!"
|
| 50 |
+
- **NEVER ask for secondary email if user already provided their email address**
|
| 51 |
+
|
| 52 |
+
**IF USER REFUSES CONTACT INFO**: "You can call Peter at {my_phone_number}"
|
| 53 |
+
|
| 54 |
+
Store this information and use it when booking appointments. Address the user by their name once you know it.
|
| 55 |
+
|
| 56 |
+
## Peter's Availability Requests:
|
| 57 |
+
When users ask about Peter's availability (e.g., "What times does Peter have free tomorrow?"), use the check_availability tool IMMEDIATELY without requiring contact information first. Present the results in 15-minute increments in a clear, selectable format:
|
| 58 |
+
|
| 59 |
+
**Example Response Format:**
|
| 60 |
+
"Here are Peter's available time slots for [date] in 15-minute increments:
|
| 61 |
+
β’ 9:00 AM - 9:15 AM
|
| 62 |
+
β’ 9:15 AM - 9:30 AM
|
| 63 |
+
β’ 9:30 AM - 9:45 AM
|
| 64 |
+
β’ 9:45 AM - 10:00 AM
|
| 65 |
+
β’ 11:30 AM - 11:45 AM
|
| 66 |
+
β’ 11:45 AM - 12:00 PM
|
| 67 |
+
β’ 2:00 PM - 2:15 PM
|
| 68 |
+
β’ 2:15 PM - 2:30 PM
|
| 69 |
+
β’ 4:00 PM - 4:15 PM
|
| 70 |
+
β’ 4:15 PM - 4:30 PM
|
| 71 |
+
|
| 72 |
+
Which time would work best for you?"
|
| 73 |
+
|
| 74 |
+
When handling appointments:
|
| 75 |
+
1. **EXTRACT**: Save any user info provided (name, email, phone) immediately
|
| 76 |
+
2. **VALIDATE COMPLETELY**: Before attempting to book, ensure you have ALL required info:
|
| 77 |
+
- User's first name
|
| 78 |
+
- User's contact (email OR phone)
|
| 79 |
+
- Specific date (not vague like "tomorrow")
|
| 80 |
+
- Specific time (not vague like "afternoon")
|
| 81 |
+
3. **IF ANY INFO MISSING**: Ask for the missing information - DO NOT attempt to book
|
| 82 |
+
4. **IF ALL INFO COMPLETE**: Book directly
|
| 83 |
+
|
| 84 |
+
**CRITICAL TOOL USAGE**:
|
| 85 |
+
- NEVER call create_appointment tool unless you have ALL required information
|
| 86 |
+
- create_appointment ONLY accepts these 5 parameters: title, date_string, time_string, duration_minutes, description
|
| 87 |
+
- NEVER use: organizer_email, email, phone, user_name, attendee_email, attendee_name, organizer_phone
|
| 88 |
+
- Using forbidden parameters causes ERRORS and wastes API calls
|
| 89 |
+
|
| 90 |
+
## Response Formatting
|
| 91 |
+
- Use line breaks to separate different pieces of information
|
| 92 |
+
- Each detail should be on its own line for better readability
|
| 93 |
+
- Use bullet points or numbered lists when presenting multiple items
|
| 94 |
+
|
| 95 |
+
π« **STRICT ENFORCEMENT**:
|
| 96 |
+
- If missing name: "I need your first name to book the appointment."
|
| 97 |
+
- If missing contact: "I need your email address or phone number to book the appointment."
|
| 98 |
+
- If missing date: "I need a specific date for the appointment."
|
| 99 |
+
- If missing time: "I need a specific time for the appointment."
|
| 100 |
+
- If user refuses contact: "You can call Peter directly at {my_phone_number}"
|
| 101 |
+
- **NEVER attempt to book with incomplete information**
|
| 102 |
+
|
| 103 |
+
Types of meetings you can schedule with Peter:
|
| 104 |
+
- Business consultations (60 min) - in-person or Google Meet
|
| 105 |
+
- Professional meetings (60 min) - in-person or Google Meet
|
| 106 |
+
- Project discussions (60 min) - in-person or Google Meet
|
| 107 |
+
- Quick discussions (30 min) - in-person or Google Meet
|
| 108 |
+
- Advisory sessions (90 min) - in-person or Google Meet
|
| 109 |
+
|
| 110 |
+
**Meeting Format Options:**
|
| 111 |
+
- **In-Person**: Traditional face-to-face meeting
|
| 112 |
+
- **Google Meet**: Video conference call with automatic Meet link generation
|
| 113 |
+
|
| 114 |
+
**AFTER SUCCESSFUL BOOKING**: Instead of suggesting users "start fresh" or "begin a new conversation", ask:
|
| 115 |
+
- "Would you like to book another meeting with Peter?"
|
| 116 |
+
- "Need to schedule any other appointments?"
|
| 117 |
+
- "Anything else I can help you schedule?"
|
| 118 |
+
|
| 119 |
+
Remember: You're the professional gateway to Peter's calendar. Always maintain user information throughout the conversation and make the booking process smooth and professional while being friendly and approachable."""
|
| 120 |
+
|
| 121 |
+
# Greeting templates removed - no longer needed
|
| 122 |
+
|
| 123 |
+
BOOKING_CONFIRMATIONS = [
|
| 124 |
+
"""<div style="background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #4caf50; margin: 10px 0;">
|
| 125 |
+
<strong>β
All set!</strong><br>
|
| 126 |
+
Your <strong>{meeting_type}</strong> with Peter is confirmed.<br>
|
| 127 |
+
<strong>π
When:</strong> {date} at <strong>{time}</strong><br>
|
| 128 |
+
Peter's looking forward to it!
|
| 129 |
+
</div>""",
|
| 130 |
+
|
| 131 |
+
"""<div style="background: #e3f2fd; padding: 15px; border-radius: 8px; border-left: 4px solid #2196f3; margin: 10px 0;">
|
| 132 |
+
<strong>π Booked!</strong><br>
|
| 133 |
+
<strong>{meeting_type}</strong> with Peter<br>
|
| 134 |
+
<strong>π
{date} at {time}</strong><br>
|
| 135 |
+
Calendar invite coming your way!
|
| 136 |
+
</div>""",
|
| 137 |
+
|
| 138 |
+
"""<div style="background: #fff3e0; padding: 15px; border-radius: 8px; border-left: 4px solid #ff9800; margin: 10px 0;">
|
| 139 |
+
<strong>π Perfect!</strong><br>
|
| 140 |
+
Your <strong>{meeting_type}</strong> is locked in.<br>
|
| 141 |
+
<strong>π
{date} at {time}</strong><br>
|
| 142 |
+
It's now on Peter's calendar.
|
| 143 |
+
</div>""",
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
ENCOURAGEMENT_PHRASES = [
|
| 147 |
+
"Peter appreciates you taking the time to schedule properly!",
|
| 148 |
+
"Thank you for booking through the proper channels.",
|
| 149 |
+
"Peter is looking forward to your meeting!",
|
| 150 |
+
"Great choice scheduling this meeting with Peter.",
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
ERROR_RESPONSES = {
|
| 154 |
+
"time_conflict": "That time slot is already booked on Peter's calendar. Let me suggest some alternative times: {alternative_times}. Which works better for you?",
|
| 155 |
+
"invalid_date": "I need a bit more clarity on the date. Could you please specify? For example: 'next Tuesday at 2pm' or 'December 15th at 10am'.",
|
| 156 |
+
"past_date": "That date has already passed. Please choose a future date for your meeting with Peter.",
|
| 157 |
+
"auth_error": "I'm having trouble accessing Peter's calendar. Please try again in a moment.",
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
AVAILABILITY_CHECK = """Let me check Peter's availability for you... π
|
| 161 |
+
One moment please...
|
| 162 |
+
Here's what I found:"""
|
| 163 |
+
|
| 164 |
+
CLARIFICATION_REQUESTS = {
|
| 165 |
+
"duration": "How much time do you need with Peter? Typical meetings are 30 minutes for quick discussions or 60 minutes for detailed consultations.",
|
| 166 |
+
"attendees": "Will anyone else be joining this meeting with Peter? If so, please provide their email addresses.",
|
| 167 |
+
"title": "What's the purpose or topic of your meeting with Peter? This helps him prepare appropriately.",
|
| 168 |
+
"time": "What time works best for you? Peter typically meets between 9 AM and 5 PM.",
|
| 169 |
+
"contact": "Email or phone?",
|
| 170 |
+
"purpose": "What would you like to discuss?",
|
| 171 |
+
"name": "First name?",
|
| 172 |
+
"email": "Email address?",
|
| 173 |
+
"phone": "Phone number?",
|
| 174 |
+
"availability": "Which day?",
|
| 175 |
+
"contact_options": "Email or phone?",
|
| 176 |
+
"cancellation_time": "What time was the meeting?",
|
| 177 |
+
"cancellation_date": "Which day was the meeting?",
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
MEETING_TYPES = {
|
| 181 |
+
"consultation": {
|
| 182 |
+
"duration": 60,
|
| 183 |
+
"description": "Professional consultation with Peter Michael Gits",
|
| 184 |
+
"conference": False
|
| 185 |
+
},
|
| 186 |
+
"consultation_meet": {
|
| 187 |
+
"duration": 60,
|
| 188 |
+
"description": "Professional consultation with Peter via Google Meet",
|
| 189 |
+
"conference": True
|
| 190 |
+
},
|
| 191 |
+
"quick_chat": {
|
| 192 |
+
"duration": 30,
|
| 193 |
+
"description": "Brief discussion with Peter",
|
| 194 |
+
"conference": False
|
| 195 |
+
},
|
| 196 |
+
"quick_chat_meet": {
|
| 197 |
+
"duration": 30,
|
| 198 |
+
"description": "Brief discussion with Peter via Google Meet",
|
| 199 |
+
"conference": True
|
| 200 |
+
},
|
| 201 |
+
"project_discussion": {
|
| 202 |
+
"duration": 60,
|
| 203 |
+
"description": "Project planning or review with Peter",
|
| 204 |
+
"conference": False
|
| 205 |
+
},
|
| 206 |
+
"project_discussion_meet": {
|
| 207 |
+
"duration": 60,
|
| 208 |
+
"description": "Project planning or review with Peter via Google Meet",
|
| 209 |
+
"conference": True
|
| 210 |
+
},
|
| 211 |
+
"business_meeting": {
|
| 212 |
+
"duration": 60,
|
| 213 |
+
"description": "Business meeting with Peter Michael Gits",
|
| 214 |
+
"conference": False
|
| 215 |
+
},
|
| 216 |
+
"business_meeting_meet": {
|
| 217 |
+
"duration": 60,
|
| 218 |
+
"description": "Business meeting with Peter via Google Meet",
|
| 219 |
+
"conference": True
|
| 220 |
+
},
|
| 221 |
+
"advisory_session": {
|
| 222 |
+
"duration": 90,
|
| 223 |
+
"description": "Extended advisory session with Peter",
|
| 224 |
+
"conference": False
|
| 225 |
+
},
|
| 226 |
+
"advisory_session_meet": {
|
| 227 |
+
"duration": 90,
|
| 228 |
+
"description": "Extended advisory session with Peter via Google Meet",
|
| 229 |
+
"conference": True
|
| 230 |
+
}
|
| 231 |
+
}
|
app/services/tts_proxy.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
TTS Proxy Server for ChatCal.ai - integrating kyutai-tts-service-v3
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
import json
|
| 8 |
+
import tempfile
|
| 9 |
+
import shutil
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from gradio_client import Client
|
| 15 |
+
except ImportError:
|
| 16 |
+
print("Installing gradio-client...")
|
| 17 |
+
import subprocess
|
| 18 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "gradio-client"])
|
| 19 |
+
from gradio_client import Client
|
| 20 |
+
|
| 21 |
+
# Global file mapping to store audio files
|
| 22 |
+
AUDIO_FILES = {}
|
| 23 |
+
|
| 24 |
+
class ChatCalTTSHandler(SimpleHTTPRequestHandler):
|
| 25 |
+
def __init__(self, *args, **kwargs):
|
| 26 |
+
super().__init__(*args, directory=str(Path(__file__).parent), **kwargs)
|
| 27 |
+
|
| 28 |
+
def end_headers(self):
|
| 29 |
+
# Add CORS headers
|
| 30 |
+
self.send_header('Access-Control-Allow-Origin', '*')
|
| 31 |
+
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
| 32 |
+
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
| 33 |
+
super().end_headers()
|
| 34 |
+
|
| 35 |
+
def do_OPTIONS(self):
|
| 36 |
+
self.send_response(200)
|
| 37 |
+
self.end_headers()
|
| 38 |
+
|
| 39 |
+
def do_GET(self):
|
| 40 |
+
if self.path.startswith('/audio/'):
|
| 41 |
+
self.serve_audio_file()
|
| 42 |
+
else:
|
| 43 |
+
super().do_GET()
|
| 44 |
+
|
| 45 |
+
def do_POST(self):
|
| 46 |
+
if self.path == '/api/synthesize':
|
| 47 |
+
self.handle_tts_synthesis()
|
| 48 |
+
else:
|
| 49 |
+
super().do_POST()
|
| 50 |
+
|
| 51 |
+
def serve_audio_file(self):
|
| 52 |
+
try:
|
| 53 |
+
filename = self.path.split('/')[-1]
|
| 54 |
+
print(f"π Looking for audio file: {filename}")
|
| 55 |
+
|
| 56 |
+
if filename in AUDIO_FILES:
|
| 57 |
+
audio_path = AUDIO_FILES[filename]
|
| 58 |
+
print(f"π Found file mapping: {audio_path}")
|
| 59 |
+
|
| 60 |
+
if audio_path and Path(audio_path).exists():
|
| 61 |
+
print(f"β
Serving audio file: {audio_path}")
|
| 62 |
+
|
| 63 |
+
self.send_response(200)
|
| 64 |
+
self.send_header('Content-Type', 'audio/wav')
|
| 65 |
+
self.send_header('Cache-Control', 'no-cache')
|
| 66 |
+
self.end_headers()
|
| 67 |
+
|
| 68 |
+
with open(audio_path, 'rb') as f:
|
| 69 |
+
self.wfile.write(f.read())
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
print(f"β Audio file not found: {filename}")
|
| 73 |
+
self.send_response(404)
|
| 74 |
+
self.end_headers()
|
| 75 |
+
self.wfile.write(b'Audio file not found')
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"β Audio serve error: {e}")
|
| 79 |
+
self.send_response(500)
|
| 80 |
+
self.end_headers()
|
| 81 |
+
self.wfile.write(f"Error: {e}".encode())
|
| 82 |
+
|
| 83 |
+
def handle_tts_synthesis(self):
|
| 84 |
+
try:
|
| 85 |
+
# Parse request
|
| 86 |
+
content_length = int(self.headers['Content-Length'])
|
| 87 |
+
post_data = self.rfile.read(content_length)
|
| 88 |
+
request_data = json.loads(post_data.decode('utf-8'))
|
| 89 |
+
|
| 90 |
+
text = request_data.get('text', '')
|
| 91 |
+
voice = request_data.get('voice', 'expresso/ex03-ex01_happy_001_channel1_334s.wav')
|
| 92 |
+
|
| 93 |
+
print(f"π΅ ChatCal TTS: '{text}' with voice: {voice}")
|
| 94 |
+
|
| 95 |
+
# Connect to kyutai-tts-service-v3 on HuggingFace
|
| 96 |
+
client = Client("https://pgits-kyutai-tts-service-v3.hf.space")
|
| 97 |
+
result = client.predict(
|
| 98 |
+
text,
|
| 99 |
+
voice,
|
| 100 |
+
{},
|
| 101 |
+
api_name="/synthesize_and_stream"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
if result and len(result) >= 2:
|
| 105 |
+
audio_file_path = result[0] # This is the full path string
|
| 106 |
+
status_message = result[1]
|
| 107 |
+
|
| 108 |
+
print(f"β
TTS Success: {status_message}")
|
| 109 |
+
print(f"π Original audio file: {audio_file_path}")
|
| 110 |
+
|
| 111 |
+
if isinstance(audio_file_path, str) and audio_file_path.startswith('/'):
|
| 112 |
+
# Create a simple filename for serving
|
| 113 |
+
temp_filename = f"tts_{len(AUDIO_FILES)}.wav"
|
| 114 |
+
|
| 115 |
+
# Store the mapping
|
| 116 |
+
AUDIO_FILES[temp_filename] = audio_file_path
|
| 117 |
+
|
| 118 |
+
# Return relative URL path for client
|
| 119 |
+
audio_url = f"/audio/{temp_filename}"
|
| 120 |
+
|
| 121 |
+
response_data = {
|
| 122 |
+
'success': True,
|
| 123 |
+
'audio_url': audio_url,
|
| 124 |
+
'status': status_message,
|
| 125 |
+
'debug_path': audio_file_path
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
print(f"π Audio URL: http://localhost:8081{audio_url}")
|
| 129 |
+
print(f"ποΈ File mapping: {temp_filename} -> {audio_file_path}")
|
| 130 |
+
|
| 131 |
+
else:
|
| 132 |
+
raise Exception("Invalid audio file path format")
|
| 133 |
+
else:
|
| 134 |
+
raise Exception("No audio data received from TTS service")
|
| 135 |
+
|
| 136 |
+
# Send response
|
| 137 |
+
self.send_response(200)
|
| 138 |
+
self.send_header('Content-Type', 'application/json')
|
| 139 |
+
self.end_headers()
|
| 140 |
+
self.wfile.write(json.dumps(response_data).encode('utf-8'))
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
print(f"β TTS Synthesis error: {e}")
|
| 144 |
+
|
| 145 |
+
error_response = {
|
| 146 |
+
'success': False,
|
| 147 |
+
'error': str(e)
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
self.send_response(500)
|
| 151 |
+
self.send_header('Content-Type', 'application/json')
|
| 152 |
+
self.end_headers()
|
| 153 |
+
self.wfile.write(json.dumps(error_response).encode('utf-8'))
|
| 154 |
+
|
| 155 |
+
def main():
|
| 156 |
+
port = 8081
|
| 157 |
+
server_address = ('', port)
|
| 158 |
+
|
| 159 |
+
print("π Starting ChatCal TTS Proxy Server...")
|
| 160 |
+
print(f"π‘ Server running at: http://localhost:{port}")
|
| 161 |
+
print(f"π΅ TTS API: http://localhost:{port}/api/synthesize")
|
| 162 |
+
print(f"π― Connected to: kyutai-tts-service-v3")
|
| 163 |
+
print("Press Ctrl+C to stop")
|
| 164 |
+
|
| 165 |
+
httpd = HTTPServer(server_address, ChatCalTTSHandler)
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
httpd.serve_forever()
|
| 169 |
+
except KeyboardInterrupt:
|
| 170 |
+
print("\nπ Server stopped")
|
| 171 |
+
httpd.server_close()
|
| 172 |
+
|
| 173 |
+
if __name__ == "__main__":
|
| 174 |
+
main()
|
pyproject.toml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool.poetry]
|
| 2 |
+
name = "voicecal-ai"
|
| 3 |
+
version = "1.0.0"
|
| 4 |
+
description = "A friendly AI chatbot for booking Google Calendar appointments"
|
| 5 |
+
authors = ["Peter <pgits.job@gmail.com>"]
|
| 6 |
+
|
| 7 |
+
[tool.poetry.dependencies]
|
| 8 |
+
python = "^3.11"
|
| 9 |
+
llama-index = "^0.11.0"
|
| 10 |
+
llama-index-llms-anthropic = "^0.3.0"
|
| 11 |
+
llama-index-llms-gemini = "^0.3.0"
|
| 12 |
+
llama-index-llms-groq = "^0.2.0"
|
| 13 |
+
google-generativeai = "^0.5.2"
|
| 14 |
+
llama-index-tools-google = "^0.2.0"
|
| 15 |
+
google-api-python-client = "^2.100.0"
|
| 16 |
+
google-auth = "^2.23.0"
|
| 17 |
+
google-auth-oauthlib = "^1.1.0"
|
| 18 |
+
google-auth-httplib2 = "^0.2.0"
|
| 19 |
+
fastapi = "^0.104.0"
|
| 20 |
+
uvicorn = "^0.24.0"
|
| 21 |
+
pydantic = "^2.4.0"
|
| 22 |
+
pydantic-settings = "^2.0.0"
|
| 23 |
+
python-dateutil = "^2.8.2"
|
| 24 |
+
pytz = "^2023.3"
|
| 25 |
+
redis = "^5.0.0"
|
| 26 |
+
python-multipart = "^0.0.6"
|
| 27 |
+
python-jose = "^3.3.0"
|
| 28 |
+
httpx = "^0.25.0"
|
| 29 |
+
python-dotenv = "^1.0.0"
|
| 30 |
+
|
| 31 |
+
[tool.poetry.group.dev.dependencies]
|
| 32 |
+
pytest = "^7.4.0"
|
| 33 |
+
pytest-asyncio = "^0.21.0"
|
| 34 |
+
black = "^23.10.0"
|
| 35 |
+
flake8 = "^6.1.0"
|
| 36 |
+
mypy = "^1.6.0"
|
| 37 |
+
pre-commit = "^3.5.0"
|
| 38 |
+
|
| 39 |
+
[build-system]
|
| 40 |
+
requires = ["poetry-core"]
|
| 41 |
+
build-backend = "poetry.core.masonry.api"
|
| 42 |
+
|
| 43 |
+
[tool.black]
|
| 44 |
+
line-length = 88
|
| 45 |
+
target-version = ['py311']
|
| 46 |
+
|
| 47 |
+
[tool.mypy]
|
| 48 |
+
python_version = "3.11"
|
| 49 |
+
warn_return_any = true
|
| 50 |
+
warn_unused_configs = true
|
requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
llama-index>=0.11.0
|
| 2 |
+
llama-index-llms-anthropic>=0.3.0
|
| 3 |
+
llama-index-llms-gemini>=0.3.0
|
| 4 |
+
llama-index-llms-groq>=0.2.0
|
| 5 |
+
google-generativeai>=0.5.2
|
| 6 |
+
llama-index-tools-google>=0.2.0
|
| 7 |
+
google-api-python-client>=2.100.0
|
| 8 |
+
google-auth>=2.23.0
|
| 9 |
+
google-auth-oauthlib>=1.1.0
|
| 10 |
+
google-auth-httplib2>=0.2.0
|
| 11 |
+
fastapi>=0.104.0
|
| 12 |
+
uvicorn>=0.24.0
|
| 13 |
+
pydantic>=2.4.0
|
| 14 |
+
pydantic-settings>=2.0.0
|
| 15 |
+
python-dateutil>=2.8.2
|
| 16 |
+
pytz>=2023.3
|
| 17 |
+
redis>=5.0.0
|
| 18 |
+
python-multipart>=0.0.6
|
| 19 |
+
python-jose>=3.3.0
|
| 20 |
+
PyJWT>=2.8.0
|
| 21 |
+
httpx>=0.25.0
|
| 22 |
+
python-dotenv>=1.0.0
|