BilalCode commited on
Commit
310260a
·
1 Parent(s): b63647e

taskflow todo app

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +18 -0
  2. .gitignore +46 -0
  3. API_DOCUMENTATION.md +840 -0
  4. Dockerfile +32 -0
  5. alembic.ini +147 -0
  6. alembic/README +1 -0
  7. alembic/env.py +90 -0
  8. alembic/script.py.mako +28 -0
  9. alembic/versions/78c5de432ae1_add_sequence_number_to_messages_and_.py +59 -0
  10. alembic/versions/f00ef5a9ad43_add_conversations_and_messages_tables.py +68 -0
  11. requirements.txt +12 -0
  12. src/agents/__init__.py +10 -0
  13. src/agents/agent_config.py +83 -0
  14. src/agents/task_agent.py +203 -0
  15. src/api/__init__.py +6 -0
  16. src/api/auth.py +140 -0
  17. src/api/chat.py +563 -0
  18. src/api/dependencies.py +81 -0
  19. src/api/tasks.py +240 -0
  20. src/config.py +46 -0
  21. src/database.py +29 -0
  22. src/jobs/cleanup_conversations.py +157 -0
  23. src/main.py +121 -0
  24. src/middleware/__init__.py +1 -0
  25. src/middleware/auth.py +62 -0
  26. src/middleware/rate_limit.py +161 -0
  27. src/models/__init__.py +8 -0
  28. src/models/conversation.py +28 -0
  29. src/models/message.py +37 -0
  30. src/models/task.py +23 -0
  31. src/models/tool_call_log.py +45 -0
  32. src/models/user.py +22 -0
  33. src/schemas/__init__.py +1 -0
  34. src/schemas/chat.py +78 -0
  35. src/schemas/error.py +22 -0
  36. src/schemas/task.py +61 -0
  37. src/services/__init__.py +5 -0
  38. src/services/agent_service.py +374 -0
  39. src/services/cohere_agent_service.py +221 -0
  40. src/services/conversation_service.py +330 -0
  41. src/tools/__init__.py +40 -0
  42. src/tools/create_task.py +133 -0
  43. src/tools/delete_task.py +106 -0
  44. src/tools/get_task.py +112 -0
  45. src/tools/list_tasks.py +101 -0
  46. src/tools/mark_complete.py +115 -0
  47. src/tools/mcp_server.py +113 -0
  48. src/tools/update_task.py +160 -0
  49. src/utils/logging.py +193 -0
  50. src/utils/validation.py +149 -0
.env.example ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Database Configuration
2
+ DATABASE_URL=postgresql://neondb_owner:npg_E1weFcD5YCsp@ep-wandering-waterfall-ai9cg8eu-pooler.c-4.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require
3
+
4
+
5
+ # JWT Configuration
6
+ JWT_SECRET=c93afeb6710d2845
7
+ JWT_ALGORITHM=HS256
8
+ JWT_EXPIRATION_HOURS=168
9
+
10
+ # Better Auth Secret (must match frontend)
11
+ BETTER_AUTH_SECRET=3gHSWlEDitVGXMw9B9d1YcXriLhyxltr
12
+
13
+ # CORS Configuration
14
+ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
15
+
16
+ # Environment
17
+ ENVIRONMENT=development
18
+ DEBUG=true
.gitignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ venv/
8
+ env/
9
+ ENV/
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+
26
+ # Environment
27
+ .env
28
+ .env.local
29
+ .env.*.local
30
+
31
+ # Testing
32
+ .pytest_cache/
33
+ .coverage
34
+ htmlcov/
35
+ .tox/
36
+
37
+ # IDE
38
+ .vscode/
39
+ .idea/
40
+ *.swp
41
+ *.swo
42
+ *~
43
+
44
+ # OS
45
+ .DS_Store
46
+ Thumbs.db
API_DOCUMENTATION.md ADDED
@@ -0,0 +1,840 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KIro Todo API Documentation
2
+
3
+ **Version**: 1.0.0
4
+ **Base URL**: `http://localhost:8001`
5
+ **Authentication**: JWT Bearer Token
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ 1. [Overview](#overview)
12
+ 2. [Authentication](#authentication)
13
+ 3. [Task Management](#task-management)
14
+ 4. [Data Models](#data-models)
15
+ 5. [Error Handling](#error-handling)
16
+ 6. [Security](#security)
17
+ 7. [Examples](#examples)
18
+
19
+ ---
20
+
21
+ ## Overview
22
+
23
+ The KIro Todo API is a RESTful API built with FastAPI that provides secure, multi-user task management with complete data isolation. Each user can only access their own tasks, enforced through JWT authentication and database-level filtering.
24
+
25
+ **Key Features:**
26
+ - JWT-based authentication
27
+ - User-scoped data isolation
28
+ - Complete CRUD operations for tasks
29
+ - Task completion tracking
30
+ - Comprehensive error handling
31
+ - Automatic timestamp management
32
+
33
+ ---
34
+
35
+ ## Authentication
36
+
37
+ All task endpoints require a valid JWT token in the `Authorization` header.
38
+
39
+ ### User Signup
40
+
41
+ Create a new user account.
42
+
43
+ **Endpoint:** `POST /api/auth/signup`
44
+
45
+ **Request Body:**
46
+ ```json
47
+ {
48
+ "email": "user@example.com",
49
+ "password": "SecurePass123"
50
+ }
51
+ ```
52
+
53
+ **Response:** `201 Created`
54
+ ```json
55
+ {
56
+ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
57
+ "token_type": "bearer",
58
+ "user": {
59
+ "id": 1,
60
+ "email": "user@example.com"
61
+ }
62
+ }
63
+ ```
64
+
65
+ **Validation Rules:**
66
+ - Email must be valid format
67
+ - Password minimum 8 characters
68
+ - Password must contain: uppercase, lowercase, number
69
+ - Email must be unique
70
+
71
+ **Error Responses:**
72
+ - `400 Bad Request` - Invalid email format or weak password
73
+ - `409 Conflict` - Email already exists
74
+
75
+ ---
76
+
77
+ ### User Signin
78
+
79
+ Authenticate an existing user.
80
+
81
+ **Endpoint:** `POST /api/auth/signin`
82
+
83
+ **Request Body:**
84
+ ```json
85
+ {
86
+ "email": "user@example.com",
87
+ "password": "SecurePass123"
88
+ }
89
+ ```
90
+
91
+ **Response:** `200 OK`
92
+ ```json
93
+ {
94
+ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
95
+ "token_type": "bearer",
96
+ "user": {
97
+ "id": 1,
98
+ "email": "user@example.com"
99
+ }
100
+ }
101
+ ```
102
+
103
+ **Error Responses:**
104
+ - `401 Unauthorized` - Invalid credentials
105
+
106
+ ---
107
+
108
+ ## Task Management
109
+
110
+ All task endpoints require authentication via JWT token.
111
+
112
+ **Authentication Header:**
113
+ ```
114
+ Authorization: Bearer <your_jwt_token>
115
+ ```
116
+
117
+ ---
118
+
119
+ ### List All Tasks
120
+
121
+ Retrieve all tasks for the authenticated user.
122
+
123
+ **Endpoint:** `GET /api/tasks`
124
+
125
+ **Headers:**
126
+ ```
127
+ Authorization: Bearer <token>
128
+ ```
129
+
130
+ **Response:** `200 OK`
131
+ ```json
132
+ [
133
+ {
134
+ "id": 1,
135
+ "title": "Complete project documentation",
136
+ "description": "Write comprehensive API docs",
137
+ "completed": false,
138
+ "user_id": 1,
139
+ "created_at": "2026-02-03T10:30:00Z",
140
+ "updated_at": "2026-02-03T10:30:00Z"
141
+ },
142
+ {
143
+ "id": 2,
144
+ "title": "Review pull requests",
145
+ "description": null,
146
+ "completed": true,
147
+ "user_id": 1,
148
+ "created_at": "2026-02-03T09:15:00Z",
149
+ "updated_at": "2026-02-03T11:45:00Z"
150
+ }
151
+ ]
152
+ ```
153
+
154
+ **Query Parameters:** None
155
+
156
+ **Notes:**
157
+ - Returns only tasks owned by authenticated user
158
+ - Ordered by creation date (newest first)
159
+ - Empty array if user has no tasks
160
+
161
+ ---
162
+
163
+ ### Create Task
164
+
165
+ Create a new task for the authenticated user.
166
+
167
+ **Endpoint:** `POST /api/tasks`
168
+
169
+ **Headers:**
170
+ ```
171
+ Authorization: Bearer <token>
172
+ Content-Type: application/json
173
+ ```
174
+
175
+ **Request Body:**
176
+ ```json
177
+ {
178
+ "title": "Buy groceries",
179
+ "description": "Milk, eggs, bread, and coffee"
180
+ }
181
+ ```
182
+
183
+ **Response:** `201 Created`
184
+ ```json
185
+ {
186
+ "id": 3,
187
+ "title": "Buy groceries",
188
+ "description": "Milk, eggs, bread, and coffee",
189
+ "completed": false,
190
+ "user_id": 1,
191
+ "created_at": "2026-02-03T12:00:00Z",
192
+ "updated_at": "2026-02-03T12:00:00Z"
193
+ }
194
+ ```
195
+
196
+ **Field Requirements:**
197
+ - `title` (required): 1-200 characters, cannot be empty or whitespace
198
+ - `description` (optional): 0-2000 characters, can be null
199
+
200
+ **Error Responses:**
201
+ - `400 Bad Request` - Missing or empty title
202
+ - `401 Unauthorized` - Invalid or missing token
203
+
204
+ **Notes:**
205
+ - `user_id` is automatically set from JWT token
206
+ - `completed` defaults to `false`
207
+ - Timestamps are automatically generated
208
+
209
+ ---
210
+
211
+ ### Get Single Task
212
+
213
+ Retrieve a specific task by ID.
214
+
215
+ **Endpoint:** `GET /api/tasks/{task_id}`
216
+
217
+ **Headers:**
218
+ ```
219
+ Authorization: Bearer <token>
220
+ ```
221
+
222
+ **Path Parameters:**
223
+ - `task_id` (integer): The task ID
224
+
225
+ **Response:** `200 OK`
226
+ ```json
227
+ {
228
+ "id": 1,
229
+ "title": "Complete project documentation",
230
+ "description": "Write comprehensive API docs",
231
+ "completed": false,
232
+ "user_id": 1,
233
+ "created_at": "2026-02-03T10:30:00Z",
234
+ "updated_at": "2026-02-03T10:30:00Z"
235
+ }
236
+ ```
237
+
238
+ **Error Responses:**
239
+ - `401 Unauthorized` - Invalid or missing token
240
+ - `403 Forbidden` - Task belongs to another user
241
+ - `404 Not Found` - Task does not exist
242
+
243
+ **Security:**
244
+ - Ownership is verified before returning task
245
+ - Users cannot access other users' tasks
246
+
247
+ ---
248
+
249
+ ### Update Task
250
+
251
+ Update an existing task's title and/or description.
252
+
253
+ **Endpoint:** `PUT /api/tasks/{task_id}`
254
+
255
+ **Headers:**
256
+ ```
257
+ Authorization: Bearer <token>
258
+ Content-Type: application/json
259
+ ```
260
+
261
+ **Path Parameters:**
262
+ - `task_id` (integer): The task ID
263
+
264
+ **Request Body:**
265
+ ```json
266
+ {
267
+ "title": "Complete project documentation (Updated)",
268
+ "description": "Write comprehensive API docs with examples"
269
+ }
270
+ ```
271
+
272
+ **Partial Update Supported:**
273
+ ```json
274
+ {
275
+ "title": "New title only"
276
+ }
277
+ ```
278
+
279
+ **Response:** `200 OK`
280
+ ```json
281
+ {
282
+ "id": 1,
283
+ "title": "Complete project documentation (Updated)",
284
+ "description": "Write comprehensive API docs with examples",
285
+ "completed": false,
286
+ "user_id": 1,
287
+ "created_at": "2026-02-03T10:30:00Z",
288
+ "updated_at": "2026-02-03T12:30:00Z"
289
+ }
290
+ ```
291
+
292
+ **Field Requirements:**
293
+ - `title` (optional): If provided, 1-200 characters, cannot be empty
294
+ - `description` (optional): If provided, 0-2000 characters
295
+
296
+ **Error Responses:**
297
+ - `400 Bad Request` - Empty title provided
298
+ - `401 Unauthorized` - Invalid or missing token
299
+ - `403 Forbidden` - Task belongs to another user
300
+ - `404 Not Found` - Task does not exist
301
+
302
+ **Notes:**
303
+ - Only provided fields are updated
304
+ - `updated_at` timestamp is automatically updated
305
+ - Ownership is verified before update
306
+
307
+ ---
308
+
309
+ ### Delete Task
310
+
311
+ Permanently delete a task.
312
+
313
+ **Endpoint:** `DELETE /api/tasks/{task_id}`
314
+
315
+ **Headers:**
316
+ ```
317
+ Authorization: Bearer <token>
318
+ ```
319
+
320
+ **Path Parameters:**
321
+ - `task_id` (integer): The task ID
322
+
323
+ **Response:** `200 OK`
324
+ ```json
325
+ {
326
+ "message": "Task deleted successfully"
327
+ }
328
+ ```
329
+
330
+ **Error Responses:**
331
+ - `401 Unauthorized` - Invalid or missing token
332
+ - `403 Forbidden` - Task belongs to another user
333
+ - `404 Not Found` - Task does not exist
334
+
335
+ **Notes:**
336
+ - Deletion is permanent (no soft delete)
337
+ - Ownership is verified before deletion
338
+
339
+ ---
340
+
341
+ ### Toggle Task Completion
342
+
343
+ Toggle a task's completion status (complete ↔ incomplete).
344
+
345
+ **Endpoint:** `PATCH /api/tasks/{task_id}/complete`
346
+
347
+ **Headers:**
348
+ ```
349
+ Authorization: Bearer <token>
350
+ ```
351
+
352
+ **Path Parameters:**
353
+ - `task_id` (integer): The task ID
354
+
355
+ **Request Body:** None
356
+
357
+ **Response:** `200 OK`
358
+ ```json
359
+ {
360
+ "id": 1,
361
+ "title": "Complete project documentation",
362
+ "description": "Write comprehensive API docs",
363
+ "completed": true,
364
+ "user_id": 1,
365
+ "created_at": "2026-02-03T10:30:00Z",
366
+ "updated_at": "2026-02-03T13:00:00Z"
367
+ }
368
+ ```
369
+
370
+ **Behavior:**
371
+ - `completed: false` → `completed: true`
372
+ - `completed: true` → `completed: false`
373
+
374
+ **Error Responses:**
375
+ - `401 Unauthorized` - Invalid or missing token
376
+ - `403 Forbidden` - Task belongs to another user
377
+ - `404 Not Found` - Task does not exist
378
+
379
+ **Notes:**
380
+ - `updated_at` timestamp is automatically updated
381
+ - Ownership is verified before toggle
382
+
383
+ ---
384
+
385
+ ## Data Models
386
+
387
+ ### User
388
+
389
+ ```typescript
390
+ {
391
+ id: number; // Auto-generated primary key
392
+ email: string; // Unique, valid email format
393
+ password_hash: string; // Bcrypt hashed password (never exposed in API)
394
+ created_at: datetime; // Auto-generated timestamp
395
+ }
396
+ ```
397
+
398
+ ### Task
399
+
400
+ ```typescript
401
+ {
402
+ id: number; // Auto-generated primary key
403
+ title: string; // Required, 1-200 characters
404
+ description: string | null; // Optional, 0-2000 characters
405
+ completed: boolean; // Default: false
406
+ user_id: number; // Foreign key to User (from JWT)
407
+ created_at: datetime; // Auto-generated timestamp
408
+ updated_at: datetime; // Auto-updated on changes
409
+ }
410
+ ```
411
+
412
+ ### JWT Token Payload
413
+
414
+ ```typescript
415
+ {
416
+ user_id: number; // User's database ID
417
+ email: string; // User's email
418
+ exp: number; // Token expiration timestamp
419
+ iat: number; // Token issued at timestamp
420
+ }
421
+ ```
422
+
423
+ ---
424
+
425
+ ## Error Handling
426
+
427
+ All errors follow a consistent format:
428
+
429
+ ```json
430
+ {
431
+ "error": "Detailed error message",
432
+ "message": "User-friendly message",
433
+ "details": null
434
+ }
435
+ ```
436
+
437
+ ### HTTP Status Codes
438
+
439
+ | Code | Meaning | When Used |
440
+ |------|---------|-----------|
441
+ | `200` | OK | Successful GET, PUT, PATCH, DELETE |
442
+ | `201` | Created | Successful POST (resource created) |
443
+ | `400` | Bad Request | Invalid input, validation failure |
444
+ | `401` | Unauthorized | Missing or invalid JWT token |
445
+ | `403` | Forbidden | Valid token but insufficient permissions |
446
+ | `404` | Not Found | Resource does not exist |
447
+ | `409` | Conflict | Resource already exists (e.g., duplicate email) |
448
+ | `500` | Internal Server Error | Unexpected server error |
449
+
450
+ ### Common Error Scenarios
451
+
452
+ **Missing Authentication:**
453
+ ```json
454
+ {
455
+ "error": "Not authenticated",
456
+ "message": "Authentication is required to access this resource",
457
+ "details": null
458
+ }
459
+ ```
460
+
461
+ **Invalid Token:**
462
+ ```json
463
+ {
464
+ "error": "Invalid token",
465
+ "message": "Authentication is required to access this resource",
466
+ "details": null
467
+ }
468
+ ```
469
+
470
+ **Accessing Another User's Task:**
471
+ ```json
472
+ {
473
+ "error": "You do not have permission to access this task",
474
+ "message": "You do not have permission to access this resource",
475
+ "details": null
476
+ }
477
+ ```
478
+
479
+ **Task Not Found:**
480
+ ```json
481
+ {
482
+ "error": "Task not found",
483
+ "message": "The requested resource was not found",
484
+ "details": null
485
+ }
486
+ ```
487
+
488
+ **Validation Error:**
489
+ ```json
490
+ {
491
+ "error": "Title is required and cannot be empty",
492
+ "message": "The request contains invalid data",
493
+ "details": null
494
+ }
495
+ ```
496
+
497
+ ---
498
+
499
+ ## Security
500
+
501
+ ### Authentication Flow
502
+
503
+ 1. **User Registration/Login:**
504
+ - User provides email and password
505
+ - Backend validates credentials
506
+ - Backend generates JWT token with user_id
507
+ - Token returned to client
508
+
509
+ 2. **Authenticated Requests:**
510
+ - Client includes token in `Authorization: Bearer <token>` header
511
+ - Backend verifies token signature
512
+ - Backend extracts `user_id` from token payload
513
+ - Backend uses `user_id` for all database queries
514
+
515
+ ### Security Guarantees
516
+
517
+ ✅ **User Identity from JWT Only:**
518
+ - User ID is NEVER accepted from client input
519
+ - All user identification comes from verified JWT token
520
+ - Prevents user impersonation attacks
521
+
522
+ ✅ **Complete Data Isolation:**
523
+ - All database queries filtered by authenticated `user_id`
524
+ - Users cannot view, edit, or delete other users' tasks
525
+ - Ownership verified on every operation
526
+
527
+ ✅ **Password Security:**
528
+ - Passwords hashed with bcrypt before storage
529
+ - Plain-text passwords never stored
530
+ - Password hashes never exposed in API responses
531
+
532
+ ✅ **SQL Injection Prevention:**
533
+ - All queries use SQLModel ORM with parameterization
534
+ - No raw SQL with string concatenation
535
+
536
+ ✅ **CORS Protection:**
537
+ - CORS middleware configured with allowed origins
538
+ - Prevents unauthorized cross-origin requests
539
+
540
+ ### Token Management
541
+
542
+ **Token Expiration:** 7 days (168 hours)
543
+
544
+ **Token Format:**
545
+ ```
546
+ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJleHAiOjE3MDcwNTI4MDB9.signature
547
+ ```
548
+
549
+ **Token Verification:**
550
+ - Signature verified using `JWT_SECRET`
551
+ - Expiration checked on every request
552
+ - Invalid tokens rejected with 401 Unauthorized
553
+
554
+ ---
555
+
556
+ ## Examples
557
+
558
+ ### Complete Workflow Example
559
+
560
+ #### 1. Create Account
561
+
562
+ ```bash
563
+ curl -X POST http://localhost:8001/api/auth/signup \
564
+ -H "Content-Type: application/json" \
565
+ -d '{
566
+ "email": "john@example.com",
567
+ "password": "SecurePass123"
568
+ }'
569
+ ```
570
+
571
+ **Response:**
572
+ ```json
573
+ {
574
+ "access_token": "eyJhbGc...",
575
+ "token_type": "bearer",
576
+ "user": {
577
+ "id": 1,
578
+ "email": "john@example.com"
579
+ }
580
+ }
581
+ ```
582
+
583
+ #### 2. Create Task
584
+
585
+ ```bash
586
+ curl -X POST http://localhost:8001/api/tasks \
587
+ -H "Authorization: Bearer eyJhbGc..." \
588
+ -H "Content-Type: application/json" \
589
+ -d '{
590
+ "title": "Learn FastAPI",
591
+ "description": "Build a REST API with authentication"
592
+ }'
593
+ ```
594
+
595
+ **Response:**
596
+ ```json
597
+ {
598
+ "id": 1,
599
+ "title": "Learn FastAPI",
600
+ "description": "Build a REST API with authentication",
601
+ "completed": false,
602
+ "user_id": 1,
603
+ "created_at": "2026-02-03T14:00:00Z",
604
+ "updated_at": "2026-02-03T14:00:00Z"
605
+ }
606
+ ```
607
+
608
+ #### 3. List Tasks
609
+
610
+ ```bash
611
+ curl -X GET http://localhost:8001/api/tasks \
612
+ -H "Authorization: Bearer eyJhbGc..."
613
+ ```
614
+
615
+ **Response:**
616
+ ```json
617
+ [
618
+ {
619
+ "id": 1,
620
+ "title": "Learn FastAPI",
621
+ "description": "Build a REST API with authentication",
622
+ "completed": false,
623
+ "user_id": 1,
624
+ "created_at": "2026-02-03T14:00:00Z",
625
+ "updated_at": "2026-02-03T14:00:00Z"
626
+ }
627
+ ]
628
+ ```
629
+
630
+ #### 4. Mark Task Complete
631
+
632
+ ```bash
633
+ curl -X PATCH http://localhost:8001/api/tasks/1/complete \
634
+ -H "Authorization: Bearer eyJhbGc..."
635
+ ```
636
+
637
+ **Response:**
638
+ ```json
639
+ {
640
+ "id": 1,
641
+ "title": "Learn FastAPI",
642
+ "description": "Build a REST API with authentication",
643
+ "completed": true,
644
+ "user_id": 1,
645
+ "created_at": "2026-02-03T14:00:00Z",
646
+ "updated_at": "2026-02-03T14:30:00Z"
647
+ }
648
+ ```
649
+
650
+ #### 5. Update Task
651
+
652
+ ```bash
653
+ curl -X PUT http://localhost:8001/api/tasks/1 \
654
+ -H "Authorization: Bearer eyJhbGc..." \
655
+ -H "Content-Type: application/json" \
656
+ -d '{
657
+ "title": "Master FastAPI",
658
+ "description": "Build production-ready APIs"
659
+ }'
660
+ ```
661
+
662
+ #### 6. Delete Task
663
+
664
+ ```bash
665
+ curl -X DELETE http://localhost:8001/api/tasks/1 \
666
+ -H "Authorization: Bearer eyJhbGc..."
667
+ ```
668
+
669
+ **Response:**
670
+ ```json
671
+ {
672
+ "message": "Task deleted successfully"
673
+ }
674
+ ```
675
+
676
+ ---
677
+
678
+ ### JavaScript/TypeScript Example
679
+
680
+ ```typescript
681
+ // API Client Configuration
682
+ const API_BASE_URL = 'http://localhost:8001';
683
+ let authToken: string | null = null;
684
+
685
+ // Signup
686
+ async function signup(email: string, password: string) {
687
+ const response = await fetch(`${API_BASE_URL}/api/auth/signup`, {
688
+ method: 'POST',
689
+ headers: { 'Content-Type': 'application/json' },
690
+ body: JSON.stringify({ email, password })
691
+ });
692
+
693
+ const data = await response.json();
694
+ authToken = data.access_token;
695
+ return data;
696
+ }
697
+
698
+ // Create Task
699
+ async function createTask(title: string, description?: string) {
700
+ const response = await fetch(`${API_BASE_URL}/api/tasks`, {
701
+ method: 'POST',
702
+ headers: {
703
+ 'Content-Type': 'application/json',
704
+ 'Authorization': `Bearer ${authToken}`
705
+ },
706
+ body: JSON.stringify({ title, description })
707
+ });
708
+
709
+ return response.json();
710
+ }
711
+
712
+ // List Tasks
713
+ async function listTasks() {
714
+ const response = await fetch(`${API_BASE_URL}/api/tasks`, {
715
+ headers: {
716
+ 'Authorization': `Bearer ${authToken}`
717
+ }
718
+ });
719
+
720
+ return response.json();
721
+ }
722
+
723
+ // Toggle Task Completion
724
+ async function toggleTaskComplete(taskId: number) {
725
+ const response = await fetch(`${API_BASE_URL}/api/tasks/${taskId}/complete`, {
726
+ method: 'PATCH',
727
+ headers: {
728
+ 'Authorization': `Bearer ${authToken}`
729
+ }
730
+ });
731
+
732
+ return response.json();
733
+ }
734
+
735
+ // Delete Task
736
+ async function deleteTask(taskId: number) {
737
+ const response = await fetch(`${API_BASE_URL}/api/tasks/${taskId}`, {
738
+ method: 'DELETE',
739
+ headers: {
740
+ 'Authorization': `Bearer ${authToken}`
741
+ }
742
+ });
743
+
744
+ return response.json();
745
+ }
746
+ ```
747
+
748
+ ---
749
+
750
+ ## Testing the API
751
+
752
+ ### Using Swagger UI
753
+
754
+ FastAPI provides interactive API documentation:
755
+
756
+ 1. Start the backend server
757
+ 2. Open browser to: http://localhost:8001/docs
758
+ 3. Click "Authorize" button
759
+ 4. Enter JWT token from signup/signin response
760
+ 5. Test endpoints interactively
761
+
762
+ ### Using Postman
763
+
764
+ 1. Import the API endpoints
765
+ 2. Set `Authorization` header: `Bearer <token>`
766
+ 3. Set `Content-Type` header: `application/json`
767
+ 4. Test each endpoint
768
+
769
+ ### Using curl
770
+
771
+ See examples above for complete curl commands.
772
+
773
+ ---
774
+
775
+ ## Rate Limiting & Performance
776
+
777
+ **Current Implementation:**
778
+ - No rate limiting (suitable for development/hackathon)
779
+ - Database connection pooling via Neon PostgreSQL
780
+ - Async/await for non-blocking I/O
781
+
782
+ **Production Recommendations:**
783
+ - Add rate limiting middleware
784
+ - Implement request caching
785
+ - Add database query optimization
786
+ - Monitor API performance metrics
787
+
788
+ ---
789
+
790
+ ## Support & Troubleshooting
791
+
792
+ **Common Issues:**
793
+
794
+ 1. **401 Unauthorized on all requests**
795
+ - Check token is included in Authorization header
796
+ - Verify token format: `Bearer <token>`
797
+ - Ensure JWT_SECRET matches between backend and frontend
798
+
799
+ 2. **403 Forbidden when accessing task**
800
+ - Task belongs to another user
801
+ - Verify you're using correct user's token
802
+
803
+ 3. **CORS errors from frontend**
804
+ - Check CORS_ORIGINS in backend .env
805
+ - Ensure frontend URL is in allowed origins list
806
+
807
+ 4. **Database connection errors**
808
+ - Verify DATABASE_URL in .env
809
+ - Check Neon PostgreSQL is accessible
810
+ - Test connection with database client
811
+
812
+ ---
813
+
814
+ ## API Versioning
815
+
816
+ **Current Version:** v1.0.0
817
+
818
+ **Endpoint Prefix:** `/api/`
819
+
820
+ **Future Versioning Strategy:**
821
+ - Breaking changes will use new prefix: `/api/v2/`
822
+ - Current endpoints will remain stable
823
+ - Deprecation notices will be provided in advance
824
+
825
+ ---
826
+
827
+ ## Changelog
828
+
829
+ ### v1.0.0 (2026-02-03)
830
+ - Initial release
831
+ - User authentication (signup, signin)
832
+ - Task CRUD operations
833
+ - Task completion tracking
834
+ - JWT-based security
835
+ - Complete data isolation
836
+
837
+ ---
838
+
839
+ **Last Updated:** 2026-02-03
840
+ **Maintained By:** KIro Todo Development Team
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend Dockerfile
2
+ FROM python:3.11-slim
3
+
4
+ # Working directory set karein
5
+ WORKDIR /app
6
+
7
+ # System dependencies
8
+ RUN apt-get update && apt-get install -y \
9
+ gcc \
10
+ postgresql-client \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Requirements copy aur install karein
14
+ COPY requirements.txt .
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+ # Baaki saara code copy karein
18
+ COPY . .
19
+
20
+ # Hugging Face ke liye Permissions (User 1000)
21
+ RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
22
+ USER appuser
23
+
24
+ # Hugging Face ke liye PORT 7860 lazmi hai
25
+ EXPOSE 7860
26
+
27
+ # Health check (Port 7860 ke saath)
28
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
29
+ CMD python -c "import requests; requests.get('http://localhost:7860/health')"
30
+
31
+ # Run application (Yahan main:app check karlein)
32
+ CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "7860"]
alembic.ini ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts.
5
+ # this is typically a path given in POSIX (e.g. forward slashes)
6
+ # format, relative to the token %(here)s which refers to the location of this
7
+ # ini file
8
+ script_location = %(here)s/alembic
9
+
10
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
+ # Uncomment the line below if you want the files to be prepended with date and time
12
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
+ # for all available tokens
14
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
+
16
+ # sys.path path, will be prepended to sys.path if present.
17
+ # defaults to the current working directory. for multiple paths, the path separator
18
+ # is defined by "path_separator" below.
19
+ prepend_sys_path = .
20
+
21
+
22
+ # timezone to use when rendering the date within the migration file
23
+ # as well as the filename.
24
+ # If specified, requires the tzdata library which can be installed by adding
25
+ # `alembic[tz]` to the pip requirements.
26
+ # string value is passed to ZoneInfo()
27
+ # leave blank for localtime
28
+ # timezone =
29
+
30
+ # max length of characters to apply to the "slug" field
31
+ # truncate_slug_length = 40
32
+
33
+ # set to 'true' to run the environment during
34
+ # the 'revision' command, regardless of autogenerate
35
+ # revision_environment = false
36
+
37
+ # set to 'true' to allow .pyc and .pyo files without
38
+ # a source .py file to be detected as revisions in the
39
+ # versions/ directory
40
+ # sourceless = false
41
+
42
+ # version location specification; This defaults
43
+ # to <script_location>/versions. When using multiple version
44
+ # directories, initial revisions must be specified with --version-path.
45
+ # The path separator used here should be the separator specified by "path_separator"
46
+ # below.
47
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
48
+
49
+ # path_separator; This indicates what character is used to split lists of file
50
+ # paths, including version_locations and prepend_sys_path within configparser
51
+ # files such as alembic.ini.
52
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
53
+ # to provide os-dependent path splitting.
54
+ #
55
+ # Note that in order to support legacy alembic.ini files, this default does NOT
56
+ # take place if path_separator is not present in alembic.ini. If this
57
+ # option is omitted entirely, fallback logic is as follows:
58
+ #
59
+ # 1. Parsing of the version_locations option falls back to using the legacy
60
+ # "version_path_separator" key, which if absent then falls back to the legacy
61
+ # behavior of splitting on spaces and/or commas.
62
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
63
+ # behavior of splitting on spaces, commas, or colons.
64
+ #
65
+ # Valid values for path_separator are:
66
+ #
67
+ # path_separator = :
68
+ # path_separator = ;
69
+ # path_separator = space
70
+ # path_separator = newline
71
+ #
72
+ # Use os.pathsep. Default configuration used for new projects.
73
+ path_separator = os
74
+
75
+ # set to 'true' to search source files recursively
76
+ # in each "version_locations" directory
77
+ # new in Alembic version 1.10
78
+ # recursive_version_locations = false
79
+
80
+ # the output encoding used when revision files
81
+ # are written from script.py.mako
82
+ # output_encoding = utf-8
83
+
84
+ # database URL. This is consumed by the user-maintained env.py script only.
85
+ # other means of configuring database URLs may be customized within the env.py
86
+ # file.
87
+ sqlalchemy.url = driver://user:pass@localhost/dbname
88
+
89
+
90
+ [post_write_hooks]
91
+ # post_write_hooks defines scripts or Python functions that are run
92
+ # on newly generated revision scripts. See the documentation for further
93
+ # detail and examples
94
+
95
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
96
+ # hooks = black
97
+ # black.type = console_scripts
98
+ # black.entrypoint = black
99
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
100
+
101
+ # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
102
+ # hooks = ruff
103
+ # ruff.type = module
104
+ # ruff.module = ruff
105
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
106
+
107
+ # Alternatively, use the exec runner to execute a binary found on your PATH
108
+ # hooks = ruff
109
+ # ruff.type = exec
110
+ # ruff.executable = ruff
111
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
112
+
113
+ # Logging configuration. This is also consumed by the user-maintained
114
+ # env.py script only.
115
+ [loggers]
116
+ keys = root,sqlalchemy,alembic
117
+
118
+ [handlers]
119
+ keys = console
120
+
121
+ [formatters]
122
+ keys = generic
123
+
124
+ [logger_root]
125
+ level = WARNING
126
+ handlers = console
127
+ qualname =
128
+
129
+ [logger_sqlalchemy]
130
+ level = WARNING
131
+ handlers =
132
+ qualname = sqlalchemy.engine
133
+
134
+ [logger_alembic]
135
+ level = INFO
136
+ handlers =
137
+ qualname = alembic
138
+
139
+ [handler_console]
140
+ class = StreamHandler
141
+ args = (sys.stderr,)
142
+ level = NOTSET
143
+ formatter = generic
144
+
145
+ [formatter_generic]
146
+ format = %(levelname)-5.5s [%(name)s] %(message)s
147
+ datefmt = %H:%M:%S
alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration.
alembic/env.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+ import sys
3
+ import os
4
+
5
+ from sqlalchemy import engine_from_config
6
+ from sqlalchemy import pool
7
+
8
+ from alembic import context
9
+
10
+ # Add parent directory to path to import src modules
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
12
+
13
+ # Import SQLModel and models
14
+ from sqlmodel import SQLModel
15
+ from src.config import settings
16
+ from src.models import User, Task, Conversation, Message
17
+
18
+ # this is the Alembic Config object, which provides
19
+ # access to the values within the .ini file in use.
20
+ config = context.config
21
+
22
+ # Override sqlalchemy.url with DATABASE_URL from environment
23
+ config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
24
+
25
+ # Interpret the config file for Python logging.
26
+ # This line sets up loggers basically.
27
+ if config.config_file_name is not None:
28
+ fileConfig(config.config_file_name)
29
+
30
+ # add your model's MetaData object here
31
+ # for 'autogenerate' support
32
+ # Use SQLModel metadata for autogenerate
33
+ target_metadata = SQLModel.metadata
34
+
35
+ # other values from the config, defined by the needs of env.py,
36
+ # can be acquired:
37
+ # my_important_option = config.get_main_option("my_important_option")
38
+ # ... etc.
39
+
40
+
41
+ def run_migrations_offline() -> None:
42
+ """Run migrations in 'offline' mode.
43
+
44
+ This configures the context with just a URL
45
+ and not an Engine, though an Engine is acceptable
46
+ here as well. By skipping the Engine creation
47
+ we don't even need a DBAPI to be available.
48
+
49
+ Calls to context.execute() here emit the given string to the
50
+ script output.
51
+
52
+ """
53
+ url = config.get_main_option("sqlalchemy.url")
54
+ context.configure(
55
+ url=url,
56
+ target_metadata=target_metadata,
57
+ literal_binds=True,
58
+ dialect_opts={"paramstyle": "named"},
59
+ )
60
+
61
+ with context.begin_transaction():
62
+ context.run_migrations()
63
+
64
+
65
+ def run_migrations_online() -> None:
66
+ """Run migrations in 'online' mode.
67
+
68
+ In this scenario we need to create an Engine
69
+ and associate a connection with the context.
70
+
71
+ """
72
+ connectable = engine_from_config(
73
+ config.get_section(config.config_ini_section, {}),
74
+ prefix="sqlalchemy.",
75
+ poolclass=pool.NullPool,
76
+ )
77
+
78
+ with connectable.connect() as connection:
79
+ context.configure(
80
+ connection=connection, target_metadata=target_metadata
81
+ )
82
+
83
+ with context.begin_transaction():
84
+ context.run_migrations()
85
+
86
+
87
+ if context.is_offline_mode():
88
+ run_migrations_offline()
89
+ else:
90
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
alembic/versions/78c5de432ae1_add_sequence_number_to_messages_and_.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add sequence_number to messages and create tool_call_logs table
2
+
3
+ Revision ID: 78c5de432ae1
4
+ Revises: f00ef5a9ad43
5
+ Create Date: 2026-02-04 01:56:44.861631
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '78c5de432ae1'
16
+ down_revision: Union[str, Sequence[str], None] = 'f00ef5a9ad43'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+ op.create_table('tool_call_logs',
25
+ sa.Column('id', sa.Integer(), nullable=False),
26
+ sa.Column('message_id', sa.Integer(), nullable=True),
27
+ sa.Column('conversation_id', sa.Integer(), nullable=False),
28
+ sa.Column('user_id', sa.Integer(), nullable=False),
29
+ sa.Column('tool_name', sa.String(length=50), nullable=False),
30
+ sa.Column('arguments', sa.JSON(), nullable=True),
31
+ sa.Column('result', sa.JSON(), nullable=True),
32
+ sa.Column('status', sa.Enum('SUCCESS', 'ERROR', name='toolcallstatus'), nullable=False),
33
+ sa.Column('execution_time_ms', sa.Integer(), nullable=False),
34
+ sa.Column('created_at', sa.DateTime(), nullable=False),
35
+ sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
36
+ sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ondelete='SET NULL'),
37
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
38
+ sa.PrimaryKeyConstraint('id')
39
+ )
40
+ op.create_index(op.f('ix_tool_call_logs_conversation_id'), 'tool_call_logs', ['conversation_id'], unique=False)
41
+ op.create_index(op.f('ix_tool_call_logs_created_at'), 'tool_call_logs', ['created_at'], unique=False)
42
+ op.create_index(op.f('ix_tool_call_logs_tool_name'), 'tool_call_logs', ['tool_name'], unique=False)
43
+ op.create_index(op.f('ix_tool_call_logs_user_id'), 'tool_call_logs', ['user_id'], unique=False)
44
+ op.add_column('messages', sa.Column('sequence_number', sa.Integer(), nullable=False))
45
+ op.create_index(op.f('ix_messages_sequence_number'), 'messages', ['sequence_number'], unique=False)
46
+ # ### end Alembic commands ###
47
+
48
+
49
+ def downgrade() -> None:
50
+ """Downgrade schema."""
51
+ # ### commands auto generated by Alembic - please adjust! ###
52
+ op.drop_index(op.f('ix_messages_sequence_number'), table_name='messages')
53
+ op.drop_column('messages', 'sequence_number')
54
+ op.drop_index(op.f('ix_tool_call_logs_user_id'), table_name='tool_call_logs')
55
+ op.drop_index(op.f('ix_tool_call_logs_tool_name'), table_name='tool_call_logs')
56
+ op.drop_index(op.f('ix_tool_call_logs_created_at'), table_name='tool_call_logs')
57
+ op.drop_index(op.f('ix_tool_call_logs_conversation_id'), table_name='tool_call_logs')
58
+ op.drop_table('tool_call_logs')
59
+ # ### end Alembic commands ###
alembic/versions/f00ef5a9ad43_add_conversations_and_messages_tables.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add conversations and messages tables
2
+
3
+ Revision ID: f00ef5a9ad43
4
+ Revises:
5
+ Create Date: 2026-02-03 11:50:29.285248
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ import sqlmodel
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = 'f00ef5a9ad43'
17
+ down_revision: Union[str, Sequence[str], None] = None
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.create_table('conversations',
26
+ sa.Column('id', sa.Integer(), nullable=False),
27
+ sa.Column('user_id', sa.Integer(), nullable=False),
28
+ sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
29
+ sa.Column('created_at', sa.DateTime(), nullable=False),
30
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
31
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
32
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
33
+ sa.PrimaryKeyConstraint('id')
34
+ )
35
+ op.create_index(op.f('ix_conversations_created_at'), 'conversations', ['created_at'], unique=False)
36
+ op.create_index(op.f('ix_conversations_deleted_at'), 'conversations', ['deleted_at'], unique=False)
37
+ op.create_index(op.f('ix_conversations_updated_at'), 'conversations', ['updated_at'], unique=False)
38
+ op.create_index(op.f('ix_conversations_user_id'), 'conversations', ['user_id'], unique=False)
39
+ op.create_table('messages',
40
+ sa.Column('id', sa.Integer(), nullable=False),
41
+ sa.Column('conversation_id', sa.Integer(), nullable=False),
42
+ sa.Column('role', sa.Enum('USER', 'ASSISTANT', name='messagerole'), nullable=False),
43
+ sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
44
+ sa.Column('tool_calls', sa.JSON(), nullable=True),
45
+ sa.Column('created_at', sa.DateTime(), nullable=False),
46
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
47
+ sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ),
48
+ sa.PrimaryKeyConstraint('id')
49
+ )
50
+ op.create_index(op.f('ix_messages_conversation_id'), 'messages', ['conversation_id'], unique=False)
51
+ op.create_index(op.f('ix_messages_created_at'), 'messages', ['created_at'], unique=False)
52
+ op.create_index(op.f('ix_messages_deleted_at'), 'messages', ['deleted_at'], unique=False)
53
+ # ### end Alembic commands ###
54
+
55
+
56
+ def downgrade() -> None:
57
+ """Downgrade schema."""
58
+ # ### commands auto generated by Alembic - please adjust! ###
59
+ op.drop_index(op.f('ix_messages_deleted_at'), table_name='messages')
60
+ op.drop_index(op.f('ix_messages_created_at'), table_name='messages')
61
+ op.drop_index(op.f('ix_messages_conversation_id'), table_name='messages')
62
+ op.drop_table('messages')
63
+ op.drop_index(op.f('ix_conversations_user_id'), table_name='conversations')
64
+ op.drop_index(op.f('ix_conversations_updated_at'), table_name='conversations')
65
+ op.drop_index(op.f('ix_conversations_deleted_at'), table_name='conversations')
66
+ op.drop_index(op.f('ix_conversations_created_at'), table_name='conversations')
67
+ op.drop_table('conversations')
68
+ # ### end Alembic commands ###
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ sqlmodel
4
+ psycopg2-binary
5
+ python-jose[cryptography]
6
+ passlib[bcrypt]
7
+ python-dotenv
8
+ pydantic-settings
9
+ alembic
10
+ cohere
11
+ mcp
12
+ sqlalchemy
src/agents/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agents package for AI-powered task management.
3
+
4
+ This package contains the TaskAgent class and configuration for
5
+ handling natural language task management conversations.
6
+ """
7
+ from .task_agent import TaskAgent
8
+ from .agent_config import TASK_AGENT_SYSTEM_PROMPT, AGENT_CONFIG, TOOL_CONFIG
9
+
10
+ __all__ = ["TaskAgent", "TASK_AGENT_SYSTEM_PROMPT", "AGENT_CONFIG", "TOOL_CONFIG"]
src/agents/agent_config.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent configuration including system prompts and behavior settings.
3
+
4
+ This module defines the AI agent's personality, capabilities, and instructions.
5
+ """
6
+
7
+ # System prompt for the task management agent
8
+ TASK_AGENT_SYSTEM_PROMPT = """You are a helpful task management assistant for KIro Todo application.
9
+
10
+ Your role is to help users manage their tasks through natural language conversation. You have access to the following tools:
11
+
12
+ 1. **list_tasks** - View all tasks (with optional filtering by completion status)
13
+ 2. **create_task** - Create a new task with title and optional description
14
+ 3. **update_task** - Modify an existing task's title or description
15
+ 4. **delete_task** - Permanently remove a task
16
+ 5. **get_task** - Retrieve details of a specific task by ID
17
+ 6. **mark_complete** - Toggle task completion status or set to specific value
18
+
19
+ ## Guidelines:
20
+
21
+ ### Communication Style
22
+ - Be friendly, concise, and helpful
23
+ - Use natural conversational language
24
+ - Confirm actions clearly (e.g., "I've created the task 'Buy groceries'")
25
+ - Ask for clarification when user intent is ambiguous
26
+
27
+ ### Task Operations
28
+ - When creating tasks, extract the title from the user's message
29
+ - Include descriptions if the user provides additional details
30
+ - For updates and deletions, you may need to list tasks first to find the correct task ID
31
+ - Always confirm destructive operations (like delete) with clear feedback
32
+ - Present task lists in a readable format
33
+
34
+ ### Error Handling
35
+ - If a tool call fails, explain the issue in user-friendly terms
36
+ - Suggest alternatives when operations cannot be completed
37
+ - Never expose technical details or error codes to users
38
+
39
+ ### User Privacy
40
+ - You can only access tasks belonging to the authenticated user
41
+ - Never mention or reference other users' data
42
+ - All operations are automatically scoped to the current user
43
+
44
+ ### Examples:
45
+
46
+ **User**: "Show me my tasks"
47
+ **You**: Use list_tasks tool, then present results like:
48
+ "Here are your tasks:
49
+ 1. Buy groceries (incomplete)
50
+ 2. Call mom (incomplete)
51
+ 3. Finish report (completed)"
52
+
53
+ **User**: "Add a task to buy milk"
54
+ **You**: Use create_task with title="Buy milk", then confirm:
55
+ "I've created the task 'Buy milk' for you."
56
+
57
+ **User**: "Mark task 1 as done"
58
+ **You**: Use mark_complete with task_id=1, completed=true, then confirm:
59
+ "Great! I've marked 'Buy groceries' as complete."
60
+
61
+ **User**: "Delete the milk task"
62
+ **You**: Use list_tasks to find the task ID, then delete_task, then confirm:
63
+ "I've deleted the task 'Buy milk'."
64
+
65
+ Remember: Always be helpful, clear, and confirm your actions!
66
+ """
67
+
68
+ # Agent configuration settings
69
+ AGENT_CONFIG = {
70
+ "model": None, # Will be set from settings.OPENAI_MODEL
71
+ "temperature": 0.7, # Balanced between creativity and consistency
72
+ "max_tokens": 500, # Reasonable response length
73
+ "top_p": 1.0,
74
+ "frequency_penalty": 0.0,
75
+ "presence_penalty": 0.0,
76
+ }
77
+
78
+ # Tool execution settings
79
+ TOOL_CONFIG = {
80
+ "max_retries": 2, # Retry failed tool calls up to 2 times
81
+ "timeout_seconds": 10, # Tool execution timeout
82
+ "log_all_calls": True, # Log all tool invocations for audit
83
+ }
src/agents/task_agent.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TaskAgent class for handling AI-powered task management conversations.
3
+
4
+ This module implements the core agent that processes user messages and
5
+ executes task operations through MCP tools.
6
+ """
7
+ from openai import AsyncOpenAI
8
+ from typing import List, Dict, Any, Optional
9
+ from ..config import settings
10
+ from .agent_config import TASK_AGENT_SYSTEM_PROMPT, AGENT_CONFIG
11
+
12
+
13
+ class TaskAgent:
14
+ """
15
+ AI agent for task management conversations.
16
+
17
+ This agent uses OpenAI's API (or OpenRouter) with function calling to process natural
18
+ language requests and execute task operations through MCP tools.
19
+ """
20
+
21
+ def __init__(self):
22
+ """
23
+ Initialize the TaskAgent with OpenAI client.
24
+
25
+ The agent is configured with:
26
+ - OpenAI API key from settings (can be OpenRouter key)
27
+ - System prompt defining agent behavior
28
+ - Model configuration (temperature, max_tokens, etc.)
29
+ - Empty tools list (tools registered later via register_tools)
30
+ """
31
+ # Configure client for OpenRouter if base URL is provided
32
+ client_kwargs = {"api_key": settings.OPENAI_API_KEY}
33
+ if hasattr(settings, 'OPENAI_BASE_URL') and settings.OPENAI_BASE_URL:
34
+ client_kwargs["base_url"] = settings.OPENAI_BASE_URL
35
+
36
+ self.client = AsyncOpenAI(**client_kwargs)
37
+ self.model = settings.OPENAI_MODEL
38
+ self.system_prompt = TASK_AGENT_SYSTEM_PROMPT
39
+ self.temperature = AGENT_CONFIG["temperature"]
40
+ self.max_tokens = AGENT_CONFIG["max_tokens"]
41
+ self.tools: List[Dict[str, Any]] = []
42
+
43
+ def register_tools(self, tools: List[Dict[str, Any]]) -> None:
44
+ """
45
+ Register MCP tools with the agent.
46
+
47
+ Tools should be in OpenAI function calling format:
48
+ {
49
+ "type": "function",
50
+ "function": {
51
+ "name": "tool_name",
52
+ "description": "Tool description",
53
+ "parameters": {...}
54
+ }
55
+ }
56
+
57
+ Args:
58
+ tools: List of tool definitions in OpenAI format
59
+ """
60
+ self.tools = tools
61
+
62
+ async def process_message(
63
+ self,
64
+ message: str,
65
+ conversation_history: List[Dict[str, str]],
66
+ tool_executor: Optional[Any] = None
67
+ ) -> Dict[str, Any]:
68
+ """
69
+ Process a user message and generate a response.
70
+
71
+ This method:
72
+ 1. Constructs the full message history with system prompt
73
+ 2. Calls OpenAI API with tool definitions
74
+ 3. Handles tool calls if the agent decides to use them
75
+ 4. Returns the final response with any tool call metadata
76
+
77
+ Args:
78
+ message: The user's message to process
79
+ conversation_history: Previous messages in the conversation
80
+ Format: [{"role": "user"|"assistant", "content": "..."}]
81
+ tool_executor: Optional callable to execute tool calls
82
+ Should accept (tool_name, arguments) and return result
83
+
84
+ Returns:
85
+ Dict containing:
86
+ - content: The assistant's response text
87
+ - tool_calls: List of tool calls made (if any)
88
+ - finish_reason: Why the model stopped generating
89
+
90
+ Raises:
91
+ Exception: If OpenAI API call fails
92
+ """
93
+ # Build messages array with system prompt
94
+ messages = [
95
+ {"role": "system", "content": self.system_prompt}
96
+ ]
97
+
98
+ # Add conversation history
99
+ messages.extend(conversation_history)
100
+
101
+ # Add current user message
102
+ messages.append({"role": "user", "content": message})
103
+
104
+ # Call OpenAI API
105
+ response = await self.client.chat.completions.create(
106
+ model=self.model,
107
+ messages=messages,
108
+ tools=self.tools if self.tools else None,
109
+ temperature=self.temperature,
110
+ max_tokens=self.max_tokens
111
+ )
112
+
113
+ # Extract response
114
+ assistant_message = response.choices[0].message
115
+
116
+ # Handle tool calls if present
117
+ tool_calls_data = []
118
+ if assistant_message.tool_calls and tool_executor:
119
+ for tool_call in assistant_message.tool_calls:
120
+ tool_name = tool_call.function.name
121
+ tool_args = tool_call.function.arguments
122
+
123
+ # Execute tool
124
+ try:
125
+ import json
126
+ args_dict = json.loads(tool_args)
127
+ result = await tool_executor(tool_name, args_dict)
128
+
129
+ tool_calls_data.append({
130
+ "id": tool_call.id,
131
+ "name": tool_name,
132
+ "arguments": args_dict,
133
+ "result": result
134
+ })
135
+ except Exception as e:
136
+ tool_calls_data.append({
137
+ "id": tool_call.id,
138
+ "name": tool_name,
139
+ "arguments": tool_args,
140
+ "error": str(e)
141
+ })
142
+
143
+ # If tools were called, make another API call with tool results
144
+ # to get the final response
145
+ messages.append({
146
+ "role": "assistant",
147
+ "content": assistant_message.content,
148
+ "tool_calls": [
149
+ {
150
+ "id": tc["id"],
151
+ "type": "function",
152
+ "function": {
153
+ "name": tc["name"],
154
+ "arguments": json.dumps(tc["arguments"])
155
+ }
156
+ }
157
+ for tc in tool_calls_data
158
+ ]
159
+ })
160
+
161
+ # Add tool results
162
+ for tc in tool_calls_data:
163
+ messages.append({
164
+ "role": "tool",
165
+ "tool_call_id": tc["id"],
166
+ "content": json.dumps(tc.get("result", {"error": tc.get("error")}))
167
+ })
168
+
169
+ # Get final response
170
+ final_response = await self.client.chat.completions.create(
171
+ model=self.model,
172
+ messages=messages,
173
+ temperature=self.temperature,
174
+ max_tokens=self.max_tokens
175
+ )
176
+
177
+ final_message = final_response.choices[0].message
178
+
179
+ return {
180
+ "content": final_message.content,
181
+ "tool_calls": tool_calls_data,
182
+ "finish_reason": final_response.choices[0].finish_reason
183
+ }
184
+
185
+ # No tool calls - return direct response
186
+ return {
187
+ "content": assistant_message.content,
188
+ "tool_calls": [],
189
+ "finish_reason": response.choices[0].finish_reason
190
+ }
191
+
192
+ async def health_check(self) -> bool:
193
+ """
194
+ Verify the agent can communicate with OpenAI API.
195
+
196
+ Returns:
197
+ True if API is accessible, False otherwise
198
+ """
199
+ try:
200
+ await self.client.models.retrieve(self.model)
201
+ return True
202
+ except Exception:
203
+ return False
src/api/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """API routers package."""
2
+ from .tasks import router as tasks_router
3
+ from .auth import router as auth_router
4
+ from .chat import router as chat_router
5
+
6
+ __all__ = ["tasks_router", "auth_router", "chat_router"]
src/api/auth.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, status, Depends
2
+ from pydantic import BaseModel, EmailStr
3
+ from sqlmodel import Session, select
4
+ from passlib.context import CryptContext
5
+ from jose import jwt
6
+ from datetime import datetime, timedelta
7
+ from ..models.user import User
8
+ from ..database import get_session
9
+ from ..config import settings
10
+
11
+ router = APIRouter(prefix="/api/auth", tags=["Authentication"])
12
+
13
+ # Password hashing
14
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
15
+
16
+
17
+ class SignupRequest(BaseModel):
18
+ email: EmailStr
19
+ password: str
20
+
21
+
22
+ class SigninRequest(BaseModel):
23
+ email: EmailStr
24
+ password: str
25
+
26
+
27
+ class AuthResponse(BaseModel):
28
+ access_token: str
29
+ token_type: str = "bearer"
30
+ user: dict
31
+
32
+
33
+ def hash_password(password: str) -> str:
34
+ """Hash a password using bcrypt."""
35
+ return pwd_context.hash(password)
36
+
37
+
38
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
39
+ """Verify a password against its hash."""
40
+ return pwd_context.verify(plain_password, hashed_password)
41
+
42
+
43
+ def create_access_token(user_id: int, email: str) -> str:
44
+ """Create a JWT access token."""
45
+ expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRATION_HOURS)
46
+ to_encode = {
47
+ "sub": str(user_id),
48
+ "email": email,
49
+ "exp": expire
50
+ }
51
+ encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
52
+ return encoded_jwt
53
+
54
+
55
+ @router.post("/signup", response_model=AuthResponse)
56
+ async def signup(
57
+ request: SignupRequest,
58
+ session: Session = Depends(get_session)
59
+ ):
60
+ """
61
+ Register a new user.
62
+
63
+ - Validates email uniqueness
64
+ - Hashes password
65
+ - Creates user in database
66
+ - Returns JWT token
67
+ """
68
+ # Check if user already exists
69
+ existing_user = session.exec(
70
+ select(User).where(User.email == request.email)
71
+ ).first()
72
+
73
+ if existing_user:
74
+ raise HTTPException(
75
+ status_code=status.HTTP_400_BAD_REQUEST,
76
+ detail="Email already registered"
77
+ )
78
+
79
+ # Create new user
80
+ hashed_password = hash_password(request.password)
81
+ new_user = User(
82
+ email=request.email,
83
+ password_hash=hashed_password
84
+ )
85
+
86
+ session.add(new_user)
87
+ session.commit()
88
+ session.refresh(new_user)
89
+
90
+ # Create access token
91
+ access_token = create_access_token(new_user.id, new_user.email)
92
+
93
+ return AuthResponse(
94
+ access_token=access_token,
95
+ user={
96
+ "id": new_user.id,
97
+ "email": new_user.email
98
+ }
99
+ )
100
+
101
+
102
+ @router.post("/signin", response_model=AuthResponse)
103
+ async def signin(
104
+ request: SigninRequest,
105
+ session: Session = Depends(get_session)
106
+ ):
107
+ """
108
+ Authenticate a user.
109
+
110
+ - Validates credentials
111
+ - Returns JWT token
112
+ """
113
+ # Find user by email
114
+ user = session.exec(
115
+ select(User).where(User.email == request.email)
116
+ ).first()
117
+
118
+ if not user:
119
+ raise HTTPException(
120
+ status_code=status.HTTP_401_UNAUTHORIZED,
121
+ detail="Invalid email or password"
122
+ )
123
+
124
+ # Verify password
125
+ if not verify_password(request.password, user.password_hash):
126
+ raise HTTPException(
127
+ status_code=status.HTTP_401_UNAUTHORIZED,
128
+ detail="Invalid email or password"
129
+ )
130
+
131
+ # Create access token
132
+ access_token = create_access_token(user.id, user.email)
133
+
134
+ return AuthResponse(
135
+ access_token=access_token,
136
+ user={
137
+ "id": user.id,
138
+ "email": user.email
139
+ }
140
+ )
src/api/chat.py ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat API Router
3
+
4
+ Provides conversational task management through natural language.
5
+ Users send messages, and the AI agent responds with task operations via MCP tools.
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
9
+ from fastapi.responses import StreamingResponse
10
+ from sqlmodel import Session, select
11
+ from typing import Dict, AsyncGenerator
12
+ from datetime import datetime
13
+ import json
14
+ import logging
15
+
16
+ from ..database import get_session
17
+ from ..middleware.auth import get_current_user
18
+ from ..middleware.rate_limit import rate_limit_middleware
19
+ from ..models.conversation import Conversation
20
+ from ..models.message import Message, MessageRole
21
+ from ..schemas.chat import ChatRequest, ChatResponse
22
+ from ..services.agent_service import AgentService
23
+
24
+
25
+ # Configure logger
26
+ logger = logging.getLogger(__name__)
27
+
28
+ router = APIRouter(prefix="/api", tags=["Chat"])
29
+
30
+
31
+ @router.post("/{user_id}/chat", response_model=ChatResponse, status_code=status.HTTP_200_OK)
32
+ async def chat(
33
+ user_id: int,
34
+ chat_request: ChatRequest,
35
+ http_request: Request,
36
+ response: Response,
37
+ session: Session = Depends(get_session),
38
+ current_user: Dict = Depends(get_current_user)
39
+ ):
40
+ """
41
+ Process conversational task management request.
42
+
43
+ This endpoint:
44
+ 1. Verifies JWT authentication and user_id match
45
+ 2. Loads or creates conversation
46
+ 3. Saves user message to database
47
+ 4. Processes message with AI agent
48
+ 5. Saves assistant response to database
49
+ 6. Returns response with tool call metadata
50
+
51
+ Args:
52
+ user_id: User identifier from URL (must match JWT)
53
+ request: ChatRequest with message and optional conversation_id
54
+ session: Database session dependency
55
+ current_user: Authenticated user from JWT token
56
+
57
+ Returns:
58
+ ChatResponse with conversation_id, message_id, assistant_message, and tool_calls
59
+
60
+ Raises:
61
+ HTTPException 400: Invalid message format
62
+ HTTPException 401: Missing or invalid JWT token
63
+ HTTPException 403: User_id mismatch or unauthorized conversation access
64
+ HTTPException 404: Conversation not found
65
+ HTTPException 500: Agent processing failure
66
+ """
67
+ # Log incoming chat request
68
+ logger.info(
69
+ f"Chat request received - user_id={user_id}, "
70
+ f"conversation_id={chat_request.conversation_id}, "
71
+ f"message_length={len(chat_request.message)}"
72
+ )
73
+
74
+ # Apply rate limiting
75
+ await rate_limit_middleware(http_request, user_id)
76
+
77
+ # Verify user_id matches JWT token
78
+ if current_user["user_id"] != user_id:
79
+ logger.warning(
80
+ f"Authorization failed - URL user_id={user_id} does not match "
81
+ f"JWT user_id={current_user['user_id']}"
82
+ )
83
+ raise HTTPException(
84
+ status_code=status.HTTP_403_FORBIDDEN,
85
+ detail="Permission denied",
86
+ headers={"X-Error-Details": "URL user_id does not match authenticated user"}
87
+ )
88
+
89
+ # Validate and sanitize message content
90
+ try:
91
+ from ..utils.validation import sanitize_message_content, validate_conversation_id
92
+ sanitized_message = sanitize_message_content(chat_request.message)
93
+ validated_conversation_id = validate_conversation_id(chat_request.conversation_id)
94
+ except ValueError as e:
95
+ raise HTTPException(
96
+ status_code=status.HTTP_400_BAD_REQUEST,
97
+ detail=str(e)
98
+ )
99
+
100
+ # Load or create conversation
101
+ conversation = None
102
+ if chat_request.conversation_id:
103
+ # Load existing conversation
104
+ logger.debug(f"Loading existing conversation_id={chat_request.conversation_id}")
105
+ conversation = session.get(Conversation, chat_request.conversation_id)
106
+
107
+ # Verify conversation exists
108
+ if not conversation:
109
+ logger.warning(f"Conversation not found - conversation_id={chat_request.conversation_id}")
110
+ raise HTTPException(
111
+ status_code=status.HTTP_404_NOT_FOUND,
112
+ detail="Conversation not found",
113
+ headers={"X-Error-Details": f"conversation_id {chat_request.conversation_id} does not exist"}
114
+ )
115
+
116
+ # Verify conversation belongs to authenticated user
117
+ if conversation.user_id != user_id:
118
+ logger.warning(
119
+ f"Unauthorized conversation access - conversation_id={chat_request.conversation_id}, "
120
+ f"owner_id={conversation.user_id}, requester_id={user_id}"
121
+ )
122
+ raise HTTPException(
123
+ status_code=status.HTTP_403_FORBIDDEN,
124
+ detail="Permission denied",
125
+ headers={"X-Error-Details": "Conversation belongs to different user"}
126
+ )
127
+
128
+ # Check if conversation is soft-deleted
129
+ if conversation.deleted_at is not None:
130
+ logger.warning(f"Attempted access to deleted conversation_id={chat_request.conversation_id}")
131
+ raise HTTPException(
132
+ status_code=status.HTTP_404_NOT_FOUND,
133
+ detail="Conversation not found",
134
+ headers={"X-Error-Details": "Conversation was deleted"}
135
+ )
136
+
137
+ logger.info(f"Loaded existing conversation_id={conversation.id}")
138
+ else:
139
+ # Create new conversation
140
+ logger.info(f"Creating new conversation for user_id={user_id}")
141
+ conversation = Conversation(
142
+ user_id=user_id,
143
+ title=None, # Can be auto-generated from first message later
144
+ created_at=datetime.utcnow(),
145
+ updated_at=datetime.utcnow()
146
+ )
147
+ session.add(conversation)
148
+ session.commit()
149
+ session.refresh(conversation)
150
+ logger.info(f"Created new conversation_id={conversation.id}")
151
+
152
+ # Calculate sequence number for the new message
153
+ message_count_stmt = select(Message).where(
154
+ Message.conversation_id == conversation.id,
155
+ Message.deleted_at.is_(None)
156
+ )
157
+ existing_message_count = len(session.exec(message_count_stmt).all())
158
+ next_sequence_number = existing_message_count + 1
159
+
160
+ # Save user message to database (before agent processing)
161
+ logger.debug(f"Persisting user message to conversation_id={conversation.id}, sequence={next_sequence_number}")
162
+ user_message = Message(
163
+ conversation_id=conversation.id,
164
+ role=MessageRole.USER,
165
+ content=chat_request.message,
166
+ tool_calls=None,
167
+ sequence_number=next_sequence_number,
168
+ created_at=datetime.utcnow()
169
+ )
170
+ session.add(user_message)
171
+ session.commit()
172
+ session.refresh(user_message)
173
+ logger.debug(f"User message persisted - message_id={user_message.id}")
174
+
175
+ # Load conversation history for agent context (last 20 messages for performance)
176
+ statement = (
177
+ select(Message)
178
+ .where(Message.conversation_id == conversation.id)
179
+ .where(Message.deleted_at.is_(None))
180
+ .order_by(Message.created_at.desc())
181
+ .limit(20)
182
+ )
183
+ history_messages = session.exec(statement).all()
184
+
185
+ # Reverse to get chronological order (oldest to newest)
186
+ history_messages = list(reversed(history_messages))
187
+
188
+ # Initialize agent service with user_id
189
+ agent_service = AgentService(user_id=user_id)
190
+
191
+ # Format conversation history for agent
192
+ conversation_history = agent_service.format_conversation_history(history_messages)
193
+
194
+ # Process message with agent (with timeout)
195
+ import asyncio
196
+ logger.info(
197
+ f"Processing message with agent - conversation_id={conversation.id}, "
198
+ f"history_length={len(conversation_history)}"
199
+ )
200
+
201
+ try:
202
+ agent_response = await asyncio.wait_for(
203
+ agent_service.process_message(
204
+ message=sanitized_message,
205
+ conversation_history=conversation_history[:-1] # Exclude the just-added user message
206
+ ),
207
+ timeout=30.0 # 30 second timeout
208
+ )
209
+
210
+ logger.info(
211
+ f"Agent processing completed - conversation_id={conversation.id}, "
212
+ f"tool_calls={len(agent_response.get('tool_calls', []) or [])}"
213
+ )
214
+
215
+ except asyncio.TimeoutError:
216
+ logger.error(
217
+ f"Agent processing timeout - conversation_id={conversation.id}, "
218
+ f"exceeded 30 second limit"
219
+ )
220
+ raise HTTPException(
221
+ status_code=status.HTTP_504_GATEWAY_TIMEOUT,
222
+ detail="Agent processing timeout - request took longer than 30 seconds"
223
+ )
224
+ except Exception as e:
225
+ # Log error and return 500 with user-friendly message
226
+ logger.error(
227
+ f"Agent processing error - conversation_id={conversation.id}, "
228
+ f"error={str(e)}",
229
+ exc_info=True
230
+ )
231
+
232
+ # User-friendly error messages for common OpenAI API failures
233
+ error_message = "Agent processing failed"
234
+ if "rate_limit" in str(e).lower():
235
+ error_message = "AI service is currently busy. Please try again in a moment."
236
+ elif "api_key" in str(e).lower() or "authentication" in str(e).lower():
237
+ error_message = "AI service configuration error. Please contact support."
238
+ elif "timeout" in str(e).lower():
239
+ error_message = "AI service is taking too long to respond. Please try again."
240
+ elif "connection" in str(e).lower():
241
+ error_message = "Unable to connect to AI service. Please try again."
242
+
243
+ raise HTTPException(
244
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
245
+ detail=error_message,
246
+ headers={"X-Error-Details": str(e)}
247
+ )
248
+
249
+ # Calculate sequence number for assistant response
250
+ assistant_sequence_number = next_sequence_number + 1
251
+
252
+ # Save assistant response to database (after agent processing)
253
+ logger.debug(f"Persisting assistant response to conversation_id={conversation.id}, sequence={assistant_sequence_number}")
254
+ assistant_message = Message(
255
+ conversation_id=conversation.id,
256
+ role=MessageRole.ASSISTANT,
257
+ content=agent_response.get("content", ""),
258
+ tool_calls=agent_response.get("tool_calls"),
259
+ sequence_number=assistant_sequence_number,
260
+ created_at=datetime.utcnow()
261
+ )
262
+ session.add(assistant_message)
263
+
264
+ # Update conversation timestamp
265
+ conversation.updated_at = datetime.utcnow()
266
+ session.add(conversation)
267
+
268
+ session.commit()
269
+ session.refresh(assistant_message)
270
+ logger.debug(f"Assistant response persisted - message_id={assistant_message.id}")
271
+
272
+ # Add rate limit headers to response
273
+ if hasattr(http_request, 'state') and hasattr(http_request.state, 'rate_limit_headers'):
274
+ for header_name, header_value in http_request.state.rate_limit_headers.items():
275
+ response.headers[header_name] = header_value
276
+
277
+ # Build response
278
+ logger.info(
279
+ f"Chat request completed successfully - conversation_id={conversation.id}, "
280
+ f"message_id={assistant_message.id}, user_id={user_id}"
281
+ )
282
+
283
+ return ChatResponse(
284
+ conversation_id=conversation.id,
285
+ message_id=assistant_message.id,
286
+ assistant_message=agent_response.get("content", ""),
287
+ tool_calls=agent_response.get("tool_calls"),
288
+ timestamp=assistant_message.created_at
289
+ )
290
+
291
+
292
+ @router.post("/{user_id}/chat/stream")
293
+ async def chat_stream(
294
+ user_id: int,
295
+ request: ChatRequest,
296
+ session: Session = Depends(get_session),
297
+ current_user: Dict = Depends(get_current_user)
298
+ ):
299
+ """
300
+ Stream chat response with Server-Sent Events (SSE).
301
+
302
+ Provides progressive response rendering for better user experience.
303
+ Streams agent response in chunks as it's generated.
304
+
305
+ Args:
306
+ user_id: User identifier from URL (must match JWT)
307
+ request: Chat request with message and optional conversation_id
308
+ session: Database session dependency
309
+ current_user: Authenticated user from JWT token
310
+
311
+ Returns:
312
+ StreamingResponse with text/event-stream content type
313
+
314
+ Raises:
315
+ HTTPException 400: Invalid message format
316
+ HTTPException 401: Missing or invalid JWT token
317
+ HTTPException 403: User_id mismatch or unauthorized conversation access
318
+ HTTPException 404: Conversation not found
319
+ HTTPException 500: Agent processing failure
320
+ """
321
+ # Verify user_id matches JWT token
322
+ if current_user["user_id"] != user_id:
323
+ raise HTTPException(
324
+ status_code=status.HTTP_403_FORBIDDEN,
325
+ detail="Permission denied"
326
+ )
327
+
328
+ async def event_generator() -> AsyncGenerator[str, None]:
329
+ """Generate SSE events for streaming response."""
330
+ try:
331
+ # Load or create conversation (same logic as non-streaming endpoint)
332
+ conversation = None
333
+ if request.conversation_id:
334
+ conversation = session.get(Conversation, request.conversation_id)
335
+ if not conversation or conversation.user_id != user_id:
336
+ yield f"data: {json.dumps({'error': 'Conversation not found', 'done': True})}\n\n"
337
+ return
338
+ if conversation.deleted_at is not None:
339
+ yield f"data: {json.dumps({'error': 'Conversation was deleted', 'done': True})}\n\n"
340
+ return
341
+ else:
342
+ conversation = Conversation(
343
+ user_id=user_id,
344
+ title=None,
345
+ created_at=datetime.utcnow(),
346
+ updated_at=datetime.utcnow()
347
+ )
348
+ session.add(conversation)
349
+ session.commit()
350
+ session.refresh(conversation)
351
+
352
+ # Calculate sequence number
353
+ message_count_stmt = select(Message).where(
354
+ Message.conversation_id == conversation.id,
355
+ Message.deleted_at.is_(None)
356
+ )
357
+ existing_message_count = len(session.exec(message_count_stmt).all())
358
+ user_seq = existing_message_count + 1
359
+
360
+ # Save user message
361
+ user_message = Message(
362
+ conversation_id=conversation.id,
363
+ role=MessageRole.USER,
364
+ content=request.message,
365
+ tool_calls=None,
366
+ sequence_number=user_seq,
367
+ created_at=datetime.utcnow()
368
+ )
369
+ session.add(user_message)
370
+ session.commit()
371
+
372
+ # Load conversation history
373
+ statement = (
374
+ select(Message)
375
+ .where(Message.conversation_id == conversation.id)
376
+ .where(Message.deleted_at.is_(None))
377
+ .order_by(Message.created_at.desc())
378
+ .limit(20)
379
+ )
380
+ history_messages = session.exec(statement).all()
381
+ history_messages = list(reversed(history_messages))
382
+
383
+ # Initialize agent service
384
+ agent_service = CohereAgentService(user_id=user_id)
385
+ conversation_history = agent_service.format_conversation_history(history_messages)
386
+
387
+ # Process message with agent
388
+ agent_response = await agent_service.process_message(
389
+ message=request.message,
390
+ conversation_history=conversation_history[:-1]
391
+ )
392
+
393
+ # Stream response content in chunks
394
+ content = agent_response.get("content", "")
395
+ chunk_size = 10 # Characters per chunk
396
+
397
+ for i in range(0, len(content), chunk_size):
398
+ chunk = content[i:i + chunk_size]
399
+ yield f"data: {json.dumps({'content': chunk, 'done': False})}\n\n"
400
+
401
+ # Save assistant response
402
+ assistant_message = Message(
403
+ conversation_id=conversation.id,
404
+ role=MessageRole.ASSISTANT,
405
+ content=content,
406
+ tool_calls=agent_response.get("tool_calls"),
407
+ sequence_number=user_seq + 1,
408
+ created_at=datetime.utcnow()
409
+ )
410
+ session.add(assistant_message)
411
+
412
+ # Update conversation timestamp
413
+ conversation.updated_at = datetime.utcnow()
414
+ session.add(conversation)
415
+ session.commit()
416
+ session.refresh(assistant_message)
417
+
418
+ # Send final event with metadata
419
+ final_data = {
420
+ "done": True,
421
+ "conversation_id": conversation.id,
422
+ "message_id": assistant_message.id,
423
+ "tool_calls": agent_response.get("tool_calls"),
424
+ "timestamp": assistant_message.created_at.isoformat()
425
+ }
426
+ yield f"data: {json.dumps(final_data)}\n\n"
427
+
428
+ except Exception as e:
429
+ # Stream error event
430
+ error_data = {
431
+ "error": str(e),
432
+ "done": True
433
+ }
434
+ yield f"data: {json.dumps(error_data)}\n\n"
435
+
436
+ return StreamingResponse(
437
+ event_generator(),
438
+ media_type="text/event-stream",
439
+ headers={
440
+ "Cache-Control": "no-cache",
441
+ "Connection": "keep-alive",
442
+ "X-Accel-Buffering": "no", # Disable nginx buffering
443
+ }
444
+ )
445
+
446
+
447
+ @router.get("/{user_id}/conversations", status_code=status.HTTP_200_OK)
448
+ async def list_conversations(
449
+ user_id: int,
450
+ session: Session = Depends(get_session),
451
+ current_user: Dict = Depends(get_current_user)
452
+ ):
453
+ """
454
+ List all conversations for authenticated user.
455
+
456
+ Returns conversations ordered by most recently updated.
457
+ Excludes soft-deleted conversations.
458
+
459
+ Args:
460
+ user_id: User identifier from URL (must match JWT)
461
+ session: Database session dependency
462
+ current_user: Authenticated user from JWT token
463
+
464
+ Returns:
465
+ List of conversations with metadata
466
+
467
+ Raises:
468
+ HTTPException 403: User_id mismatch
469
+ """
470
+ # Verify user_id matches JWT token
471
+ if current_user["user_id"] != user_id:
472
+ raise HTTPException(
473
+ status_code=status.HTTP_403_FORBIDDEN,
474
+ detail="Permission denied"
475
+ )
476
+
477
+ # Query conversations for user
478
+ statement = (
479
+ select(Conversation)
480
+ .where(Conversation.user_id == user_id)
481
+ .where(Conversation.deleted_at.is_(None))
482
+ .order_by(Conversation.updated_at.desc())
483
+ )
484
+ conversations = session.exec(statement).all()
485
+
486
+ return {
487
+ "conversations": [
488
+ {
489
+ "id": conv.id,
490
+ "title": conv.title,
491
+ "created_at": conv.created_at,
492
+ "updated_at": conv.updated_at
493
+ }
494
+ for conv in conversations
495
+ ],
496
+ "total": len(conversations)
497
+ }
498
+
499
+
500
+ @router.get("/{user_id}/conversations/{conversation_id}/messages", status_code=status.HTTP_200_OK)
501
+ async def get_conversation_messages(
502
+ user_id: int,
503
+ conversation_id: int,
504
+ session: Session = Depends(get_session),
505
+ current_user: Dict = Depends(get_current_user)
506
+ ):
507
+ """
508
+ Get all messages in a conversation.
509
+
510
+ Returns messages ordered chronologically.
511
+ Excludes soft-deleted messages.
512
+
513
+ Args:
514
+ user_id: User identifier from URL (must match JWT)
515
+ conversation_id: Conversation identifier
516
+ session: Database session dependency
517
+ current_user: Authenticated user from JWT token
518
+
519
+ Returns:
520
+ List of messages with metadata
521
+
522
+ Raises:
523
+ HTTPException 403: User_id mismatch or unauthorized conversation access
524
+ HTTPException 404: Conversation not found
525
+ """
526
+ # Verify user_id matches JWT token
527
+ if current_user["user_id"] != user_id:
528
+ raise HTTPException(
529
+ status_code=status.HTTP_403_FORBIDDEN,
530
+ detail="Permission denied"
531
+ )
532
+
533
+ # Load conversation and verify ownership
534
+ conversation = session.get(Conversation, conversation_id)
535
+ if not conversation or conversation.user_id != user_id:
536
+ raise HTTPException(
537
+ status_code=status.HTTP_404_NOT_FOUND,
538
+ detail="Conversation not found"
539
+ )
540
+
541
+ # Query messages
542
+ statement = (
543
+ select(Message)
544
+ .where(Message.conversation_id == conversation_id)
545
+ .where(Message.deleted_at.is_(None))
546
+ .order_by(Message.created_at.asc())
547
+ )
548
+ messages = session.exec(statement).all()
549
+
550
+ return {
551
+ "conversation_id": conversation_id,
552
+ "messages": [
553
+ {
554
+ "id": msg.id,
555
+ "role": msg.role.value,
556
+ "content": msg.content,
557
+ "tool_calls": msg.tool_calls,
558
+ "created_at": msg.created_at
559
+ }
560
+ for msg in messages
561
+ ],
562
+ "total": len(messages)
563
+ }
src/api/dependencies.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI dependencies for authentication and authorization.
3
+
4
+ This module provides reusable dependency functions for route handlers.
5
+ """
6
+ from fastapi import Depends, HTTPException, status
7
+ from sqlmodel import Session, select
8
+ from typing import Dict
9
+ from ..middleware.auth import get_current_user
10
+ from ..models.user import User
11
+ from ..database import get_session
12
+
13
+
14
+ async def verify_jwt(
15
+ current_user: Dict[str, any] = Depends(get_current_user)
16
+ ) -> Dict[str, any]:
17
+ """
18
+ Verify JWT token and return authenticated user information.
19
+
20
+ This dependency can be injected into route handlers to ensure
21
+ the request is authenticated. It extracts user_id and email
22
+ from the verified JWT token.
23
+
24
+ Usage:
25
+ @app.get("/api/protected")
26
+ async def protected_route(user: Dict = Depends(verify_jwt)):
27
+ user_id = user["user_id"]
28
+ email = user["email"]
29
+ # ... route logic
30
+
31
+ Args:
32
+ current_user: User info from JWT token (injected by get_current_user)
33
+
34
+ Returns:
35
+ Dict containing user_id and email from token payload
36
+
37
+ Raises:
38
+ HTTPException: 401 if token is invalid or expired
39
+ """
40
+ return current_user
41
+
42
+
43
+ async def get_current_user_from_db(
44
+ current_user: Dict[str, any] = Depends(get_current_user),
45
+ session: Session = Depends(get_session)
46
+ ) -> User:
47
+ """
48
+ Verify JWT token and fetch full User model from database.
49
+
50
+ This dependency verifies the JWT token and then fetches the
51
+ complete User object from the database. Use this when you need
52
+ access to the full user model with relationships.
53
+
54
+ Usage:
55
+ @app.get("/api/profile")
56
+ async def get_profile(user: User = Depends(get_current_user_from_db)):
57
+ return {"email": user.email, "created_at": user.created_at}
58
+
59
+ Args:
60
+ current_user: User info from JWT token (injected by get_current_user)
61
+ session: Database session
62
+
63
+ Returns:
64
+ User model instance from database
65
+
66
+ Raises:
67
+ HTTPException: 401 if token is invalid or 404 if user not found
68
+ """
69
+ user_id = current_user["user_id"]
70
+
71
+ # Fetch user from database
72
+ statement = select(User).where(User.id == user_id)
73
+ user = session.exec(statement).first()
74
+
75
+ if not user:
76
+ raise HTTPException(
77
+ status_code=status.HTTP_404_NOT_FOUND,
78
+ detail="User not found"
79
+ )
80
+
81
+ return user
src/api/tasks.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlmodel import Session, select
3
+ from typing import List, Dict
4
+ from datetime import datetime
5
+
6
+ from ..database import get_session
7
+ from ..middleware.auth import get_current_user
8
+ from ..models.task import Task
9
+ from ..schemas.task import TaskCreate, TaskUpdate, TaskResponse
10
+
11
+
12
+ router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
13
+
14
+
15
+ @router.get("", response_model=List[TaskResponse], status_code=status.HTTP_200_OK)
16
+ async def list_tasks(
17
+ session: Session = Depends(get_session),
18
+ current_user: Dict = Depends(get_current_user)
19
+ ):
20
+ """
21
+ List all tasks for authenticated user.
22
+
23
+ Returns all tasks owned by the authenticated user, ordered by creation date (newest first).
24
+ User identity is extracted from JWT token.
25
+ """
26
+ user_id = current_user["user_id"]
27
+
28
+ # Query tasks filtered by authenticated user_id
29
+ statement = select(Task).where(Task.user_id == user_id).order_by(Task.created_at.desc())
30
+ tasks = session.exec(statement).all()
31
+
32
+ return tasks
33
+
34
+
35
+ @router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
36
+ async def create_task(
37
+ task_data: TaskCreate,
38
+ session: Session = Depends(get_session),
39
+ current_user: Dict = Depends(get_current_user)
40
+ ):
41
+ """
42
+ Create a new task for authenticated user.
43
+
44
+ User ID is extracted from JWT token, never from client input.
45
+ Task starts with completed=False by default.
46
+ """
47
+ user_id = current_user["user_id"]
48
+
49
+ # Validate title is not empty (Pydantic handles this, but double-check)
50
+ if not task_data.title or task_data.title.strip() == "":
51
+ raise HTTPException(
52
+ status_code=status.HTTP_400_BAD_REQUEST,
53
+ detail="Title is required and cannot be empty"
54
+ )
55
+
56
+ # Create task with user_id from JWT (never from client)
57
+ task = Task(
58
+ title=task_data.title,
59
+ description=task_data.description,
60
+ completed=False, # Always start as incomplete
61
+ user_id=user_id, # Set from JWT token
62
+ created_at=datetime.utcnow(),
63
+ updated_at=datetime.utcnow()
64
+ )
65
+
66
+ session.add(task)
67
+ session.commit()
68
+ session.refresh(task)
69
+
70
+ return task
71
+
72
+
73
+ @router.get("/{task_id}", response_model=TaskResponse, status_code=status.HTTP_200_OK)
74
+ async def get_task(
75
+ task_id: int,
76
+ session: Session = Depends(get_session),
77
+ current_user: Dict = Depends(get_current_user)
78
+ ):
79
+ """
80
+ Get a specific task by ID.
81
+
82
+ User must own the task. Returns 403 if task belongs to another user.
83
+ Returns 404 if task doesn't exist.
84
+ """
85
+ user_id = current_user["user_id"]
86
+
87
+ # Fetch task by ID
88
+ task = session.get(Task, task_id)
89
+
90
+ # Return 404 if task not found
91
+ if not task:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_404_NOT_FOUND,
94
+ detail="Task not found"
95
+ )
96
+
97
+ # Verify ownership - return 403 if user doesn't own this task
98
+ if task.user_id != user_id:
99
+ raise HTTPException(
100
+ status_code=status.HTTP_403_FORBIDDEN,
101
+ detail="You do not have permission to access this task"
102
+ )
103
+
104
+ return task
105
+
106
+
107
+ @router.put("/{task_id}", response_model=TaskResponse, status_code=status.HTTP_200_OK)
108
+ async def update_task(
109
+ task_id: int,
110
+ task_data: TaskUpdate,
111
+ session: Session = Depends(get_session),
112
+ current_user: Dict = Depends(get_current_user)
113
+ ):
114
+ """
115
+ Update an existing task.
116
+
117
+ User must own the task. Only provided fields are updated.
118
+ Updates the updated_at timestamp automatically.
119
+ """
120
+ user_id = current_user["user_id"]
121
+
122
+ # Fetch task by ID
123
+ task = session.get(Task, task_id)
124
+
125
+ # Return 404 if task not found
126
+ if not task:
127
+ raise HTTPException(
128
+ status_code=status.HTTP_404_NOT_FOUND,
129
+ detail="Task not found"
130
+ )
131
+
132
+ # Verify ownership - return 403 if user doesn't own this task
133
+ if task.user_id != user_id:
134
+ raise HTTPException(
135
+ status_code=status.HTTP_403_FORBIDDEN,
136
+ detail="You do not have permission to update this task"
137
+ )
138
+
139
+ # Update only provided fields
140
+ update_data = task_data.model_dump(exclude_unset=True)
141
+
142
+ # Validate title if provided
143
+ if "title" in update_data and (not update_data["title"] or update_data["title"].strip() == ""):
144
+ raise HTTPException(
145
+ status_code=status.HTTP_400_BAD_REQUEST,
146
+ detail="Title cannot be empty"
147
+ )
148
+
149
+ for field, value in update_data.items():
150
+ setattr(task, field, value)
151
+
152
+ # Update timestamp
153
+ task.updated_at = datetime.utcnow()
154
+
155
+ session.add(task)
156
+ session.commit()
157
+ session.refresh(task)
158
+
159
+ return task
160
+
161
+
162
+ @router.delete("/{task_id}", status_code=status.HTTP_200_OK)
163
+ async def delete_task(
164
+ task_id: int,
165
+ session: Session = Depends(get_session),
166
+ current_user: Dict = Depends(get_current_user)
167
+ ):
168
+ """
169
+ Delete a task permanently.
170
+
171
+ User must own the task. Returns success message on deletion.
172
+ """
173
+ user_id = current_user["user_id"]
174
+
175
+ # Fetch task by ID
176
+ task = session.get(Task, task_id)
177
+
178
+ # Return 404 if task not found
179
+ if not task:
180
+ raise HTTPException(
181
+ status_code=status.HTTP_404_NOT_FOUND,
182
+ detail="Task not found"
183
+ )
184
+
185
+ # Verify ownership - return 403 if user doesn't own this task
186
+ if task.user_id != user_id:
187
+ raise HTTPException(
188
+ status_code=status.HTTP_403_FORBIDDEN,
189
+ detail="You do not have permission to delete this task"
190
+ )
191
+
192
+ # Delete task
193
+ session.delete(task)
194
+ session.commit()
195
+
196
+ return {"message": "Task deleted successfully"}
197
+
198
+
199
+ @router.patch("/{task_id}/complete", response_model=TaskResponse, status_code=status.HTTP_200_OK)
200
+ async def toggle_task_completion(
201
+ task_id: int,
202
+ session: Session = Depends(get_session),
203
+ current_user: Dict = Depends(get_current_user)
204
+ ):
205
+ """
206
+ Toggle task completion status.
207
+
208
+ Flips the completed boolean (True -> False or False -> True).
209
+ User must own the task.
210
+ """
211
+ user_id = current_user["user_id"]
212
+
213
+ # Fetch task by ID
214
+ task = session.get(Task, task_id)
215
+
216
+ # Return 404 if task not found
217
+ if not task:
218
+ raise HTTPException(
219
+ status_code=status.HTTP_404_NOT_FOUND,
220
+ detail="Task not found"
221
+ )
222
+
223
+ # Verify ownership - return 403 if user doesn't own this task
224
+ if task.user_id != user_id:
225
+ raise HTTPException(
226
+ status_code=status.HTTP_403_FORBIDDEN,
227
+ detail="You do not have permission to modify this task"
228
+ )
229
+
230
+ # Toggle completion status
231
+ task.completed = not task.completed
232
+
233
+ # Update timestamp
234
+ task.updated_at = datetime.utcnow()
235
+
236
+ session.add(task)
237
+ session.commit()
238
+ session.refresh(task)
239
+
240
+ return task
src/config.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from typing import List
3
+
4
+
5
+ class Settings(BaseSettings):
6
+ """Application settings loaded from environment variables."""
7
+
8
+ # Database
9
+ DATABASE_URL: str
10
+
11
+ # JWT Configuration
12
+ JWT_SECRET: str
13
+ JWT_ALGORITHM: str = "HS256"
14
+ JWT_EXPIRATION_HOURS: int = 168 # 7 days
15
+
16
+ # Better Auth Secret (must match frontend)
17
+ BETTER_AUTH_SECRET: str
18
+
19
+ # CORS Configuration
20
+ CORS_ORIGINS: str = "http://localhost:3000"
21
+
22
+ # OpenAI Configuration (supports OpenRouter)
23
+ OPENAI_API_KEY: str
24
+ OPENAI_MODEL: str = "gpt-4o-mini"
25
+ OPENAI_BASE_URL: str = ""
26
+
27
+ # MCP Server Configuration
28
+ MCP_SERVER_PORT: int = 8001
29
+ MCP_SERVER_HOST: str = "localhost"
30
+
31
+ # Environment
32
+ ENVIRONMENT: str = "development"
33
+ DEBUG: bool = True
34
+
35
+ @property
36
+ def cors_origins_list(self) -> List[str]:
37
+ """Parse CORS_ORIGINS string into list."""
38
+ return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
39
+
40
+ class Config:
41
+ env_file = ".env"
42
+ case_sensitive = True
43
+
44
+
45
+ # Global settings instance
46
+ settings = Settings()
src/database.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import SQLModel, create_engine, Session
2
+ from sqlalchemy.pool import NullPool
3
+ from .config import settings
4
+
5
+ # Import models to register them with SQLModel metadata
6
+ from .models.user import User # noqa: F401
7
+ from .models.task import Task # noqa: F401
8
+ from .models.conversation import Conversation # noqa: F401
9
+ from .models.message import Message # noqa: F401
10
+
11
+
12
+ # Create engine with appropriate pooling for serverless
13
+ engine = create_engine(
14
+ settings.DATABASE_URL,
15
+ echo=settings.DEBUG,
16
+ poolclass=NullPool # Let Neon handle connection pooling
17
+ )
18
+
19
+
20
+ def init_db():
21
+ """Initialize database by creating all tables."""
22
+ SQLModel.metadata.create_all(engine)
23
+ print("Database tables created successfully")
24
+
25
+
26
+ def get_session():
27
+ """Dependency for getting database session."""
28
+ with Session(engine) as session:
29
+ yield session
src/jobs/cleanup_conversations.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Conversation Cleanup Job
3
+
4
+ Implements 90-day retention policy for conversations and messages.
5
+ Permanently deletes soft-deleted conversations older than 90 days.
6
+ """
7
+
8
+ from sqlmodel import Session, select
9
+ from datetime import datetime, timedelta
10
+ from ..database import engine
11
+ from ..models.conversation import Conversation
12
+ from ..models.message import Message
13
+ from ..utils.logging import StructuredLogger
14
+
15
+ logger = StructuredLogger("cleanup")
16
+
17
+
18
+ def cleanup_old_conversations(dry_run: bool = False) -> dict:
19
+ """
20
+ Delete conversations and messages older than 90 days.
21
+
22
+ This function implements the 90-day retention policy by:
23
+ 1. Finding conversations soft-deleted more than 90 days ago
24
+ 2. Deleting associated messages
25
+ 3. Permanently deleting the conversations
26
+
27
+ Args:
28
+ dry_run: If True, only count records without deleting
29
+
30
+ Returns:
31
+ Dict with cleanup statistics
32
+ """
33
+ cutoff_date = datetime.utcnow() - timedelta(days=90)
34
+
35
+ with Session(engine) as session:
36
+ # Find conversations to delete
37
+ statement = select(Conversation).where(
38
+ Conversation.deleted_at.is_not(None),
39
+ Conversation.deleted_at < cutoff_date
40
+ )
41
+ conversations_to_delete = session.exec(statement).all()
42
+
43
+ conversation_count = len(conversations_to_delete)
44
+ message_count = 0
45
+
46
+ if not dry_run:
47
+ for conversation in conversations_to_delete:
48
+ # Delete associated messages
49
+ message_statement = select(Message).where(
50
+ Message.conversation_id == conversation.id
51
+ )
52
+ messages = session.exec(message_statement).all()
53
+ message_count += len(messages)
54
+
55
+ for message in messages:
56
+ session.delete(message)
57
+
58
+ # Delete conversation
59
+ session.delete(conversation)
60
+
61
+ session.commit()
62
+
63
+ logger.info(
64
+ "cleanup_completed",
65
+ conversations_deleted=conversation_count,
66
+ messages_deleted=message_count,
67
+ cutoff_date=cutoff_date.isoformat()
68
+ )
69
+ else:
70
+ # Count messages without deleting
71
+ for conversation in conversations_to_delete:
72
+ message_statement = select(Message).where(
73
+ Message.conversation_id == conversation.id
74
+ )
75
+ messages = session.exec(message_statement).all()
76
+ message_count += len(messages)
77
+
78
+ logger.info(
79
+ "cleanup_dry_run",
80
+ conversations_to_delete=conversation_count,
81
+ messages_to_delete=message_count,
82
+ cutoff_date=cutoff_date.isoformat()
83
+ )
84
+
85
+ return {
86
+ "conversations_deleted": conversation_count,
87
+ "messages_deleted": message_count,
88
+ "cutoff_date": cutoff_date.isoformat(),
89
+ "dry_run": dry_run
90
+ }
91
+
92
+
93
+ def cleanup_orphaned_messages() -> dict:
94
+ """
95
+ Delete messages that belong to deleted conversations.
96
+
97
+ This is a safety cleanup for any orphaned messages.
98
+
99
+ Returns:
100
+ Dict with cleanup statistics
101
+ """
102
+ with Session(engine) as session:
103
+ # Find messages with no parent conversation
104
+ statement = select(Message).where(
105
+ ~Message.conversation_id.in_(
106
+ select(Conversation.id)
107
+ )
108
+ )
109
+ orphaned_messages = session.exec(statement).all()
110
+
111
+ count = len(orphaned_messages)
112
+
113
+ for message in orphaned_messages:
114
+ session.delete(message)
115
+
116
+ session.commit()
117
+
118
+ logger.info(
119
+ "orphaned_messages_cleanup",
120
+ messages_deleted=count
121
+ )
122
+
123
+ return {
124
+ "orphaned_messages_deleted": count
125
+ }
126
+
127
+
128
+ if __name__ == "__main__":
129
+ """
130
+ Run cleanup job from command line.
131
+
132
+ Usage:
133
+ python -m backend.src.jobs.cleanup_conversations
134
+ python -m backend.src.jobs.cleanup_conversations --dry-run
135
+ """
136
+ import sys
137
+
138
+ dry_run = "--dry-run" in sys.argv
139
+
140
+ print("Starting conversation cleanup job...")
141
+ print(f"Dry run: {dry_run}")
142
+ print(f"Cutoff date: {(datetime.utcnow() - timedelta(days=90)).isoformat()}")
143
+ print()
144
+
145
+ # Run main cleanup
146
+ result = cleanup_old_conversations(dry_run=dry_run)
147
+ print(f"Conversations deleted: {result['conversations_deleted']}")
148
+ print(f"Messages deleted: {result['messages_deleted']}")
149
+ print()
150
+
151
+ # Run orphaned messages cleanup
152
+ if not dry_run:
153
+ orphaned_result = cleanup_orphaned_messages()
154
+ print(f"Orphaned messages deleted: {orphaned_result['orphaned_messages_deleted']}")
155
+ print()
156
+
157
+ print("Cleanup job completed!")
src/main.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import JSONResponse
4
+ from .config import settings
5
+ from .schemas.error import ErrorResponse
6
+ from .api import tasks_router, auth_router, chat_router
7
+ from .agents import TaskAgent
8
+
9
+
10
+ # Initialize FastAPI application
11
+ app = FastAPI(
12
+ title="KIro Todo API",
13
+ description="RESTful API for multi-user todo application with JWT authentication and Cohere AI agent",
14
+ version="1.0.0"
15
+ )
16
+
17
+
18
+ # Global agent instance (initialized at startup)
19
+ task_agent: TaskAgent = None
20
+
21
+
22
+ @app.on_event("startup")
23
+ async def startup_event():
24
+ """
25
+ Initialize application components at startup.
26
+
27
+ This includes:
28
+ - Creating the TaskAgent instance
29
+ - Verifying OpenAI/OpenRouter API connectivity
30
+ """
31
+ global task_agent
32
+
33
+ # Initialize the task agent
34
+ task_agent = TaskAgent()
35
+
36
+ # Verify agent can connect to API
37
+ is_healthy = await task_agent.health_check()
38
+ if is_healthy:
39
+ print(f"[OK] TaskAgent initialized successfully with model: {task_agent.model}")
40
+ else:
41
+ print(f"[WARNING] TaskAgent initialized but API health check failed")
42
+
43
+
44
+ def get_task_agent() -> TaskAgent:
45
+ """
46
+ Get the global TaskAgent instance.
47
+
48
+ This function can be used as a dependency in route handlers.
49
+
50
+ Returns:
51
+ TaskAgent: The initialized agent instance
52
+
53
+ Raises:
54
+ RuntimeError: If agent is not initialized
55
+ """
56
+ if task_agent is None:
57
+ raise RuntimeError("TaskAgent not initialized. Application startup may have failed.")
58
+ return task_agent
59
+
60
+
61
+ # Configure CORS middleware
62
+ app.add_middleware(
63
+ CORSMiddleware,
64
+ allow_origins=settings.cors_origins_list,
65
+ allow_credentials=True,
66
+ allow_methods=["*"],
67
+ allow_headers=["*"],
68
+ )
69
+
70
+
71
+ # Include API routers
72
+ app.include_router(auth_router)
73
+ app.include_router(tasks_router)
74
+ app.include_router(chat_router)
75
+
76
+
77
+ # Global exception handler for HTTPException
78
+ @app.exception_handler(HTTPException)
79
+ async def http_exception_handler(request, exc: HTTPException):
80
+ """Handle HTTP exceptions with consistent error response format."""
81
+ return JSONResponse(
82
+ status_code=exc.status_code,
83
+ content={
84
+ "error": exc.detail,
85
+ "message": get_user_friendly_message(exc.status_code),
86
+ "details": getattr(exc, "details", None)
87
+ }
88
+ )
89
+
90
+
91
+ def get_user_friendly_message(status_code: int) -> str:
92
+ """Get user-friendly message for HTTP status code."""
93
+ messages = {
94
+ 400: "The request contains invalid data",
95
+ 401: "Authentication is required to access this resource",
96
+ 403: "You do not have permission to access this resource",
97
+ 404: "The requested resource was not found",
98
+ 500: "An internal server error occurred"
99
+ }
100
+ return messages.get(status_code, "An error occurred")
101
+
102
+
103
+ # Health check endpoint
104
+ @app.get("/health", tags=["Health"])
105
+ async def health_check():
106
+ """Health check endpoint to verify API is running."""
107
+ return {
108
+ "status": "healthy",
109
+ "environment": settings.ENVIRONMENT
110
+ }
111
+
112
+
113
+ # Root endpoint
114
+ @app.get("/", tags=["Root"])
115
+ async def root():
116
+ """Root endpoint with API information."""
117
+ return {
118
+ "message": "KIro Todo API",
119
+ "version": "1.0.0",
120
+ "docs": "/docs"
121
+ }
src/middleware/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Middleware package
src/middleware/auth.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends, HTTPException, status
2
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
3
+ from jose import jwt, JWTError
4
+ from typing import Dict
5
+ from ..config import settings
6
+
7
+
8
+ # HTTP Bearer security scheme
9
+ security = HTTPBearer()
10
+
11
+
12
+ async def get_current_user(
13
+ credentials: HTTPAuthorizationCredentials = Depends(security)
14
+ ) -> Dict[str, any]:
15
+ """
16
+ Verify JWT token and extract user identity.
17
+
18
+ This dependency extracts and validates the JWT token from the Authorization header.
19
+ User identity is extracted from the token payload, never from client input.
20
+
21
+ Args:
22
+ credentials: HTTP Bearer credentials containing JWT token
23
+
24
+ Returns:
25
+ Dict containing user_id and email from token payload
26
+
27
+ Raises:
28
+ HTTPException: 401 if token is missing, invalid, or expired
29
+ """
30
+ try:
31
+ # Decode and verify JWT token
32
+ payload = jwt.decode(
33
+ credentials.credentials,
34
+ settings.JWT_SECRET,
35
+ algorithms=[settings.JWT_ALGORITHM]
36
+ )
37
+
38
+ # Extract user ID from token payload
39
+ user_id = payload.get("sub")
40
+ email = payload.get("email")
41
+
42
+ if user_id is None:
43
+ raise HTTPException(
44
+ status_code=status.HTTP_401_UNAUTHORIZED,
45
+ detail="Invalid token: missing user ID"
46
+ )
47
+
48
+ return {
49
+ "user_id": int(user_id),
50
+ "email": email
51
+ }
52
+
53
+ except JWTError as e:
54
+ raise HTTPException(
55
+ status_code=status.HTTP_401_UNAUTHORIZED,
56
+ detail=f"Invalid token: {str(e)}"
57
+ )
58
+ except ValueError:
59
+ raise HTTPException(
60
+ status_code=status.HTTP_401_UNAUTHORIZED,
61
+ detail="Invalid token: malformed user ID"
62
+ )
src/middleware/rate_limit.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rate Limiting Middleware
3
+
4
+ Implements token bucket algorithm for rate limiting API requests.
5
+ Limits: 60 requests per minute per user.
6
+ """
7
+
8
+ from fastapi import Request, HTTPException, status
9
+ from typing import Dict
10
+ import time
11
+ from collections import defaultdict
12
+ import threading
13
+
14
+
15
+ class TokenBucket:
16
+ """
17
+ Token bucket implementation for rate limiting.
18
+
19
+ Each user gets a bucket with tokens that refill over time.
20
+ Each request consumes one token.
21
+ """
22
+
23
+ def __init__(self, capacity: int, refill_rate: float):
24
+ """
25
+ Initialize token bucket.
26
+
27
+ Args:
28
+ capacity: Maximum number of tokens (burst size)
29
+ refill_rate: Tokens added per second
30
+ """
31
+ self.capacity = capacity
32
+ self.refill_rate = refill_rate
33
+ self.tokens = capacity
34
+ self.last_refill = time.time()
35
+ self.lock = threading.Lock()
36
+
37
+ def consume(self, tokens: int = 1) -> bool:
38
+ """
39
+ Try to consume tokens from bucket.
40
+
41
+ Args:
42
+ tokens: Number of tokens to consume
43
+
44
+ Returns:
45
+ True if tokens were consumed, False if insufficient tokens
46
+ """
47
+ with self.lock:
48
+ # Refill tokens based on time elapsed
49
+ now = time.time()
50
+ elapsed = now - self.last_refill
51
+ self.tokens = min(
52
+ self.capacity,
53
+ self.tokens + (elapsed * self.refill_rate)
54
+ )
55
+ self.last_refill = now
56
+
57
+ # Try to consume tokens
58
+ if self.tokens >= tokens:
59
+ self.tokens -= tokens
60
+ return True
61
+ return False
62
+
63
+ def get_remaining(self) -> int:
64
+ """Get number of tokens remaining."""
65
+ with self.lock:
66
+ return int(self.tokens)
67
+
68
+ def get_reset_time(self) -> int:
69
+ """Get timestamp when bucket will be full."""
70
+ with self.lock:
71
+ if self.tokens >= self.capacity:
72
+ return int(time.time())
73
+
74
+ tokens_needed = self.capacity - self.tokens
75
+ seconds_to_full = tokens_needed / self.refill_rate
76
+ return int(time.time() + seconds_to_full)
77
+
78
+
79
+ class RateLimiter:
80
+ """
81
+ Rate limiter using token bucket algorithm.
82
+
83
+ Tracks rate limits per user_id.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ requests_per_minute: int = 60,
89
+ burst_size: int = 10
90
+ ):
91
+ """
92
+ Initialize rate limiter.
93
+
94
+ Args:
95
+ requests_per_minute: Maximum requests per minute per user
96
+ burst_size: Maximum burst size (extra tokens beyond rate)
97
+ """
98
+ self.requests_per_minute = requests_per_minute
99
+ self.capacity = requests_per_minute + burst_size
100
+ self.refill_rate = requests_per_minute / 60.0 # Tokens per second
101
+ self.buckets: Dict[int, TokenBucket] = defaultdict(
102
+ lambda: TokenBucket(self.capacity, self.refill_rate)
103
+ )
104
+ self.lock = threading.Lock()
105
+
106
+ def check_rate_limit(self, user_id: int) -> tuple[bool, int, int]:
107
+ """
108
+ Check if request is within rate limit.
109
+
110
+ Args:
111
+ user_id: User identifier
112
+
113
+ Returns:
114
+ Tuple of (allowed, remaining, reset_time)
115
+ """
116
+ with self.lock:
117
+ bucket = self.buckets[user_id]
118
+
119
+ allowed = bucket.consume(1)
120
+ remaining = bucket.get_remaining()
121
+ reset_time = bucket.get_reset_time()
122
+
123
+ return allowed, remaining, reset_time
124
+
125
+
126
+ # Global rate limiter instance
127
+ rate_limiter = RateLimiter(requests_per_minute=60, burst_size=10)
128
+
129
+
130
+ async def rate_limit_middleware(request: Request, user_id: int):
131
+ """
132
+ Rate limiting middleware for API endpoints.
133
+
134
+ Args:
135
+ request: FastAPI request object
136
+ user_id: Authenticated user ID
137
+
138
+ Raises:
139
+ HTTPException 429: Rate limit exceeded
140
+ """
141
+ allowed, remaining, reset_time = rate_limiter.check_rate_limit(user_id)
142
+
143
+ # Add rate limit headers to response
144
+ request.state.rate_limit_headers = {
145
+ "X-RateLimit-Limit": str(rate_limiter.requests_per_minute),
146
+ "X-RateLimit-Remaining": str(remaining),
147
+ "X-RateLimit-Reset": str(reset_time)
148
+ }
149
+
150
+ if not allowed:
151
+ retry_after = reset_time - int(time.time())
152
+ raise HTTPException(
153
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
154
+ detail="Rate limit exceeded",
155
+ headers={
156
+ "X-RateLimit-Limit": str(rate_limiter.requests_per_minute),
157
+ "X-RateLimit-Remaining": "0",
158
+ "X-RateLimit-Reset": str(reset_time),
159
+ "Retry-After": str(max(1, retry_after))
160
+ }
161
+ )
src/models/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Models package
2
+ from .user import User
3
+ from .task import Task
4
+ from .conversation import Conversation
5
+ from .message import Message, MessageRole
6
+ from .tool_call_log import ToolCallLog, ToolCallStatus
7
+
8
+ __all__ = ["User", "Task", "Conversation", "Message", "MessageRole", "ToolCallLog", "ToolCallStatus"]
src/models/conversation.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import Field, SQLModel, Relationship
2
+ from typing import Optional, List, TYPE_CHECKING
3
+ from datetime import datetime
4
+
5
+ if TYPE_CHECKING:
6
+ from .user import User
7
+ from .message import Message
8
+
9
+
10
+ class Conversation(SQLModel, table=True):
11
+ """Conversation model representing a chat session between a user and the AI agent."""
12
+
13
+ __tablename__ = "conversations"
14
+
15
+ id: Optional[int] = Field(default=None, primary_key=True)
16
+ user_id: int = Field(foreign_key="users.id", index=True)
17
+ title: Optional[str] = Field(default=None, max_length=200)
18
+ created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
19
+ updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
20
+ deleted_at: Optional[datetime] = Field(default=None, index=True)
21
+
22
+ # Relationships
23
+ owner: "User" = Relationship(back_populates="conversations")
24
+ messages: List["Message"] = Relationship(
25
+ back_populates="conversation",
26
+ cascade_delete=True,
27
+ sa_relationship_kwargs={"order_by": "Message.created_at"}
28
+ )
src/models/message.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import Field, SQLModel, Relationship, Column, JSON
2
+ from typing import Optional, Dict, Any, TYPE_CHECKING
3
+ from datetime import datetime
4
+ from enum import Enum
5
+
6
+ if TYPE_CHECKING:
7
+ from .conversation import Conversation
8
+
9
+
10
+ class MessageRole(str, Enum):
11
+ """Enum representing the role of a message sender."""
12
+ USER = "user"
13
+ ASSISTANT = "assistant"
14
+
15
+
16
+ class Message(SQLModel, table=True):
17
+ """Message model representing a single exchange in a conversation."""
18
+
19
+ __tablename__ = "messages"
20
+ __table_args__ = (
21
+ {"sqlite_autoincrement": True},
22
+ )
23
+
24
+ id: Optional[int] = Field(default=None, primary_key=True)
25
+ conversation_id: int = Field(foreign_key="conversations.id", index=True)
26
+ role: MessageRole = Field(sa_column_kwargs={"nullable": False})
27
+ content: str = Field(sa_column_kwargs={"nullable": False})
28
+ tool_calls: Optional[Dict[str, Any]] = Field(
29
+ default=None,
30
+ sa_column=Column(JSON)
31
+ )
32
+ sequence_number: int = Field(index=True)
33
+ created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
34
+ deleted_at: Optional[datetime] = Field(default=None, index=True)
35
+
36
+ # Relationships
37
+ conversation: "Conversation" = Relationship(back_populates="messages")
src/models/task.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import Field, SQLModel, Relationship
2
+ from typing import Optional, TYPE_CHECKING
3
+ from datetime import datetime
4
+
5
+ if TYPE_CHECKING:
6
+ from .user import User
7
+
8
+
9
+ class Task(SQLModel, table=True):
10
+ """Task model representing a todo item belonging to a specific user."""
11
+
12
+ __tablename__ = "tasks"
13
+
14
+ id: Optional[int] = Field(default=None, primary_key=True)
15
+ title: str = Field(max_length=200, min_length=1)
16
+ description: Optional[str] = Field(default=None, max_length=2000)
17
+ completed: bool = Field(default=False)
18
+ user_id: int = Field(foreign_key="users.id", index=True)
19
+ created_at: datetime = Field(default_factory=datetime.utcnow)
20
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
21
+
22
+ # Relationships
23
+ owner: "User" = Relationship(back_populates="tasks")
src/models/tool_call_log.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import Field, SQLModel, Relationship, Column, JSON
2
+ from sqlalchemy import ForeignKey, Integer
3
+ from typing import Optional, Dict, Any, TYPE_CHECKING
4
+ from datetime import datetime
5
+ from enum import Enum
6
+
7
+ if TYPE_CHECKING:
8
+ from .message import Message
9
+ from .conversation import Conversation
10
+ from .user import User
11
+
12
+
13
+ class ToolCallStatus(str, Enum):
14
+ """Enum representing the status of a tool call."""
15
+ SUCCESS = "success"
16
+ ERROR = "error"
17
+
18
+
19
+ class ToolCallLog(SQLModel, table=True):
20
+ """ToolCallLog model for auditing MCP tool invocations."""
21
+
22
+ __tablename__ = "tool_call_logs"
23
+
24
+ id: Optional[int] = Field(default=None, primary_key=True)
25
+ message_id: Optional[int] = Field(
26
+ default=None,
27
+ sa_column=Column(Integer, ForeignKey("messages.id", ondelete="SET NULL"))
28
+ )
29
+ conversation_id: int = Field(
30
+ sa_column=Column(Integer, ForeignKey("conversations.id", ondelete="CASCADE"), index=True, nullable=False)
31
+ )
32
+ user_id: int = Field(
33
+ sa_column=Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
34
+ )
35
+ tool_name: str = Field(max_length=50, index=True)
36
+ arguments: Dict[str, Any] = Field(sa_column=Column(JSON))
37
+ result: Dict[str, Any] = Field(sa_column=Column(JSON))
38
+ status: ToolCallStatus = Field(sa_column_kwargs={"nullable": False})
39
+ execution_time_ms: int = Field(default=0)
40
+ created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
41
+
42
+ # Relationships
43
+ message: Optional["Message"] = Relationship()
44
+ conversation: "Conversation" = Relationship()
45
+ user: "User" = Relationship()
src/models/user.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import Field, SQLModel, Relationship
2
+ from typing import Optional, List, TYPE_CHECKING
3
+ from datetime import datetime
4
+
5
+ if TYPE_CHECKING:
6
+ from .task import Task
7
+ from .conversation import Conversation
8
+
9
+
10
+ class User(SQLModel, table=True):
11
+ """User model representing a person with an account in the system."""
12
+
13
+ __tablename__ = "users"
14
+
15
+ id: Optional[int] = Field(default=None, primary_key=True)
16
+ email: str = Field(unique=True, index=True, max_length=255)
17
+ password_hash: str = Field(max_length=255)
18
+ created_at: datetime = Field(default_factory=datetime.utcnow)
19
+
20
+ # Relationships
21
+ tasks: List["Task"] = Relationship(back_populates="owner", cascade_delete=True)
22
+ conversations: List["Conversation"] = Relationship(back_populates="owner", cascade_delete=True)
src/schemas/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Schemas package
src/schemas/chat.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat API Schemas
3
+
4
+ Request and response models for the chat endpoint.
5
+ """
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+ from typing import Optional, List, Dict, Any
9
+ from datetime import datetime
10
+
11
+
12
+ class ChatRequest(BaseModel):
13
+ """Request model for chat endpoint."""
14
+
15
+ message: str = Field(
16
+ ...,
17
+ min_length=1,
18
+ max_length=2000,
19
+ description="User's natural language message"
20
+ )
21
+ conversation_id: Optional[int] = Field(
22
+ default=None,
23
+ description="Existing conversation ID to continue (omit for new conversation)"
24
+ )
25
+
26
+ @field_validator('message')
27
+ @classmethod
28
+ def validate_message(cls, v: str) -> str:
29
+ """Validate message is not empty or whitespace-only."""
30
+ if not v or v.strip() == "":
31
+ raise ValueError("Message cannot be empty or whitespace-only")
32
+ return v.strip()
33
+
34
+
35
+ class ToolCallResult(BaseModel):
36
+ """Tool call result metadata."""
37
+
38
+ tool: str = Field(..., description="Tool name")
39
+ parameters: Dict[str, Any] = Field(..., description="Tool parameters")
40
+ result: Dict[str, Any] = Field(..., description="Tool execution result")
41
+ duration_ms: Optional[int] = Field(None, description="Execution time in milliseconds")
42
+
43
+
44
+ class ChatResponse(BaseModel):
45
+ """Response model for chat endpoint."""
46
+
47
+ conversation_id: int = Field(..., description="Conversation identifier")
48
+ message_id: int = Field(..., description="Assistant message ID")
49
+ assistant_message: str = Field(..., description="Natural language response from agent")
50
+ tool_calls: Optional[List[ToolCallResult]] = Field(
51
+ default=None,
52
+ description="List of MCP tool invocations (empty if no tools used)"
53
+ )
54
+ timestamp: datetime = Field(..., description="Response generation timestamp")
55
+
56
+ class Config:
57
+ json_schema_extra = {
58
+ "example": {
59
+ "conversation_id": 456,
60
+ "message_id": 789,
61
+ "assistant_message": "I've added 'Buy groceries tomorrow' to your tasks. Task created successfully!",
62
+ "tool_calls": [
63
+ {
64
+ "tool": "create_task",
65
+ "parameters": {
66
+ "title": "Buy groceries tomorrow",
67
+ "description": ""
68
+ },
69
+ "result": {
70
+ "task_id": 101,
71
+ "status": "success"
72
+ },
73
+ "duration_ms": 45
74
+ }
75
+ ],
76
+ "timestamp": "2026-02-03T10:30:00Z"
77
+ }
78
+ }
src/schemas/error.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, Dict, Any
3
+
4
+
5
+ class ErrorResponse(BaseModel):
6
+ """Standard error response format."""
7
+
8
+ error: str
9
+ message: str
10
+ details: Optional[Dict[str, Any]] = None
11
+
12
+ class Config:
13
+ json_schema_extra = {
14
+ "example": {
15
+ "error": "Validation error",
16
+ "message": "The provided data is invalid",
17
+ "details": {
18
+ "field": "title",
19
+ "constraint": "minLength"
20
+ }
21
+ }
22
+ }
src/schemas/task.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+
6
+ class TaskCreate(BaseModel):
7
+ """Schema for creating a new task."""
8
+
9
+ title: str = Field(..., min_length=1, max_length=200, description="Task title (required)")
10
+ description: Optional[str] = Field(None, max_length=2000, description="Optional detailed description")
11
+
12
+ class Config:
13
+ json_schema_extra = {
14
+ "example": {
15
+ "title": "Buy groceries",
16
+ "description": "Milk, eggs, bread, and vegetables"
17
+ }
18
+ }
19
+
20
+
21
+ class TaskUpdate(BaseModel):
22
+ """Schema for updating an existing task."""
23
+
24
+ title: Optional[str] = Field(None, min_length=1, max_length=200, description="Updated task title")
25
+ description: Optional[str] = Field(None, max_length=2000, description="Updated description")
26
+ completed: Optional[bool] = Field(None, description="Updated completion status")
27
+
28
+ class Config:
29
+ json_schema_extra = {
30
+ "example": {
31
+ "title": "Buy groceries and cook dinner",
32
+ "description": "Updated shopping list",
33
+ "completed": True
34
+ }
35
+ }
36
+
37
+
38
+ class TaskResponse(BaseModel):
39
+ """Schema for task response."""
40
+
41
+ id: int = Field(..., description="Unique task identifier")
42
+ title: str = Field(..., description="Task title")
43
+ description: Optional[str] = Field(None, description="Task description")
44
+ completed: bool = Field(..., description="Whether task is marked as complete")
45
+ user_id: int = Field(..., description="ID of user who owns this task")
46
+ created_at: datetime = Field(..., description="Timestamp when task was created")
47
+ updated_at: datetime = Field(..., description="Timestamp when task was last modified")
48
+
49
+ class Config:
50
+ from_attributes = True
51
+ json_schema_extra = {
52
+ "example": {
53
+ "id": 1,
54
+ "title": "Buy groceries",
55
+ "description": "Milk, eggs, bread",
56
+ "completed": False,
57
+ "user_id": 42,
58
+ "created_at": "2026-02-02T10:30:00Z",
59
+ "updated_at": "2026-02-02T10:30:00Z"
60
+ }
61
+ }
src/services/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Services package."""
2
+
3
+ from .agent_service import AgentService
4
+
5
+ __all__ = ["AgentService"]
src/services/agent_service.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent Service Module
3
+
4
+ This module provides the AgentService class that orchestrates AI agent interactions.
5
+ The service wraps MCP tools with user_id pre-bound for security and data isolation.
6
+ """
7
+
8
+ from typing import List, Dict, Any, Optional
9
+ import json
10
+ import time
11
+ from openai import OpenAI
12
+ from ..config import settings
13
+ from ..tools.mcp_server import mcp_server, MCPContext
14
+ from ..tools import (
15
+ get_list_tasks_definition,
16
+ get_create_task_definition,
17
+ get_mark_complete_definition,
18
+ get_update_task_definition,
19
+ get_delete_task_definition,
20
+ get_get_task_definition
21
+ )
22
+ from ..database import engine
23
+
24
+
25
+ class AgentService:
26
+ """
27
+ Agent Service for processing conversational task management requests.
28
+
29
+ This service:
30
+ 1. Initializes OpenAI client with configured API key
31
+ 2. Wraps MCP tools with user_id pre-bound for security
32
+ 3. Processes user messages with conversation history
33
+ 4. Returns agent responses with tool call metadata
34
+ """
35
+
36
+ def __init__(self, user_id: int):
37
+ """
38
+ Initialize AgentService for a specific user.
39
+
40
+ Args:
41
+ user_id: Authenticated user ID for data scoping
42
+ """
43
+ self.user_id = user_id
44
+
45
+ # Configure client for OpenRouter if base URL is provided
46
+ client_kwargs = {"api_key": settings.OPENAI_API_KEY}
47
+ if hasattr(settings, 'OPENAI_BASE_URL') and settings.OPENAI_BASE_URL:
48
+ client_kwargs["base_url"] = settings.OPENAI_BASE_URL
49
+
50
+ self.client = OpenAI(**client_kwargs)
51
+ self.model = settings.OPENAI_MODEL
52
+ self.mcp_context = mcp_server.create_context(user_id=user_id)
53
+
54
+ def create_user_scoped_tools(self) -> List[Dict[str, Any]]:
55
+ """
56
+ Create user-scoped tool definitions for OpenAI function calling.
57
+
58
+ This method wraps internal MCP tools with user_id pre-bound,
59
+ ensuring the agent can only access the authenticated user's data.
60
+
61
+ Returns:
62
+ List of tool definitions in OpenAI function calling format
63
+ """
64
+ tools = [
65
+ get_list_tasks_definition(),
66
+ get_create_task_definition(),
67
+ get_mark_complete_definition(),
68
+ get_update_task_definition(),
69
+ get_delete_task_definition(),
70
+ get_get_task_definition()
71
+ ]
72
+
73
+ return tools
74
+
75
+ async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
76
+ """
77
+ Execute an MCP tool with the given arguments.
78
+
79
+ Args:
80
+ tool_name: Name of the tool to execute
81
+ arguments: Tool arguments (user_id is pre-bound from context)
82
+
83
+ Returns:
84
+ Tool execution result
85
+
86
+ Raises:
87
+ ValueError: If tool not found
88
+ """
89
+ # Get tool function from MCP server
90
+ tool_func = mcp_server.get_tool(tool_name)
91
+
92
+ if not tool_func:
93
+ raise ValueError(f"Tool '{tool_name}' not found")
94
+
95
+ # Execute tool with user-scoped context
96
+ try:
97
+ result = await tool_func(self.mcp_context, **arguments)
98
+ return result
99
+ except Exception as e:
100
+ print(f"Error executing tool '{tool_name}': {str(e)}")
101
+ return {
102
+ "status": "error",
103
+ "error": str(e)
104
+ }
105
+
106
+ async def process_message(
107
+ self,
108
+ message: str,
109
+ conversation_history: List[Dict[str, str]]
110
+ ) -> Dict[str, Any]:
111
+ """
112
+ Process user message with conversation history and return agent response.
113
+
114
+ This method implements the full OpenAI function calling workflow:
115
+ 1. Send message with tool definitions to OpenAI
116
+ 2. If model calls tools, execute them
117
+ 3. Send tool results back to model
118
+ 4. Return final natural language response
119
+
120
+ Args:
121
+ message: User's natural language input
122
+ conversation_history: List of previous messages in conversation
123
+
124
+ Returns:
125
+ Dict containing:
126
+ - content: Agent's natural language response
127
+ - tool_calls: List of tool invocations with results (if any)
128
+ - model: Model used for generation
129
+
130
+ Raises:
131
+ Exception: If OpenAI API call fails
132
+ """
133
+ # Build messages array for OpenAI API
134
+ messages = []
135
+
136
+ # Add system message for agent behavior
137
+ system_prompt = (
138
+ "You are a helpful task management assistant. "
139
+ "You help users manage their tasks through natural conversation.\n\n"
140
+
141
+ "**Task Creation Intent Recognition:**\n"
142
+ "When users express intent to create a task, use the create_task tool. Examples:\n"
143
+ "- 'remind me to X' → create_task(title='X')\n"
144
+ "- 'add task X' → create_task(title='X')\n"
145
+ "- 'I need to X' → create_task(title='X')\n"
146
+ "- 'create a task for X' → create_task(title='X')\n"
147
+ "- 'don't let me forget to X' → create_task(title='X')\n\n"
148
+
149
+ "**Task Listing Intent Recognition:**\n"
150
+ "When users want to see their tasks, use the list_tasks tool. Examples:\n"
151
+ "- 'show my tasks' → list_tasks()\n"
152
+ "- 'what do I need to do' → list_tasks()\n"
153
+ "- 'list my todos' → list_tasks()\n"
154
+ "- 'what are my tasks' → list_tasks()\n"
155
+ "- 'show me my task list' → list_tasks()\n\n"
156
+
157
+ "**Task Completion Intent Recognition:**\n"
158
+ "When users indicate they finished a task, use mark_complete tool. Examples:\n"
159
+ "- 'I finished X' → list_tasks() to find task, then mark_complete(task_id)\n"
160
+ "- 'mark X as done' → list_tasks() to find task, then mark_complete(task_id)\n"
161
+ "- 'I completed the groceries task' → list_tasks() to find task, then mark_complete(task_id)\n"
162
+ "- 'done with X' → list_tasks() to find task, then mark_complete(task_id)\n\n"
163
+
164
+ "**Task Update Intent Recognition:**\n"
165
+ "When users want to modify a task, use update_task tool. Examples:\n"
166
+ "- 'change X to Y' → list_tasks() to find task, then update_task(task_id, title='Y')\n"
167
+ "- 'update task X' → list_tasks() to find task, then update_task(task_id, ...)\n"
168
+ "- 'rename X to Y' → list_tasks() to find task, then update_task(task_id, title='Y')\n"
169
+ "- 'add details to X' → list_tasks() to find task, then update_task(task_id, description='...')\n\n"
170
+
171
+ "**Task Deletion Intent Recognition:**\n"
172
+ "When users want to remove a task, use delete_task tool. Examples:\n"
173
+ "- 'delete X' → list_tasks() to find task, then delete_task(task_id)\n"
174
+ "- 'remove the task' → list_tasks() to find task, then delete_task(task_id)\n"
175
+ "- 'cancel X' → list_tasks() to find task, then delete_task(task_id)\n"
176
+ "- 'get rid of X' → list_tasks() to find task, then delete_task(task_id)\n\n"
177
+
178
+ "**Multi-Step Operations:**\n"
179
+ "For operations that reference tasks by title or description (not ID):\n"
180
+ "1. First call list_tasks() to get all tasks\n"
181
+ "2. Identify the matching task from the list\n"
182
+ "3. Use the task's ID for the operation (mark_complete, update_task, delete_task)\n"
183
+ "4. If multiple tasks match, ask the user to clarify which one\n"
184
+ "5. If no tasks match, inform the user the task wasn't found\n\n"
185
+
186
+ "**Context Awareness:**\n"
187
+ "- Remember previous messages in the conversation\n"
188
+ "- When users say 'the first task', 'the second one', 'that task', refer to recently listed tasks\n"
189
+ "- Maintain context across multiple turns\n"
190
+ "- If context is unclear, ask clarifying questions\n\n"
191
+
192
+ "**Response Guidelines:**\n"
193
+ "- Always confirm actions taken (e.g., 'I've added X to your tasks')\n"
194
+ "- Format task lists in a readable way (use bullet points or numbered lists)\n"
195
+ "- Show completed vs incomplete tasks clearly\n"
196
+ "- Be concise, friendly, and conversational\n"
197
+ "- If no tasks exist, provide an encouraging message\n"
198
+ "- If a request is ambiguous, ask clarifying questions\n"
199
+ "- When multiple tasks match a reference, list them and ask which one\n"
200
+ "- Provide helpful error messages when operations fail"
201
+ )
202
+
203
+ messages.append({
204
+ "role": "system",
205
+ "content": system_prompt
206
+ })
207
+
208
+ # Add conversation history
209
+ for msg in conversation_history:
210
+ messages.append({
211
+ "role": msg.get("role", "user"),
212
+ "content": msg.get("content", "")
213
+ })
214
+
215
+ # Add current user message
216
+ messages.append({
217
+ "role": "user",
218
+ "content": message
219
+ })
220
+
221
+ # Get user-scoped tools
222
+ tools = self.create_user_scoped_tools()
223
+
224
+ # Track tool calls for response metadata
225
+ executed_tool_calls = []
226
+
227
+ # Call OpenAI API with retry logic
228
+ max_retries = 3
229
+ retry_delay = 1 # seconds
230
+
231
+ try:
232
+ if tools:
233
+ # First API call with tools (with retry)
234
+ for attempt in range(max_retries):
235
+ try:
236
+ response = self.client.chat.completions.create(
237
+ model=self.model,
238
+ messages=messages,
239
+ tools=tools,
240
+ temperature=0.3,
241
+ max_tokens=500
242
+ )
243
+ break # Success, exit retry loop
244
+ except Exception as api_error:
245
+ if attempt < max_retries - 1:
246
+ # Retry on transient errors
247
+ error_str = str(api_error).lower()
248
+ if any(keyword in error_str for keyword in ['timeout', 'connection', 'rate_limit', '429', '503', '502']):
249
+ print(f"OpenAI API error (attempt {attempt + 1}/{max_retries}): {api_error}. Retrying in {retry_delay}s...")
250
+ time.sleep(retry_delay)
251
+ retry_delay *= 2 # Exponential backoff
252
+ continue
253
+ # Non-retryable error or max retries reached
254
+ raise
255
+
256
+ assistant_message = response.choices[0].message
257
+
258
+ # Check if model wants to call tools
259
+ if assistant_message.tool_calls:
260
+ # Execute each tool call
261
+ for tool_call in assistant_message.tool_calls:
262
+ tool_name = tool_call.function.name
263
+
264
+ # Handle None or empty arguments from OpenRouter
265
+ tool_args_str = tool_call.function.arguments
266
+ if tool_args_str is None or tool_args_str == "":
267
+ tool_args = {}
268
+ else:
269
+ tool_args = json.loads(tool_args_str)
270
+
271
+ # Execute tool and track timing
272
+ start_time = time.time()
273
+ tool_result = await self.execute_tool(tool_name, tool_args)
274
+ duration_ms = int((time.time() - start_time) * 1000)
275
+
276
+ # Store tool call metadata
277
+ executed_tool_calls.append({
278
+ "tool": tool_name,
279
+ "parameters": tool_args,
280
+ "result": tool_result,
281
+ "duration_ms": duration_ms
282
+ })
283
+
284
+ # Add tool call and result to messages for next API call
285
+ messages.append({
286
+ "role": "assistant",
287
+ "content": None,
288
+ "tool_calls": [{
289
+ "id": tool_call.id,
290
+ "type": "function",
291
+ "function": {
292
+ "name": tool_name,
293
+ "arguments": tool_call.function.arguments
294
+ }
295
+ }]
296
+ })
297
+
298
+ messages.append({
299
+ "role": "tool",
300
+ "tool_call_id": tool_call.id,
301
+ "content": json.dumps(tool_result)
302
+ })
303
+
304
+ # Second API call to get natural language response
305
+ final_response = self.client.chat.completions.create(
306
+ model=self.model,
307
+ messages=messages,
308
+ temperature=0.3,
309
+ max_tokens=500
310
+ )
311
+
312
+ final_content = final_response.choices[0].message.content or ""
313
+
314
+ return {
315
+ "content": final_content,
316
+ "tool_calls": executed_tool_calls if executed_tool_calls else None,
317
+ "model": self.model,
318
+ "finish_reason": final_response.choices[0].finish_reason
319
+ }
320
+
321
+ else:
322
+ # No tool calls, return direct response
323
+ content = assistant_message.content or ""
324
+
325
+ return {
326
+ "content": content,
327
+ "tool_calls": None,
328
+ "model": self.model,
329
+ "finish_reason": response.choices[0].finish_reason
330
+ }
331
+
332
+ else:
333
+ # No tools available, call without tools
334
+ response = self.client.chat.completions.create(
335
+ model=self.model,
336
+ messages=messages,
337
+ temperature=0.3,
338
+ max_tokens=500
339
+ )
340
+
341
+ content = response.choices[0].message.content or ""
342
+
343
+ return {
344
+ "content": content,
345
+ "tool_calls": None,
346
+ "model": self.model,
347
+ "finish_reason": response.choices[0].finish_reason
348
+ }
349
+
350
+ except Exception as e:
351
+ # Log error and re-raise
352
+ print(f"Error processing message with OpenAI: {str(e)}")
353
+ raise
354
+
355
+ def format_conversation_history(
356
+ self,
357
+ messages: List[Any]
358
+ ) -> List[Dict[str, str]]:
359
+ """
360
+ Format database messages into OpenAI conversation history format.
361
+
362
+ Args:
363
+ messages: List of Message model instances from database
364
+
365
+ Returns:
366
+ List of message dicts in OpenAI format
367
+ """
368
+ history = []
369
+ for msg in messages:
370
+ history.append({
371
+ "role": msg.role.value if hasattr(msg.role, 'value') else msg.role,
372
+ "content": msg.content
373
+ })
374
+ return history
src/services/cohere_agent_service.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cohere Agent Service Module
3
+
4
+ This module provides the CohereAgentService class that uses Cohere API for chat.
5
+ """
6
+
7
+ from typing import List, Dict, Any
8
+ import json
9
+ import time
10
+ import cohere
11
+ from ..config import settings
12
+ from ..tools.mcp_server import mcp_server
13
+ from ..tools import (
14
+ get_list_tasks_definition,
15
+ get_create_task_definition,
16
+ get_mark_complete_definition,
17
+ get_update_task_definition,
18
+ get_delete_task_definition,
19
+ get_get_task_definition
20
+ )
21
+ from ..database import engine
22
+
23
+
24
+ class CohereAgentService:
25
+ """
26
+ Cohere Agent Service for processing conversational task management requests.
27
+ """
28
+
29
+ def __init__(self, user_id: int):
30
+ """
31
+ Initialize CohereAgentService for a specific user.
32
+
33
+ Args:
34
+ user_id: Authenticated user ID for data scoping
35
+ """
36
+ self.user_id = user_id
37
+ self.client = cohere.Client(api_key=settings.COHERE_API_KEY)
38
+ self.model = settings.COHERE_MODEL
39
+ self.mcp_context = mcp_server.create_context(user_id=user_id)
40
+
41
+ def create_user_scoped_tools(self) -> List[Dict[str, Any]]:
42
+ """
43
+ Create user-scoped tool definitions for Cohere function calling.
44
+
45
+ Converts OpenAI-style tool definitions to Cohere format.
46
+ """
47
+ openai_tools = [
48
+ get_list_tasks_definition(),
49
+ get_create_task_definition(),
50
+ get_mark_complete_definition(),
51
+ get_update_task_definition(),
52
+ get_delete_task_definition(),
53
+ get_get_task_definition()
54
+ ]
55
+
56
+ # Convert OpenAI format to Cohere format
57
+ cohere_tools = []
58
+ for tool in openai_tools:
59
+ func = tool["function"]
60
+ cohere_tool = {
61
+ "name": func["name"],
62
+ "description": func["description"],
63
+ "parameter_definitions": {}
64
+ }
65
+
66
+ # Convert parameters
67
+ if "parameters" in func and "properties" in func["parameters"]:
68
+ for param_name, param_info in func["parameters"]["properties"].items():
69
+ cohere_tool["parameter_definitions"][param_name] = {
70
+ "description": param_info.get("description", ""),
71
+ "type": param_info.get("type", "string"),
72
+ "required": param_name in func["parameters"].get("required", [])
73
+ }
74
+
75
+ cohere_tools.append(cohere_tool)
76
+
77
+ return cohere_tools
78
+
79
+ async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
80
+ """
81
+ Execute a tool with the given arguments.
82
+ """
83
+ try:
84
+ # Get the tool handler from MCP server
85
+ tool_handler = mcp_server.get_tool(tool_name)
86
+
87
+ if not tool_handler:
88
+ return {"error": f"Tool '{tool_name}' not found"}
89
+
90
+ # Execute with user context
91
+ result = await tool_handler(self.mcp_context, **arguments)
92
+
93
+ return result
94
+
95
+ except Exception as e:
96
+ return {"error": str(e)}
97
+
98
+ async def process_message(
99
+ self,
100
+ message: str,
101
+ conversation_history: List[Dict[str, str]]
102
+ ) -> Dict[str, Any]:
103
+ """
104
+ Process a user message using Cohere API.
105
+ """
106
+ try:
107
+ # Get tools
108
+ tools = self.create_user_scoped_tools()
109
+
110
+ # Convert conversation history to Cohere format
111
+ chat_history = []
112
+ for msg in conversation_history:
113
+ if msg["role"] == "user":
114
+ chat_history.append({
115
+ "role": "USER",
116
+ "message": msg["content"]
117
+ })
118
+ elif msg["role"] == "assistant":
119
+ chat_history.append({
120
+ "role": "CHATBOT",
121
+ "message": msg["content"]
122
+ })
123
+
124
+ # System message (preamble in Cohere)
125
+ preamble = """You are a helpful task management assistant for KIro Todo application.
126
+
127
+ Your role is to help users manage their tasks through natural language conversation. You have access to tools for:
128
+ - Listing tasks
129
+ - Creating new tasks
130
+ - Updating tasks
131
+ - Deleting tasks
132
+ - Marking tasks as complete/incomplete
133
+
134
+ Be friendly, concise, and helpful. Always confirm actions clearly."""
135
+
136
+ # Call Cohere API with tools
137
+ response = self.client.chat(
138
+ model=self.model,
139
+ message=message,
140
+ chat_history=chat_history,
141
+ tools=tools,
142
+ preamble=preamble,
143
+ temperature=0.7
144
+ )
145
+
146
+ executed_tool_calls = []
147
+
148
+ # Check if model wants to use tools
149
+ if response.tool_calls:
150
+ # Execute each tool call
151
+ for tool_call in response.tool_calls:
152
+ tool_name = tool_call.name
153
+ tool_args = tool_call.parameters
154
+
155
+ # Execute tool
156
+ start_time = time.time()
157
+ tool_result = await self.execute_tool(tool_name, tool_args)
158
+ duration_ms = int((time.time() - start_time) * 1000)
159
+
160
+ executed_tool_calls.append({
161
+ "tool": tool_name,
162
+ "parameters": tool_args,
163
+ "result": tool_result,
164
+ "duration_ms": duration_ms
165
+ })
166
+
167
+ # Make second call with tool results
168
+ tool_results = [
169
+ {
170
+ "call": {
171
+ "name": tc["tool"],
172
+ "parameters": tc["parameters"]
173
+ },
174
+ "outputs": [tc["result"]]
175
+ }
176
+ for tc in executed_tool_calls
177
+ ]
178
+
179
+ final_response = self.client.chat(
180
+ model=self.model,
181
+ message=message,
182
+ chat_history=chat_history,
183
+ tools=tools,
184
+ tool_results=tool_results,
185
+ preamble=preamble,
186
+ temperature=0.7
187
+ )
188
+
189
+ return {
190
+ "content": final_response.text,
191
+ "tool_calls": executed_tool_calls if executed_tool_calls else None,
192
+ "model": self.model,
193
+ "finish_reason": "complete"
194
+ }
195
+ else:
196
+ # No tool calls
197
+ return {
198
+ "content": response.text,
199
+ "tool_calls": None,
200
+ "model": self.model,
201
+ "finish_reason": "complete"
202
+ }
203
+
204
+ except Exception as e:
205
+ print(f"Error processing message with Cohere: {str(e)}")
206
+ raise
207
+
208
+ def format_conversation_history(
209
+ self,
210
+ messages: List[Any]
211
+ ) -> List[Dict[str, str]]:
212
+ """
213
+ Format database messages for Cohere API.
214
+ """
215
+ formatted = []
216
+ for msg in messages:
217
+ formatted.append({
218
+ "role": msg.role.value,
219
+ "content": msg.content
220
+ })
221
+ return formatted
src/services/conversation_service.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ConversationService for managing chat conversations and messages.
3
+
4
+ This service handles CRUD operations for conversations, message creation
5
+ with sequence numbering, and conversation history retrieval.
6
+ """
7
+ from sqlmodel import Session, select, func
8
+ from typing import List, Optional, Dict, Any
9
+ from datetime import datetime
10
+ from ..models.conversation import Conversation
11
+ from ..models.message import Message, MessageRole
12
+
13
+
14
+ class ConversationService:
15
+ """
16
+ Service for managing conversations and messages.
17
+
18
+ This service provides:
19
+ - Conversation CRUD operations
20
+ - Message creation with automatic sequence numbering
21
+ - Conversation history retrieval
22
+ - User-scoped data access
23
+ """
24
+
25
+ def __init__(self, session: Session):
26
+ """
27
+ Initialize the ConversationService.
28
+
29
+ Args:
30
+ session: SQLModel database session
31
+ """
32
+ self.session = session
33
+
34
+ def create_conversation(self, user_id: int) -> Conversation:
35
+ """
36
+ Create a new conversation for a user.
37
+
38
+ Args:
39
+ user_id: ID of the user creating the conversation
40
+
41
+ Returns:
42
+ Conversation: The newly created conversation
43
+
44
+ Raises:
45
+ Exception: If database operation fails
46
+ """
47
+ conversation = Conversation(
48
+ user_id=user_id,
49
+ created_at=datetime.utcnow(),
50
+ updated_at=datetime.utcnow()
51
+ )
52
+
53
+ self.session.add(conversation)
54
+ self.session.commit()
55
+ self.session.refresh(conversation)
56
+
57
+ return conversation
58
+
59
+ def get_conversation(
60
+ self,
61
+ conversation_id: int,
62
+ user_id: int
63
+ ) -> Optional[Conversation]:
64
+ """
65
+ Retrieve a specific conversation by ID.
66
+
67
+ This method enforces user_id scoping - users can only access
68
+ their own conversations.
69
+
70
+ Args:
71
+ conversation_id: ID of the conversation to retrieve
72
+ user_id: ID of the authenticated user
73
+
74
+ Returns:
75
+ Conversation if found and belongs to user, None otherwise
76
+ """
77
+ statement = select(Conversation).where(
78
+ Conversation.id == conversation_id,
79
+ Conversation.user_id == user_id
80
+ )
81
+ return self.session.exec(statement).first()
82
+
83
+ def list_conversations(
84
+ self,
85
+ user_id: int,
86
+ limit: int = 50,
87
+ offset: int = 0
88
+ ) -> List[Conversation]:
89
+ """
90
+ List all conversations for a user.
91
+
92
+ Conversations are returned in reverse chronological order
93
+ (most recent first).
94
+
95
+ Args:
96
+ user_id: ID of the authenticated user
97
+ limit: Maximum number of conversations to return (default: 50)
98
+ offset: Number of conversations to skip (default: 0)
99
+
100
+ Returns:
101
+ List of conversations belonging to the user
102
+ """
103
+ statement = (
104
+ select(Conversation)
105
+ .where(Conversation.user_id == user_id)
106
+ .order_by(Conversation.updated_at.desc())
107
+ .limit(limit)
108
+ .offset(offset)
109
+ )
110
+ return list(self.session.exec(statement).all())
111
+
112
+ def delete_conversation(
113
+ self,
114
+ conversation_id: int,
115
+ user_id: int
116
+ ) -> bool:
117
+ """
118
+ Delete a conversation and all its messages.
119
+
120
+ This method enforces user_id scoping - users can only delete
121
+ their own conversations.
122
+
123
+ Args:
124
+ conversation_id: ID of the conversation to delete
125
+ user_id: ID of the authenticated user
126
+
127
+ Returns:
128
+ True if conversation was deleted, False if not found
129
+ """
130
+ conversation = self.get_conversation(conversation_id, user_id)
131
+ if not conversation:
132
+ return False
133
+
134
+ self.session.delete(conversation)
135
+ self.session.commit()
136
+ return True
137
+
138
+ def create_message(
139
+ self,
140
+ conversation_id: int,
141
+ user_id: int,
142
+ role: MessageRole,
143
+ content: str,
144
+ tool_calls: Optional[Dict[str, Any]] = None
145
+ ) -> Optional[Message]:
146
+ """
147
+ Create a new message in a conversation with automatic sequence numbering.
148
+
149
+ This method:
150
+ 1. Verifies the conversation exists and belongs to the user
151
+ 2. Calculates the next sequence number
152
+ 3. Creates the message with proper sequencing
153
+ 4. Updates the conversation's updated_at timestamp
154
+
155
+ Args:
156
+ conversation_id: ID of the conversation
157
+ user_id: ID of the authenticated user
158
+ role: Message role (USER or ASSISTANT)
159
+ content: Message content
160
+ tool_calls: Optional tool call metadata
161
+
162
+ Returns:
163
+ Message if created successfully, None if conversation not found
164
+
165
+ Raises:
166
+ Exception: If database operation fails
167
+ """
168
+ # Verify conversation exists and belongs to user
169
+ conversation = self.get_conversation(conversation_id, user_id)
170
+ if not conversation:
171
+ return None
172
+
173
+ # Get next sequence number
174
+ sequence_number = self._get_next_sequence_number(conversation_id)
175
+
176
+ # Create message
177
+ message = Message(
178
+ conversation_id=conversation_id,
179
+ role=role,
180
+ content=content,
181
+ tool_calls=tool_calls,
182
+ sequence_number=sequence_number,
183
+ created_at=datetime.utcnow()
184
+ )
185
+
186
+ self.session.add(message)
187
+
188
+ # Update conversation timestamp
189
+ conversation.updated_at = datetime.utcnow()
190
+ self.session.add(conversation)
191
+
192
+ self.session.commit()
193
+ self.session.refresh(message)
194
+
195
+ return message
196
+
197
+ def get_conversation_history(
198
+ self,
199
+ conversation_id: int,
200
+ user_id: int,
201
+ include_deleted: bool = False
202
+ ) -> List[Message]:
203
+ """
204
+ Retrieve all messages in a conversation in chronological order.
205
+
206
+ This method enforces user_id scoping - users can only access
207
+ messages from their own conversations.
208
+
209
+ Args:
210
+ conversation_id: ID of the conversation
211
+ user_id: ID of the authenticated user
212
+ include_deleted: Whether to include soft-deleted messages (default: False)
213
+
214
+ Returns:
215
+ List of messages ordered by sequence_number (oldest first)
216
+ Empty list if conversation not found or doesn't belong to user
217
+ """
218
+ # Verify conversation exists and belongs to user
219
+ conversation = self.get_conversation(conversation_id, user_id)
220
+ if not conversation:
221
+ return []
222
+
223
+ # Build query
224
+ statement = (
225
+ select(Message)
226
+ .where(Message.conversation_id == conversation_id)
227
+ .order_by(Message.sequence_number.asc())
228
+ )
229
+
230
+ # Filter out deleted messages unless requested
231
+ if not include_deleted:
232
+ statement = statement.where(Message.deleted_at.is_(None))
233
+
234
+ return list(self.session.exec(statement).all())
235
+
236
+ def get_message_count(
237
+ self,
238
+ conversation_id: int,
239
+ user_id: int
240
+ ) -> int:
241
+ """
242
+ Get the total number of messages in a conversation.
243
+
244
+ Args:
245
+ conversation_id: ID of the conversation
246
+ user_id: ID of the authenticated user
247
+
248
+ Returns:
249
+ Number of messages (excluding deleted), 0 if conversation not found
250
+ """
251
+ # Verify conversation exists and belongs to user
252
+ conversation = self.get_conversation(conversation_id, user_id)
253
+ if not conversation:
254
+ return 0
255
+
256
+ statement = (
257
+ select(func.count(Message.id))
258
+ .where(
259
+ Message.conversation_id == conversation_id,
260
+ Message.deleted_at.is_(None)
261
+ )
262
+ )
263
+ return self.session.exec(statement).one()
264
+
265
+ def _get_next_sequence_number(self, conversation_id: int) -> int:
266
+ """
267
+ Calculate the next sequence number for a message in a conversation.
268
+
269
+ This method finds the highest existing sequence number and adds 1.
270
+ If no messages exist, returns 1.
271
+
272
+ Args:
273
+ conversation_id: ID of the conversation
274
+
275
+ Returns:
276
+ Next sequence number to use
277
+ """
278
+ statement = (
279
+ select(func.max(Message.sequence_number))
280
+ .where(Message.conversation_id == conversation_id)
281
+ )
282
+ max_sequence = self.session.exec(statement).one()
283
+
284
+ # If no messages exist, start at 1
285
+ if max_sequence is None:
286
+ return 1
287
+
288
+ return max_sequence + 1
289
+
290
+ def soft_delete_message(
291
+ self,
292
+ message_id: int,
293
+ conversation_id: int,
294
+ user_id: int
295
+ ) -> bool:
296
+ """
297
+ Soft delete a message (set deleted_at timestamp).
298
+
299
+ This method enforces user_id scoping - users can only delete
300
+ messages from their own conversations.
301
+
302
+ Args:
303
+ message_id: ID of the message to delete
304
+ conversation_id: ID of the conversation
305
+ user_id: ID of the authenticated user
306
+
307
+ Returns:
308
+ True if message was deleted, False if not found
309
+ """
310
+ # Verify conversation belongs to user
311
+ conversation = self.get_conversation(conversation_id, user_id)
312
+ if not conversation:
313
+ return False
314
+
315
+ # Find message
316
+ statement = select(Message).where(
317
+ Message.id == message_id,
318
+ Message.conversation_id == conversation_id
319
+ )
320
+ message = self.session.exec(statement).first()
321
+
322
+ if not message:
323
+ return False
324
+
325
+ # Soft delete
326
+ message.deleted_at = datetime.utcnow()
327
+ self.session.add(message)
328
+ self.session.commit()
329
+
330
+ return True
src/tools/__init__.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Tools Package
3
+
4
+ This package contains all MCP tools for the AI agent.
5
+ Tools are user-scoped and enforce data isolation.
6
+ """
7
+
8
+ from .mcp_server import MCPServer, MCPContext, mcp_server
9
+ from .list_tasks import list_tasks_internal, get_tool_definition as get_list_tasks_definition
10
+ from .create_task import create_task_internal, get_tool_definition as get_create_task_definition
11
+ from .mark_complete import mark_complete_internal, get_tool_definition as get_mark_complete_definition
12
+ from .update_task import update_task_internal, get_tool_definition as get_update_task_definition
13
+ from .delete_task import delete_task_internal, get_tool_definition as get_delete_task_definition
14
+ from .get_task import get_task_internal, get_tool_definition as get_get_task_definition
15
+
16
+ # Register tools with MCP server
17
+ mcp_server.register_tool("list_tasks", list_tasks_internal)
18
+ mcp_server.register_tool("create_task", create_task_internal)
19
+ mcp_server.register_tool("mark_complete", mark_complete_internal)
20
+ mcp_server.register_tool("update_task", update_task_internal)
21
+ mcp_server.register_tool("delete_task", delete_task_internal)
22
+ mcp_server.register_tool("get_task", get_task_internal)
23
+
24
+ __all__ = [
25
+ "MCPServer",
26
+ "MCPContext",
27
+ "mcp_server",
28
+ "list_tasks_internal",
29
+ "create_task_internal",
30
+ "mark_complete_internal",
31
+ "update_task_internal",
32
+ "delete_task_internal",
33
+ "get_task_internal",
34
+ "get_list_tasks_definition",
35
+ "get_create_task_definition",
36
+ "get_mark_complete_definition",
37
+ "get_update_task_definition",
38
+ "get_delete_task_definition",
39
+ "get_get_task_definition",
40
+ ]
src/tools/create_task.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Create Task MCP Tool
3
+
4
+ This tool enables the AI agent to create new tasks for the authenticated user.
5
+ Enforces user_id scoping and validates input parameters.
6
+ """
7
+
8
+ from typing import Dict, Any
9
+ from sqlmodel import Session
10
+ from datetime import datetime
11
+ from ..models.task import Task
12
+ from .mcp_server import MCPContext
13
+
14
+
15
+ async def create_task_internal(
16
+ ctx: MCPContext,
17
+ title: str,
18
+ description: str = ""
19
+ ) -> Dict[str, Any]:
20
+ """
21
+ Internal MCP tool for creating a new task.
22
+
23
+ This function is called by the AgentService with user_id pre-bound.
24
+ It creates a task in the database and returns structured result.
25
+
26
+ Args:
27
+ ctx: MCP context containing db_engine and user_id
28
+ title: Task title (required, 1-200 chars)
29
+ description: Task description (optional, max 2000 chars)
30
+
31
+ Returns:
32
+ Dict containing:
33
+ - task: Created task details (id, title, description, completed, timestamps)
34
+ - status: "success" or "error"
35
+ - error: Error message (only if status is "error")
36
+
37
+ Error Cases:
38
+ - Empty title: Returns error "Title is required"
39
+ - Title too long: Returns error "Title exceeds 200 characters"
40
+ - Database error: Returns error with message
41
+ """
42
+ try:
43
+ # Validate title
44
+ if not title or title.strip() == "":
45
+ return {
46
+ "status": "error",
47
+ "error": "Title is required"
48
+ }
49
+
50
+ title = title.strip()
51
+
52
+ if len(title) > 200:
53
+ return {
54
+ "status": "error",
55
+ "error": "Title exceeds 200 characters"
56
+ }
57
+
58
+ # Validate description length
59
+ if description and len(description) > 2000:
60
+ return {
61
+ "status": "error",
62
+ "error": "Description exceeds 2000 characters"
63
+ }
64
+
65
+ # Create task in database
66
+ with ctx.get_session() as session:
67
+ task = Task(
68
+ title=title,
69
+ description=description if description else None,
70
+ completed=False,
71
+ user_id=ctx.user_id,
72
+ created_at=datetime.utcnow(),
73
+ updated_at=datetime.utcnow()
74
+ )
75
+
76
+ session.add(task)
77
+ session.commit()
78
+ session.refresh(task)
79
+
80
+ # Return structured result
81
+ return {
82
+ "status": "success",
83
+ "task": {
84
+ "id": task.id,
85
+ "title": task.title,
86
+ "description": task.description,
87
+ "completed": task.completed,
88
+ "created_at": task.created_at.isoformat(),
89
+ "updated_at": task.updated_at.isoformat()
90
+ }
91
+ }
92
+
93
+ except Exception as e:
94
+ # Log error and return structured error response
95
+ print(f"Error creating task: {str(e)}")
96
+ return {
97
+ "status": "error",
98
+ "error": f"Database error: {str(e)}"
99
+ }
100
+
101
+
102
+ def get_tool_definition() -> Dict[str, Any]:
103
+ """
104
+ Get OpenAI function calling definition for create_task tool.
105
+
106
+ Returns:
107
+ Tool definition in OpenAI function calling format
108
+ """
109
+ return {
110
+ "type": "function",
111
+ "function": {
112
+ "name": "create_task",
113
+ "description": (
114
+ "Create a new task for the user. "
115
+ "Use this when the user wants to add a task, reminder, or todo item. "
116
+ "Examples: 'remind me to X', 'add task X', 'I need to X', 'create a task for X'."
117
+ ),
118
+ "parameters": {
119
+ "type": "object",
120
+ "properties": {
121
+ "title": {
122
+ "type": "string",
123
+ "description": "The task title or main description (required, 1-200 characters)"
124
+ },
125
+ "description": {
126
+ "type": "string",
127
+ "description": "Additional details or notes about the task (optional, max 2000 characters)"
128
+ }
129
+ },
130
+ "required": ["title"]
131
+ }
132
+ }
133
+ }
src/tools/delete_task.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Delete Task MCP Tool
3
+
4
+ This tool enables the AI agent to permanently delete tasks.
5
+ Enforces user_id scoping for security.
6
+ """
7
+
8
+ from typing import Dict, Any
9
+ from sqlmodel import Session, select
10
+ from ..models.task import Task
11
+ from .mcp_server import MCPContext
12
+
13
+
14
+ async def delete_task_internal(
15
+ ctx: MCPContext,
16
+ task_id: int
17
+ ) -> Dict[str, Any]:
18
+ """
19
+ Internal MCP tool for deleting a task.
20
+
21
+ This function is called by the AgentService with user_id pre-bound.
22
+ It permanently removes the task from the database.
23
+
24
+ Args:
25
+ ctx: MCP context containing db_engine and user_id
26
+ task_id: ID of the task to delete
27
+
28
+ Returns:
29
+ Dict containing:
30
+ - status: "success" or "error"
31
+ - message: Confirmation message
32
+ - deleted_task_id: ID of the deleted task
33
+ - error: Error message (only if status is "error")
34
+
35
+ Error Cases:
36
+ - Task not found: Returns error "Task not found"
37
+ - Task belongs to different user: Returns error "Task not found" (security)
38
+ - Database error: Returns error with message
39
+ """
40
+ try:
41
+ # Query task with user_id scoping for security
42
+ with ctx.get_session() as session:
43
+ statement = select(Task).where(
44
+ Task.id == task_id,
45
+ Task.user_id == ctx.user_id
46
+ )
47
+ task = session.exec(statement).first()
48
+
49
+ if not task:
50
+ return {
51
+ "status": "error",
52
+ "error": "Task not found"
53
+ }
54
+
55
+ # Store task title for confirmation message
56
+ task_title = task.title
57
+
58
+ # Delete task
59
+ session.delete(task)
60
+ session.commit()
61
+
62
+ # Return structured result
63
+ return {
64
+ "status": "success",
65
+ "message": f"Task '{task_title}' deleted successfully",
66
+ "deleted_task_id": task_id
67
+ }
68
+
69
+ except Exception as e:
70
+ # Log error and return structured error response
71
+ print(f"Error deleting task: {str(e)}")
72
+ return {
73
+ "status": "error",
74
+ "error": f"Database error: {str(e)}"
75
+ }
76
+
77
+
78
+ def get_tool_definition() -> Dict[str, Any]:
79
+ """
80
+ Get OpenAI function calling definition for delete_task tool.
81
+
82
+ Returns:
83
+ Tool definition in OpenAI function calling format
84
+ """
85
+ return {
86
+ "type": "function",
87
+ "function": {
88
+ "name": "delete_task",
89
+ "description": (
90
+ "Permanently delete a task. "
91
+ "Use this when the user wants to remove, delete, or cancel a task. "
92
+ "Examples: 'delete X', 'remove the task', 'cancel task 3', "
93
+ "'get rid of X', 'delete my first task'."
94
+ ),
95
+ "parameters": {
96
+ "type": "object",
97
+ "properties": {
98
+ "task_id": {
99
+ "type": "integer",
100
+ "description": "The ID of the task to delete"
101
+ }
102
+ },
103
+ "required": ["task_id"]
104
+ }
105
+ }
106
+ }
src/tools/get_task.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Get Task MCP Tool
3
+
4
+ This tool enables the AI agent to retrieve details of a specific task by ID.
5
+ Useful for multi-step operations and context-aware task references.
6
+ """
7
+
8
+ from typing import Dict, Any
9
+ from sqlmodel import Session, select
10
+ from ..models.task import Task
11
+ from .mcp_server import MCPContext
12
+
13
+
14
+ async def get_task_internal(
15
+ ctx: MCPContext,
16
+ task_id: int
17
+ ) -> Dict[str, Any]:
18
+ """
19
+ Internal MCP tool for retrieving a specific task by ID.
20
+
21
+ This function is called by the AgentService with user_id pre-bound.
22
+ It retrieves detailed information about a single task.
23
+
24
+ Args:
25
+ ctx: MCP context containing db_engine and user_id
26
+ task_id: ID of the task to retrieve
27
+
28
+ Returns:
29
+ Dict containing:
30
+ - task: Task details (id, title, description, completed, timestamps)
31
+ - status: "success" or "error"
32
+ - error: Error message (only if status is "error")
33
+
34
+ Error Cases:
35
+ - Task not found: Returns error "Task not found"
36
+ - Task belongs to different user: Returns error "Task not found" (security)
37
+ - Invalid task_id: Returns error "Invalid task ID"
38
+ - Database error: Returns error with message
39
+ """
40
+ try:
41
+ # Validate task_id
42
+ if not isinstance(task_id, int) or task_id <= 0:
43
+ return {
44
+ "status": "error",
45
+ "error": "Invalid task ID"
46
+ }
47
+
48
+ # Query task with user_id scoping for security
49
+ with ctx.get_session() as session:
50
+ statement = select(Task).where(
51
+ Task.id == task_id,
52
+ Task.user_id == ctx.user_id
53
+ )
54
+ task = session.exec(statement).first()
55
+
56
+ if not task:
57
+ return {
58
+ "status": "error",
59
+ "error": "Task not found"
60
+ }
61
+
62
+ # Return structured result
63
+ return {
64
+ "status": "success",
65
+ "task": {
66
+ "id": task.id,
67
+ "title": task.title,
68
+ "description": task.description,
69
+ "completed": task.completed,
70
+ "created_at": task.created_at.isoformat(),
71
+ "updated_at": task.updated_at.isoformat()
72
+ }
73
+ }
74
+
75
+ except Exception as e:
76
+ # Log error and return structured error response
77
+ print(f"Error getting task: {str(e)}")
78
+ return {
79
+ "status": "error",
80
+ "error": f"Database error: {str(e)}"
81
+ }
82
+
83
+
84
+ def get_tool_definition() -> Dict[str, Any]:
85
+ """
86
+ Get OpenAI function calling definition for get_task tool.
87
+
88
+ Returns:
89
+ Tool definition in OpenAI function calling format
90
+ """
91
+ return {
92
+ "type": "function",
93
+ "function": {
94
+ "name": "get_task",
95
+ "description": (
96
+ "Retrieve detailed information about a specific task by its ID. "
97
+ "Use this when the user asks for details about a particular task. "
98
+ "Examples: 'show me task 5', 'what's in task 3', 'details of the first task', "
99
+ "'tell me about task 10'."
100
+ ),
101
+ "parameters": {
102
+ "type": "object",
103
+ "properties": {
104
+ "task_id": {
105
+ "type": "integer",
106
+ "description": "The ID of the task to retrieve"
107
+ }
108
+ },
109
+ "required": ["task_id"]
110
+ }
111
+ }
112
+ }
src/tools/list_tasks.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ List Tasks MCP Tool
3
+
4
+ This tool enables the AI agent to retrieve all tasks for the authenticated user.
5
+ Returns task list with completion status and counts.
6
+ """
7
+
8
+ from typing import Dict, Any, List
9
+ from sqlmodel import Session, select
10
+ from ..models.task import Task
11
+ from .mcp_server import MCPContext
12
+
13
+
14
+ async def list_tasks_internal(ctx: MCPContext) -> Dict[str, Any]:
15
+ """
16
+ Internal MCP tool for listing all tasks for the authenticated user.
17
+
18
+ This function is called by the AgentService with user_id pre-bound.
19
+ It retrieves all tasks from the database and returns structured result.
20
+
21
+ Args:
22
+ ctx: MCP context containing db_engine and user_id
23
+
24
+ Returns:
25
+ Dict containing:
26
+ - tasks: List of task objects with all fields
27
+ - total: Total number of tasks
28
+ - completed_count: Number of completed tasks
29
+ - pending_count: Number of incomplete tasks
30
+
31
+ Error Cases:
32
+ - Database connection failure: Returns error with message
33
+ - No tasks found: Returns empty array (not an error)
34
+ """
35
+ try:
36
+ # Query tasks for user
37
+ with ctx.get_session() as session:
38
+ statement = select(Task).where(Task.user_id == ctx.user_id)
39
+ tasks = session.exec(statement).all()
40
+
41
+ # Convert tasks to dict format
42
+ task_list = []
43
+ for task in tasks:
44
+ task_list.append({
45
+ "id": task.id,
46
+ "title": task.title,
47
+ "description": task.description,
48
+ "completed": task.completed,
49
+ "created_at": task.created_at.isoformat(),
50
+ "updated_at": task.updated_at.isoformat()
51
+ })
52
+
53
+ # Calculate counts
54
+ completed_count = sum(1 for t in tasks if t.completed)
55
+ pending_count = sum(1 for t in tasks if not t.completed)
56
+
57
+ return {
58
+ "tasks": task_list,
59
+ "total": len(tasks),
60
+ "completed_count": completed_count,
61
+ "pending_count": pending_count
62
+ }
63
+
64
+ except Exception as e:
65
+ # Log error and return structured error response
66
+ print(f"Error listing tasks: {str(e)}")
67
+ return {
68
+ "status": "error",
69
+ "error": f"Database error: {str(e)}",
70
+ "tasks": [],
71
+ "total": 0,
72
+ "completed_count": 0,
73
+ "pending_count": 0
74
+ }
75
+
76
+
77
+ def get_tool_definition() -> Dict[str, Any]:
78
+ """
79
+ Get OpenAI function calling definition for list_tasks tool.
80
+
81
+ Returns:
82
+ Tool definition in OpenAI function calling format
83
+ """
84
+ return {
85
+ "type": "function",
86
+ "function": {
87
+ "name": "list_tasks",
88
+ "description": (
89
+ "Retrieve all tasks for the user with their completion status. "
90
+ "Use this when the user wants to see their tasks, check what they need to do, "
91
+ "or inquire about their task list. "
92
+ "Examples: 'show my tasks', 'what do I need to do', 'list my todos', "
93
+ "'what are my tasks', 'show me my task list'."
94
+ ),
95
+ "parameters": {
96
+ "type": "object",
97
+ "properties": {},
98
+ "required": []
99
+ }
100
+ }
101
+ }
src/tools/mark_complete.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mark Complete MCP Tool
3
+
4
+ This tool enables the AI agent to toggle task completion status.
5
+ Supports marking tasks as complete or incomplete.
6
+ """
7
+
8
+ from typing import Dict, Any
9
+ from sqlmodel import Session, select
10
+ from datetime import datetime
11
+ from ..models.task import Task
12
+ from .mcp_server import MCPContext
13
+
14
+
15
+ async def mark_complete_internal(
16
+ ctx: MCPContext,
17
+ task_id: int
18
+ ) -> Dict[str, Any]:
19
+ """
20
+ Internal MCP tool for toggling task completion status.
21
+
22
+ This function is called by the AgentService with user_id pre-bound.
23
+ It finds the task and toggles its completed status.
24
+
25
+ Args:
26
+ ctx: MCP context containing db_engine and user_id
27
+ task_id: ID of the task to mark complete/incomplete
28
+
29
+ Returns:
30
+ Dict containing:
31
+ - task: Updated task details
32
+ - status: "success" or "error"
33
+ - action: "marked_complete" or "marked_incomplete"
34
+ - error: Error message (only if status is "error")
35
+
36
+ Error Cases:
37
+ - Task not found: Returns error "Task not found"
38
+ - Task belongs to different user: Returns error "Task not found" (security)
39
+ - Database error: Returns error with message
40
+ """
41
+ try:
42
+ # Query task with user_id scoping for security
43
+ with ctx.get_session() as session:
44
+ statement = select(Task).where(
45
+ Task.id == task_id,
46
+ Task.user_id == ctx.user_id
47
+ )
48
+ task = session.exec(statement).first()
49
+
50
+ if not task:
51
+ return {
52
+ "status": "error",
53
+ "error": "Task not found"
54
+ }
55
+
56
+ # Toggle completion status
57
+ task.completed = not task.completed
58
+ task.updated_at = datetime.utcnow()
59
+
60
+ session.add(task)
61
+ session.commit()
62
+ session.refresh(task)
63
+
64
+ # Return structured result
65
+ return {
66
+ "status": "success",
67
+ "action": "marked_complete" if task.completed else "marked_incomplete",
68
+ "task": {
69
+ "id": task.id,
70
+ "title": task.title,
71
+ "description": task.description,
72
+ "completed": task.completed,
73
+ "created_at": task.created_at.isoformat(),
74
+ "updated_at": task.updated_at.isoformat()
75
+ }
76
+ }
77
+
78
+ except Exception as e:
79
+ # Log error and return structured error response
80
+ print(f"Error marking task complete: {str(e)}")
81
+ return {
82
+ "status": "error",
83
+ "error": f"Database error: {str(e)}"
84
+ }
85
+
86
+
87
+ def get_tool_definition() -> Dict[str, Any]:
88
+ """
89
+ Get OpenAI function calling definition for mark_complete tool.
90
+
91
+ Returns:
92
+ Tool definition in OpenAI function calling format
93
+ """
94
+ return {
95
+ "type": "function",
96
+ "function": {
97
+ "name": "mark_complete",
98
+ "description": (
99
+ "Toggle the completion status of a task (mark as complete or incomplete). "
100
+ "Use this when the user indicates they finished a task or wants to mark it as done. "
101
+ "Examples: 'I finished X', 'mark X as done', 'complete the task', "
102
+ "'I completed X', 'mark task 5 as complete'."
103
+ ),
104
+ "parameters": {
105
+ "type": "object",
106
+ "properties": {
107
+ "task_id": {
108
+ "type": "integer",
109
+ "description": "The ID of the task to mark as complete/incomplete"
110
+ }
111
+ },
112
+ "required": ["task_id"]
113
+ }
114
+ }
115
+ }
src/tools/mcp_server.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Server Setup Module
3
+
4
+ This module provides the MCP server configuration and context setup for the AI agent.
5
+ MCP tools are embedded in the FastAPI process for security and performance.
6
+ """
7
+
8
+ from typing import Dict, Any, Optional
9
+ from sqlalchemy.ext.asyncio import AsyncEngine
10
+ from sqlmodel import Session
11
+ from ..database import engine
12
+
13
+
14
+ class MCPContext:
15
+ """
16
+ MCP Context configuration containing shared resources for MCP tools.
17
+
18
+ This context is passed to all MCP tools to provide access to:
19
+ - Database engine for query execution
20
+ - User ID for data isolation
21
+ - Configuration settings
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ db_engine: AsyncEngine,
27
+ user_id: int,
28
+ config: Optional[Dict[str, Any]] = None
29
+ ):
30
+ """
31
+ Initialize MCP context.
32
+
33
+ Args:
34
+ db_engine: SQLAlchemy async engine for database operations
35
+ user_id: Authenticated user ID for data scoping
36
+ config: Optional configuration dictionary
37
+ """
38
+ self.db_engine = db_engine
39
+ self.user_id = user_id
40
+ self.config = config or {}
41
+
42
+ def get_session(self) -> Session:
43
+ """
44
+ Create a new database session.
45
+
46
+ Returns:
47
+ SQLModel Session instance
48
+ """
49
+ return Session(self.db_engine)
50
+
51
+
52
+ class MCPServer:
53
+ """
54
+ MCP Server setup and configuration.
55
+
56
+ This class manages the MCP server lifecycle and tool registration.
57
+ In embedded mode, tools are registered directly in the FastAPI process.
58
+ """
59
+
60
+ def __init__(self):
61
+ """Initialize MCP server."""
62
+ self.tools = {}
63
+ self.db_engine = engine
64
+
65
+ def register_tool(self, name: str, tool_func: callable):
66
+ """
67
+ Register an MCP tool.
68
+
69
+ Args:
70
+ name: Tool name (e.g., "list_tasks")
71
+ tool_func: Tool function to execute
72
+ """
73
+ self.tools[name] = tool_func
74
+
75
+ def create_context(self, user_id: int) -> MCPContext:
76
+ """
77
+ Create a user-scoped MCP context.
78
+
79
+ Args:
80
+ user_id: Authenticated user ID
81
+
82
+ Returns:
83
+ MCPContext instance with user_id pre-bound
84
+ """
85
+ return MCPContext(
86
+ db_engine=self.db_engine,
87
+ user_id=user_id
88
+ )
89
+
90
+ def get_tool(self, name: str) -> Optional[callable]:
91
+ """
92
+ Get a registered tool by name.
93
+
94
+ Args:
95
+ name: Tool name
96
+
97
+ Returns:
98
+ Tool function or None if not found
99
+ """
100
+ return self.tools.get(name)
101
+
102
+ def list_tools(self) -> list[str]:
103
+ """
104
+ List all registered tool names.
105
+
106
+ Returns:
107
+ List of tool names
108
+ """
109
+ return list(self.tools.keys())
110
+
111
+
112
+ # Global MCP server instance
113
+ mcp_server = MCPServer()
src/tools/update_task.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Update Task MCP Tool
3
+
4
+ This tool enables the AI agent to update existing task details.
5
+ Supports updating title and/or description.
6
+ """
7
+
8
+ from typing import Dict, Any, Optional
9
+ from sqlmodel import Session, select
10
+ from datetime import datetime
11
+ from ..models.task import Task
12
+ from .mcp_server import MCPContext
13
+
14
+
15
+ async def update_task_internal(
16
+ ctx: MCPContext,
17
+ task_id: int,
18
+ title: Optional[str] = None,
19
+ description: Optional[str] = None
20
+ ) -> Dict[str, Any]:
21
+ """
22
+ Internal MCP tool for updating a task's details.
23
+
24
+ This function is called by the AgentService with user_id pre-bound.
25
+ It updates the task's title and/or description.
26
+
27
+ Args:
28
+ ctx: MCP context containing db_engine and user_id
29
+ task_id: ID of the task to update
30
+ title: New task title (optional, 1-200 chars)
31
+ description: New task description (optional, max 2000 chars)
32
+
33
+ Returns:
34
+ Dict containing:
35
+ - task: Updated task details
36
+ - status: "success" or "error"
37
+ - error: Error message (only if status is "error")
38
+
39
+ Error Cases:
40
+ - Task not found: Returns error "Task not found"
41
+ - Task belongs to different user: Returns error "Task not found" (security)
42
+ - Empty title: Returns error "Title cannot be empty"
43
+ - Title too long: Returns error "Title exceeds 200 characters"
44
+ - No fields to update: Returns error "No fields provided to update"
45
+ - Database error: Returns error with message
46
+ """
47
+ try:
48
+ # Validate that at least one field is provided
49
+ if title is None and description is None:
50
+ return {
51
+ "status": "error",
52
+ "error": "No fields provided to update"
53
+ }
54
+
55
+ # Validate title if provided
56
+ if title is not None:
57
+ title = title.strip()
58
+ if not title:
59
+ return {
60
+ "status": "error",
61
+ "error": "Title cannot be empty"
62
+ }
63
+ if len(title) > 200:
64
+ return {
65
+ "status": "error",
66
+ "error": "Title exceeds 200 characters"
67
+ }
68
+
69
+ # Validate description length if provided
70
+ if description is not None and len(description) > 2000:
71
+ return {
72
+ "status": "error",
73
+ "error": "Description exceeds 2000 characters"
74
+ }
75
+
76
+ # Query task with user_id scoping for security
77
+ with ctx.get_session() as session:
78
+ statement = select(Task).where(
79
+ Task.id == task_id,
80
+ Task.user_id == ctx.user_id
81
+ )
82
+ task = session.exec(statement).first()
83
+
84
+ if not task:
85
+ return {
86
+ "status": "error",
87
+ "error": "Task not found"
88
+ }
89
+
90
+ # Update fields
91
+ if title is not None:
92
+ task.title = title
93
+ if description is not None:
94
+ task.description = description
95
+
96
+ task.updated_at = datetime.utcnow()
97
+
98
+ session.add(task)
99
+ session.commit()
100
+ session.refresh(task)
101
+
102
+ # Return structured result
103
+ return {
104
+ "status": "success",
105
+ "task": {
106
+ "id": task.id,
107
+ "title": task.title,
108
+ "description": task.description,
109
+ "completed": task.completed,
110
+ "created_at": task.created_at.isoformat(),
111
+ "updated_at": task.updated_at.isoformat()
112
+ }
113
+ }
114
+
115
+ except Exception as e:
116
+ # Log error and return structured error response
117
+ print(f"Error updating task: {str(e)}")
118
+ return {
119
+ "status": "error",
120
+ "error": f"Database error: {str(e)}"
121
+ }
122
+
123
+
124
+ def get_tool_definition() -> Dict[str, Any]:
125
+ """
126
+ Get OpenAI function calling definition for update_task tool.
127
+
128
+ Returns:
129
+ Tool definition in OpenAI function calling format
130
+ """
131
+ return {
132
+ "type": "function",
133
+ "function": {
134
+ "name": "update_task",
135
+ "description": (
136
+ "Update an existing task's title or description. "
137
+ "Use this when the user wants to modify, change, or edit a task. "
138
+ "Examples: 'change X to Y', 'update the task', 'edit task 3', "
139
+ "'rename X to Y', 'add details to the task'."
140
+ ),
141
+ "parameters": {
142
+ "type": "object",
143
+ "properties": {
144
+ "task_id": {
145
+ "type": "integer",
146
+ "description": "The ID of the task to update"
147
+ },
148
+ "title": {
149
+ "type": "string",
150
+ "description": "New task title (optional, 1-200 characters)"
151
+ },
152
+ "description": {
153
+ "type": "string",
154
+ "description": "New task description (optional, max 2000 characters)"
155
+ }
156
+ },
157
+ "required": ["task_id"]
158
+ }
159
+ }
160
+ }
src/utils/logging.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Structured Logging Utility
3
+
4
+ Provides structured logging for agent operations, tool invocations, and errors.
5
+ Enables better debugging and monitoring in production.
6
+ """
7
+
8
+ import logging
9
+ import json
10
+ from datetime import datetime
11
+ from typing import Dict, Any, Optional
12
+ import sys
13
+
14
+
15
+ class StructuredLogger:
16
+ """
17
+ Structured logger for application events.
18
+
19
+ Logs events in JSON format for easy parsing and analysis.
20
+ """
21
+
22
+ def __init__(self, name: str, level: int = logging.INFO):
23
+ """
24
+ Initialize structured logger.
25
+
26
+ Args:
27
+ name: Logger name (typically module name)
28
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
29
+ """
30
+ self.logger = logging.getLogger(name)
31
+ self.logger.setLevel(level)
32
+
33
+ # Create console handler with JSON formatter
34
+ if not self.logger.handlers:
35
+ handler = logging.StreamHandler(sys.stdout)
36
+ handler.setLevel(level)
37
+ self.logger.addHandler(handler)
38
+
39
+ def _log(
40
+ self,
41
+ level: int,
42
+ event: str,
43
+ **kwargs: Any
44
+ ):
45
+ """
46
+ Log structured event.
47
+
48
+ Args:
49
+ level: Logging level
50
+ event: Event name/description
51
+ **kwargs: Additional context fields
52
+ """
53
+ log_entry = {
54
+ "timestamp": datetime.utcnow().isoformat(),
55
+ "event": event,
56
+ **kwargs
57
+ }
58
+
59
+ self.logger.log(level, json.dumps(log_entry))
60
+
61
+ def info(self, event: str, **kwargs: Any):
62
+ """Log info level event."""
63
+ self._log(logging.INFO, event, **kwargs)
64
+
65
+ def warning(self, event: str, **kwargs: Any):
66
+ """Log warning level event."""
67
+ self._log(logging.WARNING, event, **kwargs)
68
+
69
+ def error(self, event: str, error: Optional[Exception] = None, **kwargs: Any):
70
+ """Log error level event."""
71
+ if error:
72
+ kwargs["error_type"] = type(error).__name__
73
+ kwargs["error_message"] = str(error)
74
+ self._log(logging.ERROR, event, **kwargs)
75
+
76
+ def debug(self, event: str, **kwargs: Any):
77
+ """Log debug level event."""
78
+ self._log(logging.DEBUG, event, **kwargs)
79
+
80
+ def agent_operation(
81
+ self,
82
+ user_id: int,
83
+ conversation_id: int,
84
+ message: str,
85
+ response: str,
86
+ tool_calls: Optional[list] = None,
87
+ duration_ms: Optional[int] = None
88
+ ):
89
+ """
90
+ Log agent operation with full context.
91
+
92
+ Args:
93
+ user_id: User identifier
94
+ conversation_id: Conversation identifier
95
+ message: User message
96
+ response: Agent response
97
+ tool_calls: List of tool invocations
98
+ duration_ms: Operation duration in milliseconds
99
+ """
100
+ self.info(
101
+ "agent_operation",
102
+ user_id=user_id,
103
+ conversation_id=conversation_id,
104
+ message_length=len(message),
105
+ response_length=len(response),
106
+ tool_calls_count=len(tool_calls) if tool_calls else 0,
107
+ tools_used=[tc.get("tool") for tc in tool_calls] if tool_calls else [],
108
+ duration_ms=duration_ms
109
+ )
110
+
111
+ def tool_invocation(
112
+ self,
113
+ user_id: int,
114
+ tool_name: str,
115
+ parameters: Dict[str, Any],
116
+ result: Dict[str, Any],
117
+ duration_ms: int
118
+ ):
119
+ """
120
+ Log MCP tool invocation.
121
+
122
+ Args:
123
+ user_id: User identifier
124
+ tool_name: Name of the tool
125
+ parameters: Tool parameters
126
+ result: Tool result
127
+ duration_ms: Execution duration in milliseconds
128
+ """
129
+ self.info(
130
+ "tool_invocation",
131
+ user_id=user_id,
132
+ tool=tool_name,
133
+ parameters=parameters,
134
+ status=result.get("status", "unknown"),
135
+ duration_ms=duration_ms
136
+ )
137
+
138
+ def api_request(
139
+ self,
140
+ method: str,
141
+ path: str,
142
+ user_id: Optional[int] = None,
143
+ status_code: Optional[int] = None,
144
+ duration_ms: Optional[int] = None
145
+ ):
146
+ """
147
+ Log API request.
148
+
149
+ Args:
150
+ method: HTTP method
151
+ path: Request path
152
+ user_id: User identifier (if authenticated)
153
+ status_code: Response status code
154
+ duration_ms: Request duration in milliseconds
155
+ """
156
+ self.info(
157
+ "api_request",
158
+ method=method,
159
+ path=path,
160
+ user_id=user_id,
161
+ status_code=status_code,
162
+ duration_ms=duration_ms
163
+ )
164
+
165
+ def rate_limit_exceeded(
166
+ self,
167
+ user_id: int,
168
+ endpoint: str,
169
+ limit: int,
170
+ reset_time: int
171
+ ):
172
+ """
173
+ Log rate limit exceeded event.
174
+
175
+ Args:
176
+ user_id: User identifier
177
+ endpoint: API endpoint
178
+ limit: Rate limit threshold
179
+ reset_time: When limit resets (unix timestamp)
180
+ """
181
+ self.warning(
182
+ "rate_limit_exceeded",
183
+ user_id=user_id,
184
+ endpoint=endpoint,
185
+ limit=limit,
186
+ reset_time=reset_time
187
+ )
188
+
189
+
190
+ # Global logger instances
191
+ agent_logger = StructuredLogger("agent", level=logging.INFO)
192
+ api_logger = StructuredLogger("api", level=logging.INFO)
193
+ tool_logger = StructuredLogger("tools", level=logging.INFO)
src/utils/validation.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Input Sanitization and Validation Utilities
3
+
4
+ Provides utilities for sanitizing and validating user input.
5
+ Prevents injection attacks and ensures data integrity.
6
+ """
7
+
8
+ import re
9
+ from typing import Optional
10
+ import html
11
+
12
+
13
+ def sanitize_message_content(content: str, max_length: int = 2000) -> str:
14
+ """
15
+ Sanitize user message content.
16
+
17
+ Args:
18
+ content: Raw user input
19
+ max_length: Maximum allowed length
20
+
21
+ Returns:
22
+ Sanitized content
23
+
24
+ Raises:
25
+ ValueError: If content is invalid
26
+ """
27
+ if not content or not isinstance(content, str):
28
+ raise ValueError("Message content must be a non-empty string")
29
+
30
+ # Strip whitespace
31
+ content = content.strip()
32
+
33
+ if not content:
34
+ raise ValueError("Message content cannot be empty")
35
+
36
+ # Check length
37
+ if len(content) > max_length:
38
+ raise ValueError(f"Message content exceeds {max_length} characters")
39
+
40
+ # HTML escape to prevent XSS
41
+ content = html.escape(content)
42
+
43
+ # Remove null bytes
44
+ content = content.replace('\x00', '')
45
+
46
+ # Normalize whitespace (replace multiple spaces with single space)
47
+ content = re.sub(r'\s+', ' ', content)
48
+
49
+ return content
50
+
51
+
52
+ def validate_conversation_id(conversation_id: Optional[int]) -> Optional[int]:
53
+ """
54
+ Validate conversation ID.
55
+
56
+ Args:
57
+ conversation_id: Conversation ID to validate
58
+
59
+ Returns:
60
+ Validated conversation ID or None
61
+
62
+ Raises:
63
+ ValueError: If conversation_id is invalid
64
+ """
65
+ if conversation_id is None:
66
+ return None
67
+
68
+ if not isinstance(conversation_id, int):
69
+ raise ValueError("Conversation ID must be an integer")
70
+
71
+ if conversation_id <= 0:
72
+ raise ValueError("Conversation ID must be positive")
73
+
74
+ return conversation_id
75
+
76
+
77
+ def validate_task_title(title: str, max_length: int = 200) -> str:
78
+ """
79
+ Validate and sanitize task title.
80
+
81
+ Args:
82
+ title: Task title
83
+ max_length: Maximum allowed length
84
+
85
+ Returns:
86
+ Sanitized title
87
+
88
+ Raises:
89
+ ValueError: If title is invalid
90
+ """
91
+ if not title or not isinstance(title, str):
92
+ raise ValueError("Title must be a non-empty string")
93
+
94
+ # Strip whitespace
95
+ title = title.strip()
96
+
97
+ if not title:
98
+ raise ValueError("Title cannot be empty")
99
+
100
+ # Check length
101
+ if len(title) > max_length:
102
+ raise ValueError(f"Title exceeds {max_length} characters")
103
+
104
+ # HTML escape
105
+ title = html.escape(title)
106
+
107
+ # Remove null bytes
108
+ title = title.replace('\x00', '')
109
+
110
+ return title
111
+
112
+
113
+ def validate_task_description(description: Optional[str], max_length: int = 2000) -> Optional[str]:
114
+ """
115
+ Validate and sanitize task description.
116
+
117
+ Args:
118
+ description: Task description
119
+ max_length: Maximum allowed length
120
+
121
+ Returns:
122
+ Sanitized description or None
123
+
124
+ Raises:
125
+ ValueError: If description is invalid
126
+ """
127
+ if description is None or description == "":
128
+ return None
129
+
130
+ if not isinstance(description, str):
131
+ raise ValueError("Description must be a string")
132
+
133
+ # Strip whitespace
134
+ description = description.strip()
135
+
136
+ if not description:
137
+ return None
138
+
139
+ # Check length
140
+ if len(description) > max_length:
141
+ raise ValueError(f"Description exceeds {max_length} characters")
142
+
143
+ # HTML escape
144
+ description = html.escape(description)
145
+
146
+ # Remove null bytes
147
+ description = description.replace('\x00', '')
148
+
149
+ return description