MAWB commited on
Commit
67f8819
·
1 Parent(s): ea30395
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. __pycache__/config.cpython-314.pyc +0 -0
  2. __pycache__/main.cpython-314.pyc +0 -0
  3. agents/__init__.py +5 -0
  4. agents/__pycache__/__init__.cpython-314.pyc +0 -0
  5. agents/__pycache__/todo_agent.cpython-314.pyc +0 -0
  6. agents/todo_agent.py +347 -0
  7. api/__init__.py +4 -0
  8. api/__pycache__/__init__.cpython-314.pyc +0 -0
  9. api/__pycache__/dependencies.cpython-314.pyc +0 -0
  10. api/dependencies.py +69 -0
  11. api/routes/__init__.py +6 -0
  12. api/routes/__pycache__/__init__.cpython-314.pyc +0 -0
  13. api/routes/__pycache__/chat.cpython-314.pyc +0 -0
  14. api/routes/__pycache__/health.cpython-314.pyc +0 -0
  15. api/routes/__pycache__/tasks.cpython-314.pyc +0 -0
  16. api/routes/chat.py +268 -0
  17. api/routes/health.py +41 -0
  18. api/routes/tasks.py +413 -0
  19. api/schemas/__init__.py +17 -0
  20. api/schemas/__pycache__/__init__.cpython-314.pyc +0 -0
  21. api/schemas/__pycache__/chat.cpython-314.pyc +0 -0
  22. api/schemas/chat.py +151 -0
  23. config.py +81 -0
  24. dockerfile +12 -0
  25. main.py +240 -0
  26. mcp/__init__.py +5 -0
  27. mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  28. mcp/__pycache__/server.cpython-314.pyc +0 -0
  29. mcp/__pycache__/tools.cpython-314.pyc +0 -0
  30. mcp/server.py +117 -0
  31. mcp/tools.py +314 -0
  32. models/__init__.py +7 -0
  33. models/__pycache__/__init__.cpython-314.pyc +0 -0
  34. models/__pycache__/conversation.cpython-314.pyc +0 -0
  35. models/__pycache__/message.cpython-314.pyc +0 -0
  36. models/__pycache__/task.cpython-314.pyc +0 -0
  37. models/__pycache__/user.cpython-314.pyc +0 -0
  38. models/conversation.py +68 -0
  39. models/message.py +77 -0
  40. models/task.py +82 -0
  41. models/user.py +66 -0
  42. requirements.txt +9 -0
  43. services/__init__.py +5 -0
  44. services/__pycache__/__init__.cpython-314.pyc +0 -0
  45. services/__pycache__/auth.cpython-314.pyc +0 -0
  46. services/__pycache__/chat.cpython-314.pyc +0 -0
  47. services/__pycache__/email.cpython-314.pyc +0 -0
  48. services/__pycache__/mcp.cpython-314.pyc +0 -0
  49. services/__pycache__/task.cpython-314.pyc +0 -0
  50. services/auth.py +121 -0
__pycache__/config.cpython-314.pyc ADDED
Binary file (3.42 kB). View file
 
__pycache__/main.cpython-314.pyc ADDED
Binary file (9.14 kB). View file
 
agents/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """OpenAI Agents SDK orchestration for Todo AI Chatbot."""
2
+
3
+ from src.agents.todo_agent import TodoAgent, create_todo_agent
4
+
5
+ __all__ = ["TodoAgent", "create_todo_agent"]
agents/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (376 Bytes). View file
 
agents/__pycache__/todo_agent.cpython-314.pyc ADDED
Binary file (17.1 kB). View file
 
agents/todo_agent.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TodoAgent - OpenAI Agent orchestration with MCP tools.
3
+
4
+ Per @specs/001-chatbot-mcp/plan.md Section VIII - AI Chatbot Architecture
5
+ OpenAI Agents SDK for orchestration with MCP tool calling.
6
+ """
7
+ from openai import OpenAI
8
+ from typing import List, Dict, Any, Optional, AsyncGenerator
9
+ from uuid import UUID
10
+ import logging
11
+ import json
12
+
13
+ from src.config import settings
14
+ from src.mcp.server import get_mcp_server
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class TodoAgent:
20
+ """
21
+ OpenAI Agent for Todo task management with MCP tool integration.
22
+
23
+ This agent:
24
+ - Uses OpenAI Chat Completions API with tool calling
25
+ - Integrates with MCP tools for task operations
26
+ - Maintains conversation context for multi-turn dialogs
27
+ - Enforces user isolation by passing user_id to all tool calls
28
+ """
29
+
30
+ # System prompt for friendly, concise, task-oriented behavior
31
+ SYSTEM_PROMPT = """You are a helpful task management assistant for TaskFlow. Your role is to help users manage their todo tasks through natural language.
32
+
33
+ Key behaviors:
34
+ - Be friendly and concise in your responses
35
+ - Focus on helping users create, view, complete, and delete tasks
36
+ - When users ask to add tasks, extract the task title and any description
37
+ - When users ask to see tasks, list their tasks clearly with numbers for easy reference
38
+ - When users ask to complete/delete/update tasks, ALWAYS call list_tasks FIRST to identify the task
39
+ - Users may refer to tasks by number (e.g., "first task", "task 2") or by title/content
40
+ - Use the available tools to perform all task operations
41
+ - Never make up task information - always use the tools
42
+ - The user_id parameter is automatically injected - do NOT ask the user for it
43
+
44
+ Task identification workflow:
45
+ 1. When user wants to update/delete/complete a task, FIRST call list_tasks
46
+ 2. Show the user the tasks with numbers: "1. Task title", "2. Task title", etc.
47
+ 3. If user specified a number or title, match it to get the task_id
48
+ 4. Then call the appropriate tool (update_task, delete_task, complete_task) with the task_id
49
+
50
+ Available tools:
51
+ - add_task: Create a new task with title and optional description
52
+ - list_tasks: Show all tasks with their IDs (call this FIRST before update/delete/complete)
53
+ - complete_task: Mark a task as completed (requires task_id from list_tasks)
54
+ - delete_task: Remove a task permanently (requires task_id from list_tasks)
55
+ - update_task: Change a task's title or description (requires task_id from list_tasks)
56
+
57
+ Note: user_id is automatically provided for all tool calls. Never ask the user for their user ID."""
58
+
59
+ def __init__(self, user_id: UUID):
60
+ """
61
+ Initialize the TodoAgent for a specific user.
62
+
63
+ Args:
64
+ user_id: The user's UUID for data isolation
65
+ """
66
+ self.user_id = user_id
67
+
68
+ # Log API key info for debugging (don't log full key)
69
+ key_preview = settings.openai_api_key[:10] if settings.openai_api_key else "None"
70
+ key_length = len(settings.openai_api_key) if settings.openai_api_key else 0
71
+ logger.info(f"TodoAgent initializing with OpenAI API key: {key_preview}... (length: {key_length})")
72
+
73
+ self.client = OpenAI(api_key=settings.openai_api_key)
74
+ self.model = settings.openai_model
75
+
76
+ # Get MCP server and register tools as OpenAI functions
77
+ self.mcp_server = get_mcp_server()
78
+ self.tools = self._get_openai_tools()
79
+
80
+ logger.info(f"TodoAgent initialized for user {user_id}")
81
+
82
+ def _get_openai_tools(self) -> List[Dict[str, Any]]:
83
+ """
84
+ Convert MCP tools to OpenAI function format.
85
+
86
+ Returns:
87
+ List of tool definitions in OpenAI format
88
+
89
+ Per @specs/001-chatbot-mcp/contracts/mcp-tools.json
90
+ """
91
+ tools = []
92
+
93
+ # Get all registered MCP tools from SimpleMCPRegistry
94
+ mcp_tools = self.mcp_server.list_tools()
95
+
96
+ for tool in mcp_tools:
97
+ # Convert MCP tool schema to OpenAI function format
98
+ function_def = {
99
+ "type": "function",
100
+ "function": {
101
+ "name": tool.name,
102
+ "description": tool.description,
103
+ "parameters": tool.parameters.copy() # Copy to avoid mutation
104
+ }
105
+ }
106
+
107
+ # Add user_id parameter to all tools for data isolation
108
+ # Note: user_id is auto-filled and should NOT be in required parameters
109
+ if "properties" not in function_def["function"]["parameters"]:
110
+ function_def["function"]["parameters"]["properties"] = {}
111
+
112
+ function_def["function"]["parameters"]["properties"]["user_id"] = {
113
+ "type": "string",
114
+ "description": "User ID for data isolation (auto-filled, do not ask user)",
115
+ }
116
+
117
+ # Initialize required array if not present
118
+ if "required" not in function_def["function"]["parameters"]:
119
+ function_def["function"]["parameters"]["required"] = []
120
+
121
+ # IMPORTANT: Do NOT add user_id to required - it's auto-injected
122
+ # This prevents the model from asking the user for their user_id
123
+
124
+ tools.append(function_def)
125
+
126
+ logger.info(f"Registered {len(tools)} tools with OpenAI agent")
127
+ return tools
128
+
129
+ async def process_message(
130
+ self,
131
+ user_message: str,
132
+ conversation_history: Optional[List[Dict[str, str]]] = None
133
+ ) -> AsyncGenerator[str, None]:
134
+ """
135
+ Process a user message through the agent with tool execution.
136
+
137
+ Args:
138
+ user_message: The user's message text
139
+ conversation_history: Previous messages in the conversation
140
+
141
+ Yields:
142
+ Response text chunks as they're generated
143
+
144
+ Per @specs/001-chatbot-mcp/plan.md - MCP First with OpenAI Agents
145
+ """
146
+ # Build messages array
147
+ messages = [
148
+ {"role": "system", "content": self.SYSTEM_PROMPT}
149
+ ]
150
+
151
+ # Add conversation history if provided
152
+ if conversation_history:
153
+ messages.extend(conversation_history)
154
+
155
+ # Add current user message
156
+ messages.append({"role": "user", "content": user_message})
157
+
158
+ logger.info(f"Processing message for user {self.user_id}: {user_message[:50]}...")
159
+
160
+ # Check if this is an update/delete/complete request
161
+ # If so, pre-load tasks to provide context
162
+ update_delete_keywords = ["update", "delete", "remove", "change", "modify", "complete", "finish", "mark"]
163
+ needs_task_list = any(keyword in user_message.lower() for keyword in update_delete_keywords)
164
+
165
+ if needs_task_list:
166
+ # Get tasks first and add to context
167
+ list_tool = self.mcp_server.get_tool("list_tasks")
168
+ if list_tool:
169
+ tasks_result = await list_tool.handler(user_id=str(self.user_id), include_completed=True)
170
+ if tasks_result.get("success") and tasks_result.get("tasks"):
171
+ # Add task context to system prompt
172
+ task_list = "\n".join([
173
+ f"Task {i+1}: ID={t['id']}, Title='{t['title']}', Completed={t['completed']}"
174
+ for i, t in enumerate(tasks_result["tasks"])
175
+ ])
176
+ enhanced_prompt = self.SYSTEM_PROMPT + f"\n\nCURRENT USER TASKS:\n{task_list}\n\nWhen user refers to a task by number or title, use the corresponding ID from this list."
177
+ messages[0] = {"role": "system", "content": enhanced_prompt}
178
+ logger.info(f"Pre-loaded {len(tasks_result['tasks'])} tasks for context")
179
+
180
+ try:
181
+ # Make chat completion request with tools
182
+ response = self.client.chat.completions.create(
183
+ model=self.model,
184
+ messages=messages,
185
+ tools=self.tools,
186
+ tool_choice="auto", # Let model decide when to use tools
187
+ temperature=0.7, # Slightly creative but focused
188
+ max_tokens=1000, # Reasonable response length
189
+ )
190
+
191
+ # Process response
192
+ choice = response.choices[0]
193
+ message = choice.message
194
+
195
+ # Check if model wants to call tools
196
+ if message.tool_calls:
197
+ # Execute tool calls and collect results
198
+ tool_messages = []
199
+ for tool_call in message.tool_calls:
200
+ result = await self._execute_tool_call(tool_call)
201
+ # Add tool result as a tool message
202
+ tool_messages.append({
203
+ "role": "tool",
204
+ "tool_call_id": tool_call.id,
205
+ "content": result
206
+ })
207
+
208
+ # Add assistant message with tool calls
209
+ messages.append({
210
+ "role": "assistant",
211
+ "content": message.content or "",
212
+ "tool_calls": message.tool_calls
213
+ })
214
+
215
+ # Add tool result messages
216
+ messages.extend(tool_messages)
217
+
218
+ # Get follow-up response with tool results
219
+ follow_up = self.client.chat.completions.create(
220
+ model=self.model,
221
+ messages=messages,
222
+ temperature=0.7,
223
+ max_tokens=1000,
224
+ )
225
+
226
+ if follow_up.choices[0].message.content:
227
+ yield follow_up.choices[0].message.content
228
+
229
+ # Direct text response (no tools needed)
230
+ elif message.content:
231
+ yield message.content
232
+
233
+ else:
234
+ yield "I understand. How can I help you with your tasks?"
235
+
236
+ except Exception as e:
237
+ error_type = type(e).__name__
238
+ error_msg = str(e)
239
+ logger.error(f"Error processing message: {error_type}: {error_msg}", exc_info=True)
240
+
241
+ # Provide more helpful error messages
242
+ if "Connection" in error_msg or "connect" in error_msg.lower():
243
+ yield "I'm having trouble connecting to my AI service. Please check if the OpenAI API key is configured correctly in Railway environment variables."
244
+ elif "401" in error_msg or "Unauthorized" in error_msg or "authentication" in error_msg.lower():
245
+ yield "My AI service credentials are invalid. Please check the OpenAI API key in Railway environment variables."
246
+ elif "rate" in error_msg.lower() or "limit" in error_msg.lower():
247
+ yield "I've reached my rate limit. Please try again in a moment."
248
+ else:
249
+ yield f"I encountered an error ({error_type}): {error_msg}"
250
+
251
+ async def _execute_tool_call(self, tool_call) -> str:
252
+ """
253
+ Execute a single tool call from OpenAI.
254
+
255
+ Args:
256
+ tool_call: The OpenAI tool call object
257
+
258
+ Returns:
259
+ Result message to display to user
260
+
261
+ Per @specs/001-chatbot-mcp/plan.md - MCP First architecture
262
+ """
263
+ function_name = tool_call.function.name
264
+ function_args = json.loads(tool_call.function.arguments)
265
+
266
+ # Inject user_id for data isolation
267
+ function_args["user_id"] = str(self.user_id)
268
+
269
+ logger.info(f"Executing tool: {function_name} with args: {function_args}")
270
+
271
+ try:
272
+ # Get the tool from SimpleMCPRegistry
273
+ tool = self.mcp_server.get_tool(function_name)
274
+
275
+ if not tool:
276
+ return f"Error: Tool '{function_name}' not found"
277
+
278
+ # For update_task and delete_task, validate task_id exists first
279
+ if function_name in ["update_task", "delete_task", "complete_task"]:
280
+ task_id = function_args.get("task_id")
281
+ if task_id:
282
+ # Verify the task exists and belongs to user before proceeding
283
+ list_tool = self.mcp_server.get_tool("list_tasks")
284
+ tasks_result = await list_tool.handler(user_id=str(self.user_id), include_completed=True)
285
+ valid_task_ids = [t["id"] for t in tasks_result.get("tasks", [])]
286
+
287
+ if task_id not in valid_task_ids:
288
+ # Task doesn't exist or doesn't belong to user
289
+ # Provide helpful error with current tasks
290
+ if tasks_result.get("tasks"):
291
+ task_list = "\n".join([
292
+ f"Task {i+1}: {t['title']}"
293
+ for i, t in enumerate(tasks_result["tasks"])
294
+ ])
295
+ return f"Error: Task not found. Here are your current tasks:\n{task_list}\n\nPlease specify which task you'd like to {function_name.replace('_', ' ')}."
296
+ else:
297
+ return "Error: You don't have any tasks yet. Create some tasks first!"
298
+
299
+ # Execute the tool via MCP
300
+ result = await tool.handler(**function_args)
301
+
302
+ # Parse result
303
+ if isinstance(result, dict):
304
+ if result.get("success"):
305
+ # Format success message based on tool
306
+ if function_name == "add_task":
307
+ return f"✓ Task '{result.get('title')}' created successfully!"
308
+ elif function_name == "complete_task":
309
+ return f"✓ Task '{result.get('title')}' marked as complete!"
310
+ elif function_name == "delete_task":
311
+ return f"✓ Task '{result.get('title')}' deleted."
312
+ elif function_name == "update_task":
313
+ return f"✓ Task updated successfully!"
314
+ elif function_name == "list_tasks":
315
+ tasks = result.get("tasks", [])
316
+ count = result.get("count", 0)
317
+ if count == 0:
318
+ return "You don't have any tasks yet."
319
+ # Number tasks for easy reference
320
+ task_list = "\n".join([
321
+ f"{i+1}. {t['title']}" + (" ✓" if t['completed'] else "")
322
+ for i, t in enumerate(tasks)
323
+ ])
324
+ return f"You have {count} task(s):\n{task_list}\n\nYou can refer to tasks by number (e.g., \"complete task 1\")"
325
+ else:
326
+ return "Operation completed successfully!"
327
+ else:
328
+ return f"Error: {result.get('error', 'Unknown error')}"
329
+
330
+ return str(result)
331
+
332
+ except Exception as e:
333
+ logger.error(f"Error executing tool {function_name}: {e}")
334
+ return f"Error executing {function_name}: {str(e)}"
335
+
336
+
337
+ def create_todo_agent(user_id: UUID) -> TodoAgent:
338
+ """
339
+ Factory function to create a TodoAgent instance.
340
+
341
+ Args:
342
+ user_id: The user's UUID
343
+
344
+ Returns:
345
+ Initialized TodoAgent instance
346
+ """
347
+ return TodoAgent(user_id)
api/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """API package."""
2
+ from src.api.dependencies import get_current_user, verify_user_ownership
3
+
4
+ __all__ = ["get_current_user", "verify_user_ownership"]
api/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (335 Bytes). View file
 
api/__pycache__/dependencies.cpython-314.pyc ADDED
Binary file (3.01 kB). View file
 
api/dependencies.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI dependencies for authentication and authorization.
3
+
4
+ Per @specs/001-auth-api-bridge/research.md - FastAPI Dependencies pattern
5
+ """
6
+ from fastapi import Depends, HTTPException, Request, status
7
+ from src.services.auth import verify_token
8
+
9
+
10
+ async def get_current_user(request: Request) -> str:
11
+ """
12
+ Extract and verify user_id from JWT token.
13
+
14
+ This dependency enforces JWT authentication on protected endpoints.
15
+ Per @specs/001-auth-api-bridge/research.md
16
+
17
+ Args:
18
+ request: FastAPI request object
19
+
20
+ Returns:
21
+ User ID (UUID string) from verified JWT token
22
+
23
+ Raises:
24
+ HTTPException 401: If token is missing, invalid, or expired
25
+ """
26
+ auth_header = request.headers.get("Authorization")
27
+
28
+ if not auth_header or not auth_header.startswith("Bearer "):
29
+ raise HTTPException(
30
+ status_code=status.HTTP_401_UNAUTHORIZED,
31
+ detail="Missing or invalid Authorization header",
32
+ headers={"WWW-Authenticate": "Bearer"},
33
+ )
34
+
35
+ token = auth_header.split(" ")[1]
36
+ user_id = await verify_token(token)
37
+
38
+ if user_id is None:
39
+ raise HTTPException(
40
+ status_code=status.HTTP_401_UNAUTHORIZED,
41
+ detail="Invalid or expired token",
42
+ headers={"WWW-Authenticate": "Bearer"},
43
+ )
44
+
45
+ # Attach user_id to request state for use in route handlers
46
+ request.state.user_id = user_id
47
+ return user_id
48
+
49
+
50
+ async def verify_user_ownership(request: Request, user_id: str) -> None:
51
+ """
52
+ Verify that the requested user_id matches the authenticated user.
53
+
54
+ This enforces user isolation - users can only access their own resources.
55
+ Per @specs/001-auth-api-bridge/api/rest-endpoints.md security requirements
56
+
57
+ Args:
58
+ request: FastAPI request object (contains authenticated user_id in state)
59
+ user_id: User ID from URL path parameter
60
+
61
+ Raises:
62
+ HTTPException 403: If user_id doesn't match authenticated user
63
+ """
64
+ current_user = request.state.user_id
65
+ if current_user != user_id:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_403_FORBIDDEN,
68
+ detail="Access forbidden: You can only access your own resources"
69
+ )
api/routes/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Routes package."""
2
+ from src.api.routes.tasks import router as tasks_router
3
+ from src.api.routes.health import router as health_router
4
+ from src.api.routes.chat import router as chat_router
5
+
6
+ __all__ = ["tasks_router", "health_router", "chat_router"]
api/routes/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (428 Bytes). View file
 
api/routes/__pycache__/chat.cpython-314.pyc ADDED
Binary file (9.66 kB). View file
 
api/routes/__pycache__/health.cpython-314.pyc ADDED
Binary file (2.13 kB). View file
 
api/routes/__pycache__/tasks.cpython-314.pyc ADDED
Binary file (17.6 kB). View file
 
api/routes/chat.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat API endpoints for Todo AI Chatbot.
3
+
4
+ Per @specs/001-chatbot-mcp/contracts/openapi.yaml and @specs/001-chatbot-mcp/plan.md
5
+ """
6
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
7
+ from sqlmodel import Session
8
+ from uuid import UUID
9
+ from datetime import datetime
10
+ import time
11
+ import logging
12
+
13
+ from src.config import engine
14
+ from src.api.dependencies import get_current_user
15
+ from src.api.schemas.chat import ChatRequest, ChatResponse, Message, ChatError
16
+ from src.services.chat import ChatService
17
+ from src.agents.todo_agent import create_todo_agent
18
+ from src.models.message import MessageRole
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Create router
23
+ router = APIRouter(prefix="/api", tags=["chat"])
24
+
25
+
26
+ @router.post("/{user_id}/chat", response_model=ChatResponse)
27
+ async def chat_endpoint(
28
+ user_id: str,
29
+ request: ChatRequest,
30
+ http_request: Request,
31
+ current_user: str = Depends(get_current_user)
32
+ ):
33
+ """
34
+ Chat endpoint for AI-powered task management.
35
+
36
+ Processes user messages through OpenAI agent with MCP tool integration.
37
+ Creates new conversations or continues existing ones.
38
+
39
+ Per @specs/001-chatbot-mcp/plan.md:
40
+ - Stateless architecture: history loaded from DB each request
41
+ - MCP First: all task operations through MCP tools
42
+ - Data isolation: all queries filter by user_id
43
+
44
+ Per @specs/001-chatbot-mcp/contracts/openapi.yaml
45
+ """
46
+ start_time = time.time()
47
+
48
+ # Verify user ownership
49
+ if current_user != user_id:
50
+ logger.warning(f"User {current_user} attempted to access user {user_id} chat")
51
+ raise HTTPException(
52
+ status_code=status.HTTP_403_FORBIDDEN,
53
+ detail="Access forbidden: You can only access your own chat"
54
+ )
55
+
56
+ # Parse user_id as UUID
57
+ try:
58
+ user_uuid = UUID(user_id)
59
+ except ValueError:
60
+ raise HTTPException(
61
+ status_code=status.HTTP_400_BAD_REQUEST,
62
+ detail="Invalid user ID format"
63
+ )
64
+
65
+ with Session(engine) as session:
66
+ try:
67
+ # Get or create conversation
68
+ conversation = None
69
+ conversation_id = request.conversation_id
70
+
71
+ if conversation_id:
72
+ # Validate user owns this conversation
73
+ conversation = ChatService.get_conversation(session, conversation_id, user_uuid)
74
+ if not conversation:
75
+ raise HTTPException(
76
+ status_code=status.HTTP_404_NOT_FOUND,
77
+ detail="Conversation not found or access denied"
78
+ )
79
+ else:
80
+ # Create new conversation for first message
81
+ conversation = ChatService.create_conversation(
82
+ session=session,
83
+ user_id=user_uuid,
84
+ title="New Chat" # Can be updated based on first message content
85
+ )
86
+ conversation_id = conversation.id
87
+ logger.info(f"Created new conversation {conversation_id} for user {user_id}")
88
+
89
+ # Sanitize user input
90
+ sanitized_message = ChatService.sanitize_user_input(request.message)
91
+
92
+ # Store user message
93
+ user_message = ChatService.store_message(
94
+ session=session,
95
+ conversation_id=conversation_id,
96
+ role=MessageRole.USER,
97
+ content=sanitized_message
98
+ )
99
+
100
+ # Load conversation history
101
+ history = ChatService.get_conversation_history(
102
+ session=session,
103
+ conversation_id=conversation_id,
104
+ user_id=user_uuid
105
+ )
106
+
107
+ # Format for OpenAI (exclude the message we just added)
108
+ formatted_history = ChatService.format_messages_for_openai(history[:-1])
109
+
110
+ # Create agent and process message
111
+ agent = create_todo_agent(user_uuid)
112
+
113
+ # Collect agent response
114
+ response_parts = []
115
+ async for chunk in agent.process_message(sanitized_message, formatted_history):
116
+ response_parts.append(chunk)
117
+
118
+ assistant_response = "".join(response_parts)
119
+
120
+ # Store assistant response
121
+ assistant_message = ChatService.store_message(
122
+ session=session,
123
+ conversation_id=conversation_id,
124
+ role=MessageRole.ASSISTANT,
125
+ content=assistant_response,
126
+ metadata={"processing_time": time.time() - start_time}
127
+ )
128
+
129
+ # Calculate processing time
130
+ processing_time = time.time() - start_time
131
+
132
+ # Log request
133
+ logger.info(
134
+ f"Chat processed: user={user_id}, "
135
+ f"conversation={conversation_id}, "
136
+ f"processing_time={processing_time:.2f}s, "
137
+ f"message_length={len(request.message)}"
138
+ )
139
+
140
+ # Build response
141
+ return ChatResponse(
142
+ conversation_id=conversation_id,
143
+ message=Message(
144
+ id=assistant_message.id,
145
+ role="assistant",
146
+ content=assistant_response,
147
+ created_at=assistant_message.created_at
148
+ ),
149
+ tasks=None # Could be populated with affected tasks if needed
150
+ )
151
+
152
+ except HTTPException:
153
+ # Re-raise HTTP exceptions as-is
154
+ raise
155
+
156
+ except Exception as e:
157
+ # Log error for debugging
158
+ logger.error(f"Error processing chat request: {e}", exc_info=True)
159
+
160
+ # Return user-friendly error message
161
+ raise HTTPException(
162
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
163
+ detail={
164
+ "error": "Failed to process chat message",
165
+ "message": "I encountered an error processing your request. Please try again.",
166
+ "conversation_id": str(conversation_id) if conversation_id else None
167
+ }
168
+ )
169
+
170
+
171
+ @router.get("/{user_id}/conversations")
172
+ async def list_conversations(
173
+ user_id: str,
174
+ current_user: str = Depends(get_current_user)
175
+ ):
176
+ """
177
+ List all conversations for a user.
178
+
179
+ Returns conversations ordered by most recently updated.
180
+ """
181
+ # Verify user ownership
182
+ if current_user != user_id:
183
+ raise HTTPException(
184
+ status_code=status.HTTP_403_FORBIDDEN,
185
+ detail="Access forbidden: You can only access your own conversations"
186
+ )
187
+
188
+ try:
189
+ user_uuid = UUID(user_id)
190
+ except ValueError:
191
+ raise HTTPException(
192
+ status_code=status.HTTP_400_BAD_REQUEST,
193
+ detail="Invalid user ID format"
194
+ )
195
+
196
+ with Session(engine) as session:
197
+ conversations = ChatService.get_user_conversations(session, user_uuid)
198
+
199
+ return {
200
+ "conversations": [
201
+ {
202
+ "id": str(conv.id),
203
+ "title": conv.title,
204
+ "created_at": conv.created_at.isoformat(),
205
+ "updated_at": conv.updated_at.isoformat()
206
+ }
207
+ for conv in conversations
208
+ ],
209
+ "count": len(conversations)
210
+ }
211
+
212
+
213
+ @router.get("/{user_id}/conversations/{conversation_id}/messages")
214
+ async def get_conversation_messages(
215
+ user_id: str,
216
+ conversation_id: str,
217
+ current_user: str = Depends(get_current_user)
218
+ ):
219
+ """
220
+ Get all messages in a conversation.
221
+
222
+ Requires user owns the conversation.
223
+ """
224
+ # Verify user ownership
225
+ if current_user != user_id:
226
+ raise HTTPException(
227
+ status_code=status.HTTP_403_FORBIDDEN,
228
+ detail="Access forbidden"
229
+ )
230
+
231
+ try:
232
+ user_uuid = UUID(user_id)
233
+ conv_uuid = UUID(conversation_id)
234
+ except ValueError:
235
+ raise HTTPException(
236
+ status_code=status.HTTP_400_BAD_REQUEST,
237
+ detail="Invalid ID format"
238
+ )
239
+
240
+ with Session(engine) as session:
241
+ # Verify user owns the conversation
242
+ conversation = ChatService.get_conversation(session, conv_uuid, user_uuid)
243
+ if not conversation:
244
+ raise HTTPException(
245
+ status_code=status.HTTP_404_NOT_FOUND,
246
+ detail="Conversation not found"
247
+ )
248
+
249
+ # Get messages
250
+ messages = ChatService.get_conversation_history(
251
+ session=session,
252
+ conversation_id=conv_uuid,
253
+ user_id=user_uuid
254
+ )
255
+
256
+ return {
257
+ "conversation_id": conversation_id,
258
+ "messages": [
259
+ {
260
+ "id": str(msg.id),
261
+ "role": msg.role.value,
262
+ "content": msg.content,
263
+ "created_at": msg.created_at.isoformat()
264
+ }
265
+ for msg in messages
266
+ ],
267
+ "count": len(messages)
268
+ }
api/routes/health.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Health check endpoint.
3
+
4
+ Per @specs/001-auth-api-bridge/contracts/pydantic-models.md
5
+ """
6
+ from fastapi import APIRouter
7
+ from pydantic import BaseModel
8
+ from src.config import settings, engine
9
+ from sqlalchemy import text
10
+
11
+
12
+ class HealthResponse(BaseModel):
13
+ """Health check response."""
14
+ status: str = "healthy"
15
+ database: str = "connected"
16
+ version: str = "1.0.0"
17
+
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ @router.get("/health", response_model=HealthResponse)
23
+ async def health_check():
24
+ """
25
+ Health check endpoint.
26
+
27
+ Returns service health status and database connectivity.
28
+ """
29
+ # Check database connection
30
+ try:
31
+ with engine.connect() as conn:
32
+ conn.execute(text("SELECT 1"))
33
+ db_status = "connected"
34
+ except Exception as e:
35
+ db_status = f"disconnected: {str(e)[:50]}"
36
+
37
+ return HealthResponse(
38
+ status="healthy" if db_status == "connected" else "unhealthy",
39
+ database=db_status,
40
+ version="1.0.0"
41
+ )
api/routes/tasks.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task API routes with JWT authentication and user isolation.
3
+
4
+ Per @specs/001-auth-api-bridge/api/rest-endpoints.md and
5
+ @specs/001-auth-api-bridge/contracts/pydantic-models.md
6
+ """
7
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
8
+ from sqlmodel import Session, select
9
+ from typing import List, Optional
10
+ from uuid import UUID
11
+ from datetime import datetime
12
+
13
+ from src.api.dependencies import get_current_user, verify_user_ownership
14
+ from src.services.task import TaskService
15
+ from src.config import engine
16
+ from src.models.user import UserTable
17
+ from pydantic import BaseModel, Field, constr
18
+
19
+
20
+ # =============================================================================
21
+ # Pydantic Models for Request/Response Validation
22
+ # Per @specs/001-auth-api-bridge/contracts/pydantic-models.md
23
+ # =============================================================================
24
+
25
+ class TaskCreateRequest(BaseModel):
26
+ """Request model for creating a task."""
27
+ title: constr(min_length=1, max_length=255, strip_whitespace=True) = Field(
28
+ ...,
29
+ description="Task title (1-255 characters)"
30
+ )
31
+ description: Optional[constr(max_length=5000)] = Field(
32
+ None,
33
+ description="Task description (optional, max 5000 characters)"
34
+ )
35
+ priority: str = Field(
36
+ default="medium",
37
+ description="Task priority level: low, medium, or high"
38
+ )
39
+
40
+
41
+ class TaskUpdateRequest(BaseModel):
42
+ """Request model for updating a task."""
43
+ title: Optional[constr(min_length=1, max_length=255, strip_whitespace=True)] = Field(
44
+ None,
45
+ description="Task title (1-255 characters)"
46
+ )
47
+ description: Optional[constr(max_length=5000)] = Field(
48
+ None,
49
+ description="Task description (optional, max 5000 characters)"
50
+ )
51
+ priority: Optional[str] = Field(
52
+ None,
53
+ description="Task priority level: low, medium, or high"
54
+ )
55
+
56
+
57
+ class TaskResponse(BaseModel):
58
+ """Response model for a task."""
59
+ id: UUID = Field(..., description="Unique task identifier")
60
+ title: str = Field(..., description="Task title")
61
+ description: Optional[str] = Field(None, description="Task description")
62
+ completed: bool = Field(..., description="Task completion status")
63
+ priority: str = Field(..., description="Task priority level")
64
+ created_at: str = Field(..., description="Task creation timestamp (ISO 8601)")
65
+ completed_at: Optional[str] = Field(None, description="Task completion timestamp (ISO 8601)")
66
+
67
+ model_config = {"from_attributes": True}
68
+
69
+
70
+ class TaskListResponse(BaseModel):
71
+ """Response model for a list of tasks."""
72
+ tasks: List[TaskResponse] = Field(..., description="List of tasks")
73
+ count: int = Field(..., description="Total number of tasks")
74
+
75
+
76
+ class ErrorDetail(BaseModel):
77
+ """Error detail structure."""
78
+ code: str = Field(..., description="Error code (e.g., UNAUTHORIZED, NOT_FOUND)")
79
+ message: str = Field(..., description="Human-readable error message")
80
+ details: dict = Field(default_factory=dict, description="Additional error context")
81
+
82
+
83
+ class ErrorResponse(BaseModel):
84
+ """Standard error response."""
85
+ error: ErrorDetail
86
+
87
+
88
+ # =============================================================================
89
+ # Task Routes with JWT Authentication
90
+ # =============================================================================
91
+
92
+ router = APIRouter()
93
+
94
+
95
+ def ensure_user_exists(session: Session, user_id: UUID) -> None:
96
+ """Create user if they don't exist in the database."""
97
+ user = session.get(UserTable, user_id)
98
+ if user is None:
99
+ # Create user with a placeholder email
100
+ user = UserTable(
101
+ id=user_id,
102
+ email=f"user-{str(user_id)[:8]}@placeholder.com",
103
+ created_at=datetime.utcnow(),
104
+ updated_at=datetime.utcnow()
105
+ )
106
+ session.add(user)
107
+ session.commit()
108
+ print(f"Created new user: {user_id}")
109
+
110
+
111
+ @router.post(
112
+ "/api/{user_id}/tasks",
113
+ response_model=TaskResponse,
114
+ status_code=status.HTTP_201_CREATED,
115
+ responses={
116
+ 401: {"model": ErrorResponse, "description": "Unauthorized - Invalid or missing token"},
117
+ 403: {"model": ErrorResponse, "description": "Forbidden - User ID mismatch"},
118
+ 400: {"model": ErrorResponse, "description": "Bad Request - Validation error"}
119
+ }
120
+ )
121
+ async def create_task(
122
+ user_id: str,
123
+ task_data: TaskCreateRequest,
124
+ request: Request,
125
+ current_user: str = Depends(get_current_user)
126
+ ):
127
+ """
128
+ Create a new task for the authenticated user.
129
+
130
+ Per @specs/001-auth-api-bridge/api/rest-endpoints.md
131
+
132
+ Security:
133
+ - JWT token must be valid and not expired
134
+ - user_id in path must match JWT sub claim (user ownership)
135
+ - Task is automatically assigned to authenticated user
136
+ """
137
+ # Verify user ownership: user_id in path must match authenticated user
138
+ await verify_user_ownership(request, user_id)
139
+
140
+ # Create task with user_id from verified JWT
141
+ with Session(engine) as session:
142
+ # Ensure user exists in database
143
+ ensure_user_exists(session, UUID(current_user))
144
+
145
+ task = TaskService.create_task(
146
+ session=session,
147
+ user_id=UUID(current_user),
148
+ title=task_data.title,
149
+ description=task_data.description,
150
+ priority=task_data.priority
151
+ )
152
+
153
+ # Convert datetime objects to ISO 8601 strings for JSON response
154
+ return TaskResponse(
155
+ id=task.id,
156
+ title=task.title,
157
+ description=task.description,
158
+ completed=task.completed,
159
+ priority=task.priority,
160
+ created_at=task.created_at.isoformat(),
161
+ completed_at=task.completed_at.isoformat() if task.completed_at else None
162
+ )
163
+
164
+
165
+ @router.get(
166
+ "/api/{user_id}/tasks",
167
+ response_model=TaskListResponse,
168
+ responses={
169
+ 401: {"model": ErrorResponse, "description": "Unauthorized - Invalid or missing token"},
170
+ 403: {"model": ErrorResponse, "description": "Forbidden - User ID mismatch"}
171
+ }
172
+ )
173
+ async def list_tasks(
174
+ user_id: str,
175
+ request: Request, # type: ignore
176
+ current_user: str = Depends(get_current_user)
177
+ ):
178
+ """
179
+ List all tasks for the authenticated user.
180
+
181
+ Per @specs/001-auth-api-bridge/api/rest-endpoints.md
182
+
183
+ Security:
184
+ - JWT token must be valid
185
+ - user_id in path must match JWT sub claim
186
+ - Only returns tasks owned by authenticated user
187
+ """
188
+ await verify_user_ownership(request, user_id)
189
+
190
+ with Session(engine) as session:
191
+ tasks = TaskService.get_user_tasks(session=session, user_id=UUID(current_user))
192
+
193
+ return TaskListResponse(
194
+ tasks=[
195
+ TaskResponse(
196
+ id=task.id,
197
+ title=task.title,
198
+ description=task.description,
199
+ completed=task.completed,
200
+ priority=task.priority,
201
+ created_at=task.created_at.isoformat(),
202
+ completed_at=task.completed_at.isoformat() if task.completed_at else None
203
+ )
204
+ for task in tasks
205
+ ],
206
+ count=len(tasks)
207
+ )
208
+
209
+
210
+ @router.get(
211
+ "/api/{user_id}/tasks/{task_id}",
212
+ response_model=TaskResponse,
213
+ responses={
214
+ 401: {"model": ErrorResponse, "description": "Unauthorized"},
215
+ 403: {"model": ErrorResponse, "description": "Forbidden - Task belongs to different user"},
216
+ 404: {"model": ErrorResponse, "description": "Task not found"}
217
+ }
218
+ )
219
+ async def get_task(
220
+ user_id: str,
221
+ task_id: str,
222
+ request: Request, # type: ignore
223
+ current_user: str = Depends(get_current_user)
224
+ ):
225
+ """
226
+ Get details of a specific task.
227
+
228
+ Security:
229
+ - JWT token must be valid
230
+ - user_id in path must match JWT sub claim
231
+ - Task must belong to authenticated user
232
+ """
233
+ await verify_user_ownership(request, user_id)
234
+
235
+ with Session(engine) as session:
236
+ task = TaskService.get_task_by_id(
237
+ session=session,
238
+ task_id=UUID(task_id),
239
+ user_id=UUID(current_user)
240
+ )
241
+
242
+ if not task:
243
+ raise HTTPException(
244
+ status_code=status.HTTP_404_NOT_FOUND,
245
+ detail="Task not found"
246
+ )
247
+
248
+ return TaskResponse(
249
+ id=task.id,
250
+ title=task.title,
251
+ description=task.description,
252
+ completed=task.completed,
253
+ priority=task.priority,
254
+ created_at=task.created_at.isoformat(),
255
+ completed_at=task.completed_at.isoformat() if task.completed_at else None
256
+ )
257
+
258
+
259
+ @router.patch(
260
+ "/api/{user_id}/tasks/{task_id}/complete",
261
+ response_model=TaskResponse,
262
+ responses={
263
+ 401: {"model": ErrorResponse, "description": "Unauthorized"},
264
+ 403: {"model": ErrorResponse, "description": "Forbidden - Task belongs to different user"},
265
+ 404: {"model": ErrorResponse, "description": "Task not found"}
266
+ }
267
+ )
268
+ async def complete_task(
269
+ user_id: str,
270
+ task_id: str,
271
+ request: Request, # type: ignore
272
+ current_user: str = Depends(get_current_user)
273
+ ):
274
+ """
275
+ Mark a task as completed.
276
+
277
+ Per @specs/001-auth-api-bridge/api/rest-endpoints.md
278
+
279
+ Security:
280
+ - JWT token must be valid
281
+ - user_id in path must match JWT sub claim
282
+ - Task must belong to authenticated user
283
+
284
+ Idempotent: Can be called multiple times with same result
285
+ """
286
+ await verify_user_ownership(request, user_id)
287
+
288
+ with Session(engine) as session:
289
+ task = TaskService.complete_task(
290
+ session=session,
291
+ task_id=UUID(task_id),
292
+ user_id=UUID(current_user)
293
+ )
294
+
295
+ if not task:
296
+ raise HTTPException(
297
+ status_code=status.HTTP_404_NOT_FOUND,
298
+ detail="Task not found"
299
+ )
300
+
301
+ return TaskResponse(
302
+ id=task.id,
303
+ title=task.title,
304
+ description=task.description,
305
+ completed=task.completed,
306
+ priority=task.priority,
307
+ created_at=task.created_at.isoformat(),
308
+ completed_at=task.completed_at.isoformat() if task.completed_at else None
309
+ )
310
+
311
+
312
+ @router.patch(
313
+ "/api/{user_id}/tasks/{task_id}",
314
+ response_model=TaskResponse,
315
+ responses={
316
+ 401: {"model": ErrorResponse, "description": "Unauthorized"},
317
+ 403: {"model": ErrorResponse, "description": "Forbidden - Task belongs to different user"},
318
+ 404: {"model": ErrorResponse, "description": "Task not found"}
319
+ }
320
+ )
321
+ async def update_task(
322
+ user_id: str,
323
+ task_id: str,
324
+ task_data: TaskUpdateRequest,
325
+ request: Request, # type: ignore
326
+ current_user: str = Depends(get_current_user)
327
+ ):
328
+ """
329
+ Update a task's title and/or description.
330
+
331
+ Per @specs/001-auth-api-bridge/api/rest-endpoints.md
332
+
333
+ Security:
334
+ - JWT token must be valid
335
+ - user_id in path must match JWT sub claim
336
+ - Task must belong to authenticated user
337
+ """
338
+ await verify_user_ownership(request, user_id)
339
+
340
+ with Session(engine) as session:
341
+ # Check if at least one field is being updated
342
+ if task_data.title is None and task_data.description is None and task_data.priority is None:
343
+ raise HTTPException(
344
+ status_code=status.HTTP_400_BAD_REQUEST,
345
+ detail="At least one field (title, description, or priority) must be provided"
346
+ )
347
+
348
+ task = TaskService.update_task(
349
+ session=session,
350
+ task_id=UUID(task_id),
351
+ user_id=UUID(current_user),
352
+ title=task_data.title,
353
+ description=task_data.description,
354
+ priority=task_data.priority
355
+ )
356
+
357
+ if not task:
358
+ raise HTTPException(
359
+ status_code=status.HTTP_404_NOT_FOUND,
360
+ detail="Task not found"
361
+ )
362
+
363
+ return TaskResponse(
364
+ id=task.id,
365
+ title=task.title,
366
+ description=task.description,
367
+ completed=task.completed,
368
+ priority=task.priority,
369
+ created_at=task.created_at.isoformat(),
370
+ completed_at=task.completed_at.isoformat() if task.completed_at else None
371
+ )
372
+
373
+
374
+
375
+ @router.delete(
376
+ "/api/{user_id}/tasks/{task_id}",
377
+ status_code=status.HTTP_204_NO_CONTENT,
378
+ responses={
379
+ 401: {"model": ErrorResponse, "description": "Unauthorized"},
380
+ 403: {"model": ErrorResponse, "description": "Forbidden - Task belongs to different user"},
381
+ 404: {"model": ErrorResponse, "description": "Task not found"}
382
+ }
383
+ )
384
+ async def delete_task(
385
+ user_id: str,
386
+ task_id: str,
387
+ request: Request, # type: ignore
388
+ current_user: str = Depends(get_current_user)
389
+ ):
390
+ """
391
+ Delete a task.
392
+
393
+ Security:
394
+ - JWT token must be valid
395
+ - user_id in path must match JWT sub claim
396
+ - Task must belong to authenticated user
397
+ """
398
+ await verify_user_ownership(request, user_id)
399
+
400
+ with Session(engine) as session:
401
+ success = TaskService.delete_task(
402
+ session=session,
403
+ task_id=UUID(task_id),
404
+ user_id=UUID(current_user)
405
+ )
406
+
407
+ if not success:
408
+ raise HTTPException(
409
+ status_code=status.HTTP_404_NOT_FOUND,
410
+ detail="Task not found"
411
+ )
412
+
413
+ return None
api/schemas/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas for API request/response validation."""
2
+
3
+ from src.api.schemas.chat import (
4
+ ChatRequest,
5
+ ChatResponse,
6
+ Message,
7
+ TaskSummary,
8
+ ChatError
9
+ )
10
+
11
+ __all__ = [
12
+ "ChatRequest",
13
+ "ChatResponse",
14
+ "Message",
15
+ "TaskSummary",
16
+ "ChatError"
17
+ ]
api/schemas/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (431 Bytes). View file
 
api/schemas/__pycache__/chat.cpython-314.pyc ADDED
Binary file (6.75 kB). View file
 
api/schemas/chat.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat API Pydantic schemas for request/response validation.
3
+
4
+ Per @specs/001-chatbot-mcp/contracts/openapi.yaml
5
+ """
6
+ from pydantic import BaseModel, Field
7
+ from typing import Optional, List, Dict, Any
8
+ from datetime import datetime
9
+ from uuid import UUID
10
+
11
+
12
+ # =============================================================================
13
+ # Request Schemas
14
+ # =============================================================================
15
+
16
+ class ChatRequest(BaseModel):
17
+ """
18
+ Request schema for chat endpoint.
19
+
20
+ Per @specs/001-chatbot-mcp/contracts/openapi.yaml
21
+ """
22
+ conversation_id: Optional[UUID] = Field(
23
+ default=None,
24
+ description="Optional conversation ID to continue existing chat. If not provided, a new conversation is created."
25
+ )
26
+ message: str = Field(
27
+ ...,
28
+ min_length=1,
29
+ max_length=5000,
30
+ description="User's message content"
31
+ )
32
+
33
+ class Config:
34
+ json_schema_extra = {
35
+ "example": {
36
+ "conversation_id": None,
37
+ "message": "Add a task to buy groceries"
38
+ }
39
+ }
40
+
41
+
42
+ # =============================================================================
43
+ # Response Schemas
44
+ # =============================================================================
45
+
46
+ class Message(BaseModel):
47
+ """
48
+ Message schema for chat responses.
49
+
50
+ Represents a single message in the conversation.
51
+ Per @specs/001-chatbot-mcp/data-model.md
52
+ """
53
+ id: UUID = Field(..., description="Unique message identifier")
54
+ role: str = Field(..., description="Message role: 'user' or 'assistant'")
55
+ content: str = Field(..., description="Message content")
56
+ created_at: datetime = Field(..., description="Timestamp when message was created")
57
+
58
+ class Config:
59
+ json_schema_extra = {
60
+ "example": {
61
+ "id": "123e4567-e89b-12d3-a456-426614174000",
62
+ "role": "assistant",
63
+ "content": "I've added a task 'Buy groceries' to your list.",
64
+ "created_at": "2026-01-11T22:00:00Z"
65
+ }
66
+ }
67
+
68
+
69
+ class TaskSummary(BaseModel):
70
+ """
71
+ Task summary schema for chat responses.
72
+
73
+ Simplified task representation returned in chat responses.
74
+ Per @specs/001-chatbot-mcp/contracts/openapi.yaml
75
+ """
76
+ id: UUID = Field(..., description="Task ID")
77
+ title: str = Field(..., description="Task title")
78
+ description: Optional[str] = Field(None, description="Task description")
79
+ completed: bool = Field(..., description="Task completion status")
80
+
81
+ class Config:
82
+ json_schema_extra = {
83
+ "example": {
84
+ "id": "123e4567-e89b-12d3-a456-426614174000",
85
+ "title": "Buy groceries",
86
+ "description": None,
87
+ "completed": False
88
+ }
89
+ }
90
+
91
+
92
+ class ChatResponse(BaseModel):
93
+ """
94
+ Response schema for chat endpoint.
95
+
96
+ Per @specs/001-chatbot-mcp/contracts/openapi.yaml
97
+ """
98
+ conversation_id: UUID = Field(..., description="Conversation ID (new or existing)")
99
+ message: Message = Field(..., description="Assistant's response message")
100
+ tasks: Optional[List[TaskSummary]] = Field(
101
+ default=None,
102
+ description="List of tasks affected by the chat (optional, for context)"
103
+ )
104
+
105
+ class Config:
106
+ json_schema_extra = {
107
+ "example": {
108
+ "conversation_id": "123e4567-e89b-12d3-a456-426614174000",
109
+ "message": {
110
+ "id": "123e4567-e89b-12d3-a456-426614174001",
111
+ "role": "assistant",
112
+ "content": "I've added a task 'Buy groceries' to your list.",
113
+ "created_at": "2026-01-11T22:00:00Z"
114
+ },
115
+ "tasks": [
116
+ {
117
+ "id": "123e4567-e89b-12d3-a456-426614174002",
118
+ "title": "Buy groceries",
119
+ "description": None,
120
+ "completed": False
121
+ }
122
+ ]
123
+ }
124
+ }
125
+
126
+
127
+ # =============================================================================
128
+ # Error Schemas
129
+ # =============================================================================
130
+
131
+ class ChatError(BaseModel):
132
+ """
133
+ Error response schema for chat endpoint.
134
+
135
+ Returned when chat processing fails.
136
+ """
137
+ error: str = Field(..., description="Error message")
138
+ detail: Optional[str] = Field(None, description="Detailed error information")
139
+ conversation_id: Optional[UUID] = Field(
140
+ None,
141
+ description="Conversation ID if error occurred during existing conversation"
142
+ )
143
+
144
+ class Config:
145
+ json_schema_extra = {
146
+ "example": {
147
+ "error": "Failed to process chat message",
148
+ "detail": "OpenAI API timeout",
149
+ "conversation_id": None
150
+ }
151
+ }
config.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Backend configuration with environment variable loading.
3
+
4
+ Per @specs/001-auth-api-bridge/research.md
5
+ """
6
+ import os
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from dotenv import load_dotenv
10
+ from pydantic_settings import BaseSettings
11
+ from sqlalchemy import create_engine
12
+ from sqlmodel import SQLModel
13
+
14
+ # Load .env file with override to ensure .env takes precedence over system env vars
15
+ # This is needed when system env vars are set with placeholder values
16
+ env_path = Path(__file__).parent.parent / ".env"
17
+ load_dotenv(env_path, override=True)
18
+
19
+
20
+ class Settings(BaseSettings):
21
+ """Application settings loaded from environment variables."""
22
+
23
+ # Better Auth
24
+ better_auth_secret: str
25
+ better_auth_url: str = "http://localhost:3000"
26
+
27
+ # Database
28
+ database_url: str
29
+
30
+ # API
31
+ api_port: int = 8000
32
+ api_host: str = "localhost"
33
+ debug: bool = True
34
+
35
+ # Chatbot Configuration
36
+ # Per @specs/001-chatbot-mcp/plan.md
37
+ openai_api_key: str
38
+ neon_database_url: str # Same as database_url but explicit for chatbot
39
+ mcp_server_port: int = 8000
40
+ openai_model: str = "gpt-4-turbo-preview"
41
+ mcp_server_host: str = "127.0.0.1"
42
+
43
+ # Email Configuration
44
+ email_host: str = "smtp.gmail.com"
45
+ email_port: int = 587
46
+ email_username: str = ""
47
+ email_password: str = ""
48
+ email_from: str = ""
49
+ email_from_name: str = "TaskFlow"
50
+ emails_enabled: bool = True
51
+
52
+ class Config:
53
+ env_file = ".env"
54
+ case_sensitive = False
55
+ extra = "ignore" # Ignore extra env vars (frontend vars)
56
+
57
+
58
+ @lru_cache()
59
+ def get_settings() -> Settings:
60
+ """Get cached settings instance."""
61
+ return Settings()
62
+
63
+
64
+ # Global settings instance
65
+ settings = get_settings()
66
+
67
+ # Database engine with connection pooling per @specs/001-auth-api-bridge/research.md
68
+ engine = create_engine(
69
+ settings.database_url,
70
+ poolclass=None, # QueuePool (default)
71
+ pool_size=5, # Connections to maintain
72
+ max_overflow=10, # Additional connections under load
73
+ pool_pre_ping=True, # Validate connections before use (handles Neon scale-to-zero)
74
+ pool_recycle=3600, # Recycle connections after 1 hour
75
+ echo=settings.debug, # Log SQL in development
76
+ )
77
+
78
+
79
+ def init_db():
80
+ """Initialize database tables."""
81
+ SQLModel.metadata.create_all(engine)
dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application entry point.
3
+
4
+ Per @specs/001-auth-api-bridge/plan.md and @specs/001-auth-api-bridge/quickstart.md
5
+
6
+ Includes password reset functionality.
7
+ """
8
+ from datetime import datetime, timedelta
9
+ from uuid import uuid4, UUID
10
+
11
+ from fastapi import FastAPI, HTTPException
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from pydantic import BaseModel
14
+
15
+ from src.config import settings
16
+ from src.api.routes import tasks_router, health_router, chat_router
17
+ from src.services.auth import create_token, create_password_reset_token, verify_password_reset_token, consume_password_reset_token
18
+ from src.services.email import send_password_reset_email
19
+
20
+
21
+ # Create FastAPI application
22
+ app = FastAPI(
23
+ title="Task Management API",
24
+ description="FastAPI backend for task management with JWT authentication",
25
+ version="1.0.0",
26
+ docs_url="/docs",
27
+ redoc_url="/redoc"
28
+ )
29
+
30
+ # =============================================================================
31
+ # CORS Middleware
32
+ # Per @specs/001-auth-api-bridge/quickstart.md
33
+ # =============================================================================
34
+ app.add_middleware(
35
+ CORSMiddleware,
36
+ allow_origins=[
37
+ "http://localhost:3000",
38
+ "http://localhost:3001",
39
+ "http://localhost:3002",
40
+ "http://127.0.0.1:3000",
41
+ "http://127.0.0.1:3001",
42
+ "http://127.0.0.1:3002",
43
+ "https://taskflow-app-frontend-4kmp-emsultiio-mawbs-projects.vercel.app",
44
+ ], # Frontend URLs
45
+ allow_credentials=True,
46
+ allow_methods=["*"],
47
+ allow_headers=["*"],
48
+ )
49
+
50
+ # =============================================================================
51
+ # Register Routes
52
+ # =============================================================================
53
+ app.include_router(health_router)
54
+ app.include_router(tasks_router)
55
+ app.include_router(chat_router)
56
+
57
+
58
+ # =============================================================================
59
+ # Startup Event
60
+ # =============================================================================
61
+ @app.on_event("startup")
62
+ async def startup_event():
63
+ """
64
+ Initialize database tables and MCP server on startup.
65
+
66
+ Per @specs/001-chatbot-mcp/plan.md, MCP server lifecycle is tied to FastAPI app.
67
+ """
68
+ from src.config import init_db
69
+ from src.services.mcp import mcp_service
70
+ import logging
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ # Initialize database tables
75
+ try:
76
+ init_db()
77
+ logger.info("Database initialized successfully")
78
+ except Exception as e:
79
+ logger.error(f"Database initialization failed: {e}")
80
+ # Don't fail startup - database might be available later
81
+
82
+ # Initialize MCP server with all tools registered
83
+ try:
84
+ await mcp_service.initialize()
85
+ logger.info("MCP server initialized successfully")
86
+ except Exception as e:
87
+ logger.error(f"MCP server initialization failed: {e}")
88
+ # Don't fail startup - MCP might not be critical for basic functionality
89
+
90
+
91
+ @app.on_event("shutdown")
92
+ async def shutdown_event():
93
+ """
94
+ Shutdown MCP server gracefully.
95
+
96
+ Per @specs/001-chatbot-mcp/plan.md, MCP server lifecycle is tied to FastAPI app.
97
+ """
98
+ from src.services.mcp import mcp_service
99
+ await mcp_service.shutdown()
100
+
101
+
102
+ @app.get("/")
103
+ async def root():
104
+ """Root endpoint."""
105
+ return {
106
+ "message": "Task Management API",
107
+ "version": "1.0.0",
108
+ "docs": "/docs"
109
+ }
110
+
111
+
112
+ # =============================================================================
113
+ # Demo Token Generation (for testing login page)
114
+ # =============================================================================
115
+ class TokenRequest(BaseModel):
116
+ email: str
117
+
118
+
119
+ @app.post("/generate-token")
120
+ async def generate_token(request: TokenRequest):
121
+ """
122
+ Generate a test JWT token for demo purposes.
123
+
124
+ In production, this would be replaced by Better Auth's actual authentication.
125
+
126
+ Uses consistent user_id generation based on email hash to ensure
127
+ users always get the same user_id when logging in with the same email.
128
+ This fixes the issue where tasks appeared "lost" after logout/login.
129
+ """
130
+ import hashlib
131
+ import uuid
132
+
133
+ # Generate a consistent user ID based on email hash
134
+ # This ensures the same email always gets the same user_id
135
+ email_bytes = request.email.encode('utf-8')
136
+ hash_bytes = hashlib.sha256(email_bytes).digest()
137
+ # Convert first 16 bytes to a UUID (UUID v5 style but using SHA256)
138
+ user_id = str(uuid.UUID(bytes=hash_bytes[:16]))
139
+
140
+ # Ensure user exists in database (create if not)
141
+ from sqlmodel import Session
142
+ from src.models.user import UserTable
143
+ from src.config import engine
144
+
145
+ with Session(engine) as session:
146
+ existing_user = session.get(UserTable, user_id)
147
+ if not existing_user:
148
+ # Create new user with this email
149
+ from datetime import datetime
150
+ user = UserTable(
151
+ id=user_id,
152
+ email=request.email,
153
+ created_at=datetime.utcnow(),
154
+ updated_at=datetime.utcnow()
155
+ )
156
+ session.add(user)
157
+ session.commit()
158
+ print(f"Created new user: {user_id} with email: {request.email}")
159
+
160
+ # Create JWT token
161
+ token = create_token(user_id)
162
+
163
+ return {
164
+ "token": token,
165
+ "userId": user_id,
166
+ "email": request.email
167
+ }
168
+
169
+
170
+ # =============================================================================
171
+ # Password Reset (Demo Mode)
172
+ # =============================================================================
173
+ class ForgotPasswordRequest(BaseModel):
174
+ email: str
175
+
176
+
177
+ class ResetPasswordRequest(BaseModel):
178
+ token: str
179
+ new_password: str
180
+
181
+
182
+ @app.post("/forgot-password")
183
+ async def forgot_password(request: ForgotPasswordRequest):
184
+ """
185
+ Initiate password reset process.
186
+
187
+ Sends an email with a password reset link.
188
+ In demo mode (without email configured), the token is logged and can be used directly.
189
+ """
190
+ # Create a password reset token
191
+ reset_token = create_password_reset_token(request.email)
192
+
193
+ # Send password reset email
194
+ email_sent = await send_password_reset_email(
195
+ email=request.email,
196
+ reset_token=reset_token,
197
+ frontend_url="http://localhost:3002" # In production, use settings.better_auth_url
198
+ )
199
+
200
+ # For demo mode, include token in response if email wasn't actually sent
201
+ # In production with real email, never include the token in the response
202
+ include_token = not settings.emails_enabled or not settings.email_username
203
+
204
+ response_data = {
205
+ "message": "If an account exists with that email, a password reset link has been sent.",
206
+ "email": request.email
207
+ }
208
+
209
+ if include_token:
210
+ response_data["reset_token"] = reset_token
211
+ response_data["demo_mode"] = True
212
+
213
+ return response_data
214
+
215
+
216
+ @app.post("/reset-password")
217
+ async def reset_password(request: ResetPasswordRequest):
218
+ """
219
+ Reset password using the token received from forgot-password.
220
+
221
+ In production, this would update the user's password in the database.
222
+ For demo purposes, it just validates the token.
223
+ """
224
+ # Verify the reset token
225
+ email = verify_password_reset_token(request.token)
226
+
227
+ if email is None:
228
+ raise HTTPException(
229
+ status_code=400,
230
+ detail="Invalid or expired reset token"
231
+ )
232
+
233
+ # In production, you would update the password in the database here
234
+ # For demo, we just consume the token
235
+ consume_password_reset_token(request.token)
236
+
237
+ return {
238
+ "message": "Password reset successfully",
239
+ "email": email
240
+ }
mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """MCP server and tools for Todo AI Chatbot integration."""
2
+
3
+ from src.mcp.server import get_mcp_server, mcp_server
4
+
5
+ __all__ = ["get_mcp_server", "mcp_server"]
mcp/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (364 Bytes). View file
 
mcp/__pycache__/server.cpython-314.pyc ADDED
Binary file (7.01 kB). View file
 
mcp/__pycache__/tools.cpython-314.pyc ADDED
Binary file (12.5 kB). View file
 
mcp/server.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Server initialization for Todo task management.
3
+
4
+ Per @specs/001-chatbot-mcp/plan.md Section VIII - AI Chatbot Architecture
5
+ MCP First: All task operations go through MCP SDK for OpenAI Agents integration.
6
+
7
+ Note: We create a simple tool registry instead of using FastMCP server
8
+ to avoid transport initialization issues in embedded mode.
9
+ """
10
+ from typing import List, Dict, Any, Callable, Optional, Union
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass
15
+ class Tool:
16
+ """Simple tool definition for task management."""
17
+ name: str
18
+ description: str
19
+ parameters: Dict[str, Any]
20
+ handler: Callable
21
+
22
+
23
+ class SimpleMCPRegistry:
24
+ """Simple tool registry for MCP-compatible tools without server overhead."""
25
+
26
+ def __init__(self, name: str, instructions: str):
27
+ self.name = name
28
+ self.instructions = instructions
29
+ self._tools: Dict[str, Tool] = {}
30
+
31
+ def tool(self, name: Optional[str] = None, description: Optional[str] = None):
32
+ """Decorator to register tools."""
33
+ def decorator(func: Callable):
34
+ tool_name = name or func.__name__
35
+ self._tools[tool_name] = Tool(
36
+ name=tool_name,
37
+ description=description or func.__doc__ or "",
38
+ parameters=self._get_parameters_from_func(func),
39
+ handler=func
40
+ )
41
+ return func
42
+ return decorator
43
+
44
+ def _get_parameters_from_func(self, func: Callable) -> Dict[str, Any]:
45
+ """Extract parameters from function signature."""
46
+ import inspect
47
+ sig = inspect.signature(func)
48
+ properties = {}
49
+ required = []
50
+
51
+ for param_name, param in sig.parameters.items():
52
+ param_type = param.annotation if param.annotation != inspect.Parameter.empty else "string"
53
+ properties[param_name] = {
54
+ "type": self._get_type_string(param_type),
55
+ "description": f"{param_name} parameter"
56
+ }
57
+ if param.default == inspect.Parameter.empty:
58
+ required.append(param_name)
59
+
60
+ return {
61
+ "type": "object",
62
+ "properties": properties,
63
+ "required": required
64
+ }
65
+
66
+ def _get_type_string(self, type_hint) -> str:
67
+ """Convert type hint to JSON schema type string."""
68
+ type_map = {
69
+ str: "string",
70
+ int: "integer",
71
+ float: "number",
72
+ bool: "boolean",
73
+ list: "array",
74
+ dict: "object"
75
+ }
76
+ if type_hint in type_map:
77
+ return type_map[type_hint]
78
+ # Handle Optional types and other generics
79
+ if hasattr(type_hint, "__origin__"):
80
+ origin = getattr(type_hint, "__origin__", None)
81
+ if origin is Union:
82
+ return "string"
83
+ if origin is list:
84
+ return "array"
85
+ return "string"
86
+
87
+ def list_tools(self) -> List[Tool]:
88
+ """List all registered tools."""
89
+ return list(self._tools.values())
90
+
91
+ def get_tool(self, name: str) -> Optional[Tool]:
92
+ """Get a tool by name."""
93
+ return self._tools.get(name)
94
+
95
+
96
+ # Create the tool registry
97
+ mcp_server = SimpleMCPRegistry(
98
+ name="todo-mcp-server",
99
+ instructions="MCP server for Todo task management operations. Provides tools for creating, listing, completing, deleting, and updating tasks with user isolation."
100
+ )
101
+
102
+
103
+ def get_mcp_server() -> SimpleMCPRegistry:
104
+ """
105
+ Get the MCP server/tool registry instance.
106
+
107
+ Returns:
108
+ The configured tool registry with all tools registered.
109
+
110
+ This function is called by the FastAPI application during startup
111
+ to initialize the MCP server lifecycle.
112
+ """
113
+ # Import and register tools
114
+ from src.mcp.tools import register_task_tools
115
+ register_task_tools(mcp_server)
116
+
117
+ return mcp_server
mcp/tools.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP tool definitions for Todo task management.
3
+
4
+ Per @specs/001-chatbot-mcp/contracts/mcp-tools.json
5
+ All tools enforce user isolation by requiring user_id in every request.
6
+ """
7
+ from sqlmodel import Session, select
8
+ from typing import Optional, TYPE_CHECKING
9
+ from datetime import datetime
10
+ from uuid import UUID
11
+
12
+ from src.models.task import TaskTable
13
+ from src.config import engine
14
+
15
+ if TYPE_CHECKING:
16
+ from src.mcp.server import SimpleMCPRegistry
17
+
18
+
19
+ def register_task_tools(mcp_server) -> None:
20
+ """
21
+ Register all task management tools with the MCP server.
22
+
23
+ Args:
24
+ mcp_server: The SimpleMCPRegistry instance
25
+
26
+ This function is called during MCP server initialization to register
27
+ all 5 task tools: add_task, list_tasks, complete_task, delete_task, update_task.
28
+ Per @specs/001-chatbot-mcp/plan.md, all task operations go through MCP.
29
+ """
30
+
31
+ @mcp_server.tool()
32
+ async def add_task(
33
+ user_id: str,
34
+ title: str,
35
+ description: Optional[str] = None
36
+ ) -> dict:
37
+ """
38
+ Create a new todo task for a user.
39
+
40
+ Use this when the user requests to create, add, or make a new task.
41
+
42
+ Args:
43
+ user_id: ID of the user who will own the task (UUID string)
44
+ title: Task title (short, descriptive name)
45
+ description: Optional detailed description
46
+
47
+ Returns:
48
+ Dict with task_id, title, description, created_at, success, error
49
+ """
50
+ try:
51
+ with Session(engine) as session:
52
+ task = TaskTable(
53
+ user_id=UUID(user_id),
54
+ title=title,
55
+ description=description,
56
+ completed=False,
57
+ created_at=datetime.utcnow()
58
+ )
59
+ session.add(task)
60
+ session.commit()
61
+ session.refresh(task)
62
+
63
+ return {
64
+ "task_id": str(task.id),
65
+ "title": task.title,
66
+ "description": task.description,
67
+ "created_at": task.created_at.isoformat(),
68
+ "success": True,
69
+ "error": None
70
+ }
71
+ except Exception as e:
72
+ return {
73
+ "task_id": None,
74
+ "title": None,
75
+ "description": None,
76
+ "created_at": None,
77
+ "success": False,
78
+ "error": str(e)
79
+ }
80
+
81
+ @mcp_server.tool()
82
+ async def list_tasks(
83
+ user_id: str,
84
+ include_completed: bool = True
85
+ ) -> dict:
86
+ """
87
+ List all tasks for a user.
88
+
89
+ Use this when the user asks to see, show, or display their tasks.
90
+
91
+ Args:
92
+ user_id: ID of the user whose tasks to list (UUID string)
93
+ include_completed: Whether to include completed tasks (default: true)
94
+
95
+ Returns:
96
+ Dict with tasks list, count, success, error
97
+ """
98
+ try:
99
+ with Session(engine) as session:
100
+ statement = select(TaskTable).where(TaskTable.user_id == UUID(user_id))
101
+
102
+ if not include_completed:
103
+ statement = statement.where(TaskTable.completed == False)
104
+
105
+ tasks = session.exec(statement).all()
106
+
107
+ return {
108
+ "tasks": [
109
+ {
110
+ "id": str(task.id),
111
+ "title": task.title,
112
+ "description": task.description,
113
+ "completed": task.completed,
114
+ "created_at": task.created_at.isoformat(),
115
+ "completed_at": task.completed_at.isoformat() if task.completed_at else None
116
+ }
117
+ for task in tasks
118
+ ],
119
+ "count": len(tasks),
120
+ "success": True,
121
+ "error": None
122
+ }
123
+ except Exception as e:
124
+ return {
125
+ "tasks": [],
126
+ "count": 0,
127
+ "success": False,
128
+ "error": str(e)
129
+ }
130
+
131
+ @mcp_server.tool()
132
+ async def complete_task(
133
+ user_id: str,
134
+ task_id: str
135
+ ) -> dict:
136
+ """
137
+ Mark a task as completed.
138
+
139
+ Use this when the user asks to complete, finish, or check off a task.
140
+
141
+ Args:
142
+ user_id: ID of the user who owns the task (UUID string)
143
+ task_id: ID of the task to mark as complete (UUID string)
144
+
145
+ Returns:
146
+ Dict with task_id, title, completed, completed_at, success, error
147
+ """
148
+ try:
149
+ with Session(engine) as session:
150
+ task = session.query(TaskTable).filter(
151
+ TaskTable.id == UUID(task_id),
152
+ TaskTable.user_id == UUID(user_id)
153
+ ).first()
154
+
155
+ if not task:
156
+ return {
157
+ "task_id": task_id,
158
+ "title": None,
159
+ "completed": False,
160
+ "completed_at": None,
161
+ "success": False,
162
+ "error": "Task not found or access denied"
163
+ }
164
+
165
+ task.completed = True
166
+ task.completed_at = datetime.utcnow()
167
+ session.add(task)
168
+ session.commit()
169
+ session.refresh(task)
170
+
171
+ return {
172
+ "task_id": str(task.id),
173
+ "title": task.title,
174
+ "completed": task.completed,
175
+ "completed_at": task.completed_at.isoformat(),
176
+ "success": True,
177
+ "error": None
178
+ }
179
+ except Exception as e:
180
+ return {
181
+ "task_id": task_id,
182
+ "title": None,
183
+ "completed": False,
184
+ "completed_at": None,
185
+ "success": False,
186
+ "error": str(e)
187
+ }
188
+
189
+ @mcp_server.tool()
190
+ async def delete_task(
191
+ user_id: str,
192
+ task_id: str
193
+ ) -> dict:
194
+ """
195
+ Delete a task permanently.
196
+
197
+ CRITICAL: The task_id parameter must be a valid UUID from the user's existing tasks.
198
+ If the user provides a task number or title, you MUST map it to the correct task_id.
199
+ Example: If user says "delete task 1", find the task with index 0 in their task list and use its ID.
200
+ Always confirm which task you're deleting by showing the task title before proceeding.
201
+
202
+ Use this when the user asks to delete, remove, or get rid of a task.
203
+
204
+ Args:
205
+ user_id: ID of the user who owns the task (UUID string)
206
+ task_id: ID of the task to delete (UUID string) - must match an existing task ID for this user
207
+
208
+ Returns:
209
+ Dict with task_id, title, deleted, success, error
210
+ """
211
+ try:
212
+ with Session(engine) as session:
213
+ task = session.query(TaskTable).filter(
214
+ TaskTable.id == UUID(task_id),
215
+ TaskTable.user_id == UUID(user_id)
216
+ ).first()
217
+
218
+ if not task:
219
+ return {
220
+ "task_id": task_id,
221
+ "title": None,
222
+ "deleted": False,
223
+ "success": False,
224
+ "error": "Task not found or access denied"
225
+ }
226
+
227
+ title = task.title
228
+ session.delete(task)
229
+ session.commit()
230
+
231
+ return {
232
+ "task_id": task_id,
233
+ "title": title,
234
+ "deleted": True,
235
+ "success": True,
236
+ "error": None
237
+ }
238
+ except Exception as e:
239
+ return {
240
+ "task_id": task_id,
241
+ "title": None,
242
+ "deleted": False,
243
+ "success": False,
244
+ "error": str(e)
245
+ }
246
+
247
+ @mcp_server.tool()
248
+ async def update_task(
249
+ user_id: str,
250
+ task_id: str,
251
+ title: Optional[str] = None,
252
+ description: Optional[str] = None
253
+ ) -> dict:
254
+ """
255
+ Update a task's title or description.
256
+
257
+ CRITICAL: The task_id parameter must be a valid UUID from the user's existing tasks.
258
+ If the user provides a task number or title, you MUST map it to the correct task_id.
259
+ Example: If user says "update task 1 to buy milk", find the task with index 0 in their task list and use its ID.
260
+
261
+ Use this when the user asks to change, modify, or edit a task.
262
+
263
+ Args:
264
+ user_id: ID of the user who owns the task (UUID string)
265
+ task_id: ID of the task to update (UUID string) - must match an existing task ID for this user
266
+ title: New task title (optional)
267
+ description: New task description (optional, empty string to clear)
268
+
269
+ Returns:
270
+ Dict with task_id, title, description, updated_at, success, error
271
+ """
272
+ try:
273
+ with Session(engine) as session:
274
+ task = session.query(TaskTable).filter(
275
+ TaskTable.id == UUID(task_id),
276
+ TaskTable.user_id == UUID(user_id)
277
+ ).first()
278
+
279
+ if not task:
280
+ return {
281
+ "task_id": task_id,
282
+ "title": None,
283
+ "description": None,
284
+ "updated_at": None,
285
+ "success": False,
286
+ "error": "Task not found or access denied"
287
+ }
288
+
289
+ if title is not None:
290
+ task.title = title
291
+ if description is not None:
292
+ task.description = description if description else None
293
+
294
+ session.add(task)
295
+ session.commit()
296
+ session.refresh(task)
297
+
298
+ return {
299
+ "task_id": str(task.id),
300
+ "title": task.title,
301
+ "description": task.description,
302
+ "updated_at": task.created_at.isoformat(), # Using created_at as updated_at not in model
303
+ "success": True,
304
+ "error": None
305
+ }
306
+ except Exception as e:
307
+ return {
308
+ "task_id": task_id,
309
+ "title": None,
310
+ "description": None,
311
+ "updated_at": None,
312
+ "success": False,
313
+ "error": str(e)
314
+ }
models/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Database models package."""
2
+ from src.models.user import UserTable
3
+ from src.models.task import TaskTable
4
+ from src.models.conversation import ConversationTable
5
+ from src.models.message import MessageTable, MessageRole
6
+
7
+ __all__ = ["UserTable", "TaskTable", "ConversationTable", "MessageTable", "MessageRole"]
models/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (519 Bytes). View file
 
models/__pycache__/conversation.cpython-314.pyc ADDED
Binary file (2.7 kB). View file
 
models/__pycache__/message.cpython-314.pyc ADDED
Binary file (2.94 kB). View file
 
models/__pycache__/task.cpython-314.pyc ADDED
Binary file (2.71 kB). View file
 
models/__pycache__/user.cpython-314.pyc ADDED
Binary file (2.56 kB). View file
 
models/conversation.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Conversation model representing a user's chat session with AI.
3
+
4
+ Per @specs/001-chatbot-mcp/data-model.md
5
+ """
6
+ from sqlmodel import SQLModel, Field, Relationship
7
+ from typing import TYPE_CHECKING, Optional
8
+ from datetime import datetime
9
+ from uuid import UUID, uuid4
10
+
11
+ if TYPE_CHECKING:
12
+ from src.models.user import UserTable
13
+ from src.models.message import MessageTable
14
+
15
+
16
+ class ConversationTable(SQLModel, table=True):
17
+ """
18
+ A chat session between a user and the AI assistant.
19
+
20
+ All conversations MUST be scoped to a single user to ensure data isolation
21
+ per constitutional principle III (User Isolation via JWT).
22
+ """
23
+ __tablename__ = "conversations"
24
+
25
+ # Primary key
26
+ id: UUID = Field(
27
+ default_factory=uuid4,
28
+ primary_key=True,
29
+ index=True,
30
+ description="Unique conversation identifier"
31
+ )
32
+
33
+ # Foreign key to User
34
+ user_id: UUID = Field(
35
+ foreign_key="users.id",
36
+ index=True,
37
+ nullable=False,
38
+ description="ID of the user who owns this conversation"
39
+ )
40
+
41
+ # Conversation attributes
42
+ title: Optional[str] = Field(
43
+ default=None,
44
+ max_length=255,
45
+ description="Auto-generated title from first message (e.g., 'Grocery shopping')"
46
+ )
47
+
48
+ # Timestamps
49
+ created_at: datetime = Field(
50
+ default_factory=datetime.utcnow,
51
+ nullable=False,
52
+ description="Timestamp when conversation was created"
53
+ )
54
+
55
+ updated_at: datetime = Field(
56
+ default_factory=datetime.utcnow,
57
+ sa_column_kwargs={"onupdate": datetime.utcnow},
58
+ nullable=False,
59
+ index=True,
60
+ description="Timestamp of last message in conversation"
61
+ )
62
+
63
+ # Relationships
64
+ user: "UserTable" = Relationship(back_populates="conversations")
65
+ messages: list["MessageTable"] = Relationship(
66
+ back_populates="conversation",
67
+ sa_relationship_kwargs={"cascade": "all, delete-orphan"}
68
+ )
models/message.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Message model representing a single message in a conversation.
3
+
4
+ Per @specs/001-chatbot-mcp/data-model.md
5
+ """
6
+ from sqlmodel import SQLModel, Field, Relationship, Column
7
+ from typing import TYPE_CHECKING, Optional, Any, Dict
8
+ from datetime import datetime
9
+ from uuid import UUID, uuid4
10
+ from enum import Enum
11
+ from sqlalchemy import JSON
12
+
13
+ if TYPE_CHECKING:
14
+ from src.models.conversation import ConversationTable
15
+
16
+
17
+ class MessageRole(str, Enum):
18
+ """Message sender role."""
19
+ USER = "user"
20
+ ASSISTANT = "assistant"
21
+
22
+
23
+ class MessageTable(SQLModel, table=True):
24
+ """
25
+ A single message in a conversation.
26
+
27
+ Messages are owned by a user through their conversation. All queries MUST
28
+ filter by user_id (via conversation) to ensure data isolation.
29
+ """
30
+ __tablename__ = "messages"
31
+
32
+ # Primary key
33
+ id: UUID = Field(
34
+ default_factory=uuid4,
35
+ primary_key=True,
36
+ index=True,
37
+ description="Unique message identifier"
38
+ )
39
+
40
+ # Foreign key to Conversation
41
+ conversation_id: UUID = Field(
42
+ foreign_key="conversations.id",
43
+ index=True,
44
+ nullable=False,
45
+ description="ID of the conversation this message belongs to"
46
+ )
47
+
48
+ # Message attributes
49
+ role: MessageRole = Field(
50
+ nullable=False,
51
+ description="Message sender: 'user' or 'assistant'"
52
+ )
53
+
54
+ content: str = Field(
55
+ nullable=False,
56
+ max_length=5000,
57
+ description="Message content (plaintext)"
58
+ )
59
+
60
+ # Timestamps
61
+ created_at: datetime = Field(
62
+ default_factory=datetime.utcnow,
63
+ nullable=False,
64
+ index=True,
65
+ description="Timestamp when message was created"
66
+ )
67
+
68
+ # Optional metadata for tool calls, token usage, etc.
69
+ # Renamed from 'metadata' to avoid conflict with SQLAlchemy's reserved attribute
70
+ tool_metadata: Optional[Dict[str, Any]] = Field(
71
+ default=None,
72
+ sa_column=Column(JSON),
73
+ description="Tool calls, tokens used, error information"
74
+ )
75
+
76
+ # Relationships
77
+ conversation: "ConversationTable" = Relationship(back_populates="messages")
models/task.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task model representing user tasks.
3
+
4
+ Per @specs/001-auth-api-bridge/data-model.md
5
+ """
6
+ from sqlmodel import SQLModel, Field, Relationship
7
+ from typing import TYPE_CHECKING, Optional
8
+ from datetime import datetime
9
+ from uuid import UUID, uuid4
10
+
11
+ if TYPE_CHECKING:
12
+ from src.models.user import UserTable
13
+
14
+
15
+ class TaskTable(SQLModel, table=True):
16
+ """
17
+ Task owned by a user.
18
+
19
+ Each task belongs to exactly one user. All queries MUST filter by user_id
20
+ to ensure data isolation per constitutional principle.
21
+ """
22
+ __tablename__ = "tasks"
23
+
24
+ # Primary key
25
+ id: UUID = Field(
26
+ default_factory=uuid4,
27
+ primary_key=True,
28
+ index=True,
29
+ description="Unique task identifier"
30
+ )
31
+
32
+ # Foreign key to User
33
+ user_id: UUID = Field(
34
+ foreign_key="users.id",
35
+ index=True,
36
+ nullable=False,
37
+ description="ID of the user who owns this task"
38
+ )
39
+
40
+ # Task attributes
41
+ title: str = Field(
42
+ max_length=255,
43
+ nullable=False,
44
+ description="Short title of the task"
45
+ )
46
+
47
+ description: Optional[str] = Field(
48
+ default=None,
49
+ max_length=5000,
50
+ description="Detailed description of the task (optional)"
51
+ )
52
+
53
+ # Completion status
54
+ completed: bool = Field(
55
+ default=False,
56
+ index=True,
57
+ description="Whether the task has been completed"
58
+ )
59
+
60
+ # Priority level
61
+ priority: str = Field(
62
+ default="medium",
63
+ nullable=False,
64
+ description="Task priority level: low, medium, or high"
65
+ )
66
+
67
+ # Timestamps
68
+ created_at: datetime = Field(
69
+ default_factory=datetime.utcnow,
70
+ nullable=False,
71
+ description="Timestamp when task was created"
72
+ )
73
+
74
+ completed_at: Optional[datetime] = Field(
75
+ default=None,
76
+ nullable=True,
77
+ index=True,
78
+ description="Timestamp when task was marked as completed (null until completed)"
79
+ )
80
+
81
+ # Relationships
82
+ user: "UserTable" = Relationship(back_populates="tasks")
models/user.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User model representing authenticated users managed by Better Auth.
3
+
4
+ Per @specs/001-auth-api-bridge/data-model.md
5
+ """
6
+ from sqlmodel import SQLModel, Field, Relationship
7
+ from typing import TYPE_CHECKING, List
8
+ from datetime import datetime
9
+ from uuid import UUID, uuid4
10
+
11
+ if TYPE_CHECKING:
12
+ from src.models.task import TaskTable
13
+ from src.models.conversation import ConversationTable
14
+
15
+
16
+ class UserTable(SQLModel, table=True):
17
+ """
18
+ User account managed by Better Auth.
19
+
20
+ The id field (UUID) MUST match the 'sub' claim in JWT tokens issued by Better Auth.
21
+ """
22
+ __tablename__ = "users"
23
+
24
+ # Primary key - matches the 'sub' claim in JWT tokens
25
+ id: UUID = Field(
26
+ default_factory=uuid4,
27
+ primary_key=True,
28
+ index=True,
29
+ description="Unique user identifier that matches JWT 'sub' claim"
30
+ )
31
+
32
+ # User profile information
33
+ email: str = Field(
34
+ unique=True,
35
+ index=True,
36
+ max_length=255,
37
+ description="User's email address (unique)"
38
+ )
39
+
40
+ name: str | None = Field(
41
+ default=None,
42
+ max_length=255,
43
+ description="User's display name"
44
+ )
45
+
46
+ # Timestamps
47
+ created_at: datetime = Field(
48
+ default_factory=datetime.utcnow,
49
+ description="Timestamp when user account was created"
50
+ )
51
+
52
+ updated_at: datetime = Field(
53
+ default_factory=datetime.utcnow,
54
+ sa_column_kwargs={"onupdate": datetime.utcnow},
55
+ description="Timestamp when user record was last updated"
56
+ )
57
+
58
+ # Relationships
59
+ tasks: List["TaskTable"] = Relationship(
60
+ back_populates="user",
61
+ sa_relationship_kwargs={"cascade": "all, delete-orphan"}
62
+ )
63
+ conversations: List["ConversationTable"] = Relationship(
64
+ back_populates="user",
65
+ sa_relationship_kwargs={"cascade": "all, delete-orphan"}
66
+ )
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ pydantic
4
+ python-dotenv
5
+ sqlalchemy
6
+ openai
7
+ python-jose[cryptography]
8
+ passlib[bcrypt]
9
+ python-multipart
services/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Services package."""
2
+ from src.services.auth import verify_token
3
+ from src.services.task import TaskService
4
+
5
+ __all__ = ["verify_token", "TaskService"]
services/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (359 Bytes). View file
 
services/__pycache__/auth.cpython-314.pyc ADDED
Binary file (4.73 kB). View file
 
services/__pycache__/chat.cpython-314.pyc ADDED
Binary file (10.6 kB). View file
 
services/__pycache__/email.cpython-314.pyc ADDED
Binary file (9.53 kB). View file
 
services/__pycache__/mcp.cpython-314.pyc ADDED
Binary file (7.85 kB). View file
 
services/__pycache__/task.cpython-314.pyc ADDED
Binary file (6.87 kB). View file
 
services/auth.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ JWT verification service for Better Auth integration.
3
+
4
+ Per @specs/001-auth-api-bridge/research.md
5
+ """
6
+ from datetime import datetime, timedelta
7
+ from typing import Optional, Dict
8
+ from jose import JWTError, jwt
9
+ from src.config import settings
10
+ import uuid
11
+
12
+ SECRET_KEY = settings.better_auth_secret
13
+ ALGORITHM = "HS256"
14
+
15
+ # In-memory store for password reset tokens (in production, use Redis or database)
16
+ PASSWORD_RESET_TOKENS: Dict[str, dict] = {}
17
+
18
+
19
+ def create_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
20
+ """
21
+ Create a JWT token for a user.
22
+
23
+ Args:
24
+ user_id: User UUID to embed in the token
25
+ expires_delta: Optional custom expiration time
26
+
27
+ Returns:
28
+ Encoded JWT token string
29
+ """
30
+ if expires_delta:
31
+ expire = datetime.utcnow() + expires_delta
32
+ else:
33
+ expire = datetime.utcnow() + timedelta(hours=24)
34
+
35
+ payload = {
36
+ "sub": user_id,
37
+ "iat": datetime.utcnow(),
38
+ "exp": expire
39
+ }
40
+
41
+ return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
42
+
43
+
44
+ async def verify_token(token: str) -> Optional[str]:
45
+ """
46
+ Verify JWT token and return user_id.
47
+
48
+ Args:
49
+ token: JWT token string
50
+
51
+ Returns:
52
+ User ID (UUID string) if token is valid, None otherwise
53
+ """
54
+ try:
55
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
56
+ user_id: str = payload.get("sub")
57
+ if user_id is None:
58
+ return None
59
+ return user_id
60
+ except JWTError:
61
+ return None
62
+
63
+
64
+ def create_password_reset_token(email: str) -> str:
65
+ """
66
+ Create a password reset token for an email.
67
+
68
+ Args:
69
+ email: User's email address
70
+
71
+ Returns:
72
+ Password reset token
73
+ """
74
+ reset_token = str(uuid.uuid4())
75
+ expiry = datetime.utcnow() + timedelta(hours=1) # Token valid for 1 hour
76
+
77
+ PASSWORD_RESET_TOKENS[reset_token] = {
78
+ "email": email,
79
+ "expires": expiry
80
+ }
81
+
82
+ return reset_token
83
+
84
+
85
+ def verify_password_reset_token(token: str) -> Optional[str]:
86
+ """
87
+ Verify password reset token and return email.
88
+
89
+ Args:
90
+ token: Password reset token
91
+
92
+ Returns:
93
+ Email if token is valid, None otherwise
94
+ """
95
+ if token not in PASSWORD_RESET_TOKENS:
96
+ return None
97
+
98
+ token_data = PASSWORD_RESET_TOKENS[token]
99
+
100
+ # Check if token has expired
101
+ if datetime.utcnow() > token_data["expires"]:
102
+ del PASSWORD_RESET_TOKENS[token]
103
+ return None
104
+
105
+ return token_data["email"]
106
+
107
+
108
+ def consume_password_reset_token(token: str) -> bool:
109
+ """
110
+ Consume (invalidate) a password reset token after use.
111
+
112
+ Args:
113
+ token: Password reset token to consume
114
+
115
+ Returns:
116
+ True if token was valid and consumed, False otherwise
117
+ """
118
+ if token in PASSWORD_RESET_TOKENS:
119
+ del PASSWORD_RESET_TOKENS[token]
120
+ return True
121
+ return False