pgits Claude commited on
Commit
05c4fa2
Β·
0 Parent(s):

Initial VoiceCal.ai v1.0.0 deployment

Browse files

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