adding
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- __pycache__/config.cpython-314.pyc +0 -0
- __pycache__/main.cpython-314.pyc +0 -0
- agents/__init__.py +5 -0
- agents/__pycache__/__init__.cpython-314.pyc +0 -0
- agents/__pycache__/todo_agent.cpython-314.pyc +0 -0
- agents/todo_agent.py +347 -0
- api/__init__.py +4 -0
- api/__pycache__/__init__.cpython-314.pyc +0 -0
- api/__pycache__/dependencies.cpython-314.pyc +0 -0
- api/dependencies.py +69 -0
- api/routes/__init__.py +6 -0
- api/routes/__pycache__/__init__.cpython-314.pyc +0 -0
- api/routes/__pycache__/chat.cpython-314.pyc +0 -0
- api/routes/__pycache__/health.cpython-314.pyc +0 -0
- api/routes/__pycache__/tasks.cpython-314.pyc +0 -0
- api/routes/chat.py +268 -0
- api/routes/health.py +41 -0
- api/routes/tasks.py +413 -0
- api/schemas/__init__.py +17 -0
- api/schemas/__pycache__/__init__.cpython-314.pyc +0 -0
- api/schemas/__pycache__/chat.cpython-314.pyc +0 -0
- api/schemas/chat.py +151 -0
- config.py +81 -0
- dockerfile +12 -0
- main.py +240 -0
- mcp/__init__.py +5 -0
- mcp/__pycache__/__init__.cpython-314.pyc +0 -0
- mcp/__pycache__/server.cpython-314.pyc +0 -0
- mcp/__pycache__/tools.cpython-314.pyc +0 -0
- mcp/server.py +117 -0
- mcp/tools.py +314 -0
- models/__init__.py +7 -0
- models/__pycache__/__init__.cpython-314.pyc +0 -0
- models/__pycache__/conversation.cpython-314.pyc +0 -0
- models/__pycache__/message.cpython-314.pyc +0 -0
- models/__pycache__/task.cpython-314.pyc +0 -0
- models/__pycache__/user.cpython-314.pyc +0 -0
- models/conversation.py +68 -0
- models/message.py +77 -0
- models/task.py +82 -0
- models/user.py +66 -0
- requirements.txt +9 -0
- services/__init__.py +5 -0
- services/__pycache__/__init__.cpython-314.pyc +0 -0
- services/__pycache__/auth.cpython-314.pyc +0 -0
- services/__pycache__/chat.cpython-314.pyc +0 -0
- services/__pycache__/email.cpython-314.pyc +0 -0
- services/__pycache__/mcp.cpython-314.pyc +0 -0
- services/__pycache__/task.cpython-314.pyc +0 -0
- 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
|