feat: comprehensive test infrastructure improvements
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m26s
Run Tests / Backend Tests (push) Failing after 2m36s
Run Tests / Frontend Tests (push) Failing after 2m23s
Run Tests / Test Summary (push) Failing after 13s

- Fix unit test imports: add env setup in conftest.py before module imports
- Add 24 new auth router tests (RBAC, preferences, password validation)
- Add 16 new tests for litellm and chat_summary routers
- Apply black formatting and ruff linting across codebase
- Add pre-commit hooks configuration (black, ruff, file checks)
- Increase CI coverage threshold from 40% to 50%

Test Results:
- 206 tests passing (91 unit + 115 integration)
- Coverage: 58.79% on core modules
- auth.py: 57% → 85%, litellm.py: 23% → 87%, chat_summary.py: 41% → 100%
- auth_service: 96.51%, config: 100%, rbac: 93.48%
This commit is contained in:
Gan, Jimmy
2026-04-08 00:21:32 +08:00
parent fcc95ac23f
commit b981c06d59
44 changed files with 1732 additions and 924 deletions
+65 -53
View File
@@ -1,15 +1,18 @@
"""
Agent Executor Service - Executes agent tasks and manages their lifecycle
"""
import asyncio
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from db import opc_db
from services.agents.base_agent import BaseAgent
from services import email_service
import httpx
import os
from datetime import datetime
from typing import Any
import httpx
from db import opc_db
from services import email_service
from services.agents.base_agent import BaseAgent
logger = logging.getLogger(__name__)
@@ -22,7 +25,7 @@ class AgentExecutor:
"""Manages agent execution lifecycle"""
def __init__(self):
self.agents: Dict[str, BaseAgent] = {}
self.agents: dict[str, BaseAgent] = {}
self.running = False
async def initialize(self):
@@ -35,7 +38,7 @@ class AgentExecutor:
name=agent_data["name"],
role=agent_data["role"],
system_prompt=agent_data["system_prompt"],
config=agent_data["config"]
config=agent_data["config"],
)
self.agents[agent_data["id"]] = agent
logger.info(f"Loaded agent: {agent.name} ({agent.agent_id})")
@@ -54,6 +57,7 @@ class AgentExecutor:
print(f"[Executor] Error in executor loop: {e}")
logger.error(f"Error in executor loop: {e}")
import traceback
traceback.print_exc()
await asyncio.sleep(5) # Check every 5 seconds
@@ -74,9 +78,11 @@ class AgentExecutor:
for execution in executions:
try:
print(f"[Executor] Processing execution {execution['id']} for agent {execution['agent_id']} (status: {execution['status']})")
print(
f"[Executor] Processing execution {execution['id']} for agent {execution['agent_id']} (status: {execution['status']})"
)
if execution['status'] == 'approved':
if execution["status"] == "approved":
# Resume approved execution
await self._resume_approved_execution(execution)
else:
@@ -87,7 +93,7 @@ class AgentExecutor:
logger.error(f"Failed to execute agent {execution['agent_id']}: {e}")
await self._mark_execution_failed(execution["id"], str(e))
async def _execute_agent(self, execution: Dict[str, Any]):
async def _execute_agent(self, execution: dict[str, Any]):
"""Execute a single agent task"""
agent_id = execution["agent_id"]
task_id = execution["task_id"]
@@ -127,7 +133,7 @@ class AgentExecutor:
# Send completion notification
await self._send_completion_notification(agent, task, result)
async def _resume_approved_execution(self, execution: Dict[str, Any]):
async def _resume_approved_execution(self, execution: dict[str, Any]):
"""Resume an approved execution and execute its actions"""
agent_id = execution["agent_id"]
task_id = execution["task_id"]
@@ -160,12 +166,9 @@ class AgentExecutor:
# Send completion notification
await self._send_completion_notification(agent, task, result)
async def _build_context(self, task: Dict[str, Any]) -> Dict[str, Any]:
async def _build_context(self, task: dict[str, Any]) -> dict[str, Any]:
"""Build context for agent execution"""
context = {
"task": task,
"timestamp": datetime.utcnow().isoformat()
}
context = {"task": task, "timestamp": datetime.utcnow().isoformat()}
# Get related tasks from same project
if task.get("project_id"):
@@ -178,7 +181,7 @@ class AgentExecutor:
return context
async def _execute_actions(self, task: Dict[str, Any], actions: list):
async def _execute_actions(self, task: dict[str, Any], actions: list):
"""Execute proposed actions"""
for action in actions:
action_type = action.get("type")
@@ -203,7 +206,7 @@ class AgentExecutor:
logger.error(f"Failed to execute action {action_type}: {e}")
raise
async def _action_create_subtask(self, parent_task: Dict[str, Any], action: Dict[str, Any]):
async def _action_create_subtask(self, parent_task: dict[str, Any], action: dict[str, Any]):
"""Create a subtask"""
await opc_db.create_task(
title=action.get("title", "Subtask"),
@@ -212,48 +215,52 @@ class AgentExecutor:
priority=parent_task.get("priority", "medium"),
project_id=parent_task.get("project_id"),
tags=parent_task.get("tags", []),
actor="agent"
actor="agent",
)
logger.info(f"Created subtask: {action.get('title')}")
async def _action_update_status(self, task: Dict[str, Any], action: Dict[str, Any]):
async def _action_update_status(self, task: dict[str, Any], action: dict[str, Any]):
"""Update task status"""
new_status = action.get("status")
if new_status:
await opc_db.update_task(
task_id=task["id"],
updates={"status": new_status},
actor="agent"
)
await opc_db.update_task(task_id=task["id"], updates={"status": new_status}, actor="agent")
logger.info(f"Updated task {task['id']} status to {new_status}")
async def _action_send_notification(self, action: Dict[str, Any]):
async def _action_send_notification(self, action: dict[str, Any]):
"""Send notification"""
message = action.get("message", "")
if message and TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
await self._send_telegram(message)
async def _action_add_comment(self, task: Dict[str, Any], action: Dict[str, Any]):
async def _action_add_comment(self, task: dict[str, Any], action: dict[str, Any]):
"""Add comment to task (stored in history)"""
comment = action.get("comment", "")
if comment:
# Store as task history
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
await conn.execute(
"""
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
VALUES ($1, $2, $3, $4, $5)
""", task["id"], "comment", "agent", "agent", comment)
""",
task["id"],
"comment",
"agent",
"agent",
comment,
)
async def _update_execution_status(self, execution_id: int, status: str):
"""Update execution status"""
await opc_db.update_execution_status(execution_id, status)
async def _mark_execution_completed(self, execution_id: int, result: Dict[str, Any]):
async def _mark_execution_completed(self, execution_id: int, result: dict[str, Any]):
"""Mark execution as completed"""
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
await conn.execute(
"""
UPDATE agent_executions
SET status = $1,
output_result = $2,
@@ -261,36 +268,46 @@ class AgentExecutor:
actions_executed = $3,
completed_at = CURRENT_TIMESTAMP
WHERE id = $4
""", "completed",
""",
"completed",
opc_db.json.dumps(result),
opc_db.json.dumps(result.get("actions", [])),
execution_id)
execution_id,
)
async def _mark_execution_failed(self, execution_id: int, error: str):
"""Mark execution as failed"""
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
await conn.execute(
"""
UPDATE agent_executions
SET status = $1, error_message = $2, completed_at = CURRENT_TIMESTAMP
WHERE id = $3
""", "failed", error, execution_id)
""",
"failed",
error,
execution_id,
)
async def _mark_pending_approval(self, execution_id: int, result: Dict[str, Any]):
async def _mark_pending_approval(self, execution_id: int, result: dict[str, Any]):
"""Mark execution as pending approval"""
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
await conn.execute(
"""
UPDATE agent_executions
SET status = $1,
output_result = $2,
actions_proposed = $3,
requires_approval = TRUE
WHERE id = $4
""", "pending_approval",
""",
"pending_approval",
opc_db.json.dumps(result),
opc_db.json.dumps(result.get("actions", [])),
execution_id)
execution_id,
)
async def _send_telegram(self, message: str):
"""Send Telegram notification"""
@@ -299,16 +316,12 @@ class AgentExecutor:
async with httpx.AsyncClient(proxy=proxy_url, timeout=10.0) as client:
await client.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
data={
"chat_id": TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "Markdown"
}
data={"chat_id": TELEGRAM_CHAT_ID, "text": message, "parse_mode": "Markdown"},
)
except Exception as e:
logger.error(f"Failed to send Telegram notification: {e}")
async def _send_approval_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]):
async def _send_approval_notification(self, agent: BaseAgent, task: dict[str, Any], result: dict[str, Any]):
"""Send notification for approval request"""
message = f"""🤖 *Agent Approval Required*
@@ -331,11 +344,11 @@ Proposed Actions:
await email_service.send_agent_approval_email(
agent_name=agent.name,
task=task,
reasoning=result.get('reasoning', 'No reasoning provided'),
actions=result.get('actions', [])
reasoning=result.get("reasoning", "No reasoning provided"),
actions=result.get("actions", []),
)
async def _send_completion_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]):
async def _send_completion_notification(self, agent: BaseAgent, task: dict[str, Any], result: dict[str, Any]):
"""Send notification for completed execution"""
message = f"""✅ *Agent Task Completed*
@@ -349,14 +362,12 @@ Actions Executed: {len(result.get('actions', []))}
# Send Email
await email_service.send_agent_completion_email(
agent_name=agent.name,
task=task,
actions_count=len(result.get('actions', []))
agent_name=agent.name, task=task, actions_count=len(result.get("actions", []))
)
# Global executor instance
_executor: Optional[AgentExecutor] = None
_executor: AgentExecutor | None = None
async def get_executor() -> AgentExecutor:
@@ -382,5 +393,6 @@ async def start_executor():
except Exception as e:
print(f"ERROR in start_executor: {e}")
import traceback
traceback.print_exc()
raise
+15 -18
View File
@@ -1,17 +1,18 @@
"""
Base Agent Class - Foundation for all OPC agents
"""
from typing import Dict, Any, List, Optional
import json
import httpx
import os
from datetime import datetime
from typing import Any
import httpx
class BaseAgent:
"""Base class for all OPC agents"""
def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: Dict[str, Any]):
def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: dict[str, Any]):
self.agent_id = agent_id
self.name = name
self.role = role
@@ -20,7 +21,7 @@ class BaseAgent:
self.litellm_url = os.getenv("LITELLM_URL", "http://litellm:4005")
self.litellm_api_key = os.getenv("LITELLM_API_KEY", "")
async def execute(self, task: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
async def execute(self, task: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""
Execute agent on a task
Returns: {
@@ -40,7 +41,7 @@ class BaseAgent:
return result
def _build_prompt(self, task: Dict[str, Any], context: Dict[str, Any]) -> str:
def _build_prompt(self, task: dict[str, Any], context: dict[str, Any]) -> str:
"""Build the prompt for the LLM"""
prompt = f"""You are {self.name}, a {self.role} agent.
@@ -77,6 +78,7 @@ Only propose actions you can actually execute. Be specific and actionable."""
async def _call_llm(self, prompt: str) -> str:
"""Call LiteLLM proxy"""
import logging
logger = logging.getLogger(__name__)
model = self.config.get("model", "claude-sonnet-4-6")
@@ -96,11 +98,11 @@ Only propose actions you can actually execute. Be specific and actionable."""
"model": model,
"messages": [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt}
{"role": "user", "content": prompt},
],
"temperature": temperature,
"max_tokens": 2000
}
"max_tokens": 2000,
},
)
response.raise_for_status()
data = response.json()
@@ -118,7 +120,7 @@ Only propose actions you can actually execute. Be specific and actionable."""
logger.error(f"LiteLLM call failed: {e}")
raise Exception(f"LLM call failed: {str(e)}")
def _parse_response(self, response: str) -> Dict[str, Any]:
def _parse_response(self, response: str) -> dict[str, Any]:
"""Parse LLM response and extract actions"""
try:
# Try to extract JSON from response
@@ -148,16 +150,11 @@ Only propose actions you can actually execute. Be specific and actionable."""
# Fallback: return error action
return {
"reasoning": f"Failed to parse response: {str(e)}",
"actions": [
{
"type": "error",
"message": f"Agent response parsing failed: {str(e)}"
}
],
"requires_approval": False
"actions": [{"type": "error", "message": f"Agent response parsing failed: {str(e)}"}],
"requires_approval": False,
}
def get_capabilities(self) -> List[str]:
def get_capabilities(self) -> list[str]:
"""Get agent capabilities"""
return self.config.get("capabilities", [])
+6 -3
View File
@@ -1,11 +1,12 @@
"""
Email notification service for OPC
"""
import logging
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import logging
from email.mime.text import MIMEText
logger = logging.getLogger(__name__)
@@ -90,7 +91,9 @@ async def send_agent_approval_email(agent_name: str, task: dict, reasoning: str,
"""Send email for agent approval request"""
subject = f"Agent Approval Required: {agent_name}"
actions_text = "\n".join([f"- {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}" for action in actions])
actions_text = "\n".join(
[f"- {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}" for action in actions]
)
body = f"""Agent approval required:
+81 -86
View File
@@ -1,26 +1,27 @@
"""
PDF Invoice Generation Service
"""
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.enums import TA_RIGHT, TA_CENTER
from datetime import datetime
from typing import List, Dict, Any
import io
from datetime import datetime
from typing import Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
def generate_invoice_pdf(
invoice_number: str,
client: Dict[str, Any],
time_entries: List[Dict[str, Any]],
client: dict[str, Any],
time_entries: list[dict[str, Any]],
hourly_rate: float = 100.0,
company_name: str = "Your Company",
company_address: str = "",
company_email: str = "",
company_phone: str = ""
company_phone: str = "",
) -> bytes:
"""
Generate PDF invoice from time entries
@@ -34,18 +35,18 @@ def generate_invoice_pdf(
# Custom styles
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
"CustomTitle",
parent=styles["Heading1"],
fontSize=24,
textColor=colors.HexColor('#1F2937'),
textColor=colors.HexColor("#1F2937"),
spaceAfter=30,
)
header_style = ParagraphStyle(
'Header',
parent=styles['Normal'],
"Header",
parent=styles["Normal"],
fontSize=10,
textColor=colors.HexColor('#6B7280'),
textColor=colors.HexColor("#6B7280"),
)
# Company Header
@@ -61,10 +62,10 @@ def generate_invoice_pdf(
# Invoice Title
invoice_title = ParagraphStyle(
'InvoiceTitle',
parent=styles['Heading2'],
"InvoiceTitle",
parent=styles["Heading2"],
fontSize=18,
textColor=colors.HexColor('#4F46E5'),
textColor=colors.HexColor("#4F46E5"),
)
elements.append(Paragraph("INVOICE", invoice_title))
elements.append(Spacer(1, 0.2 * inch))
@@ -74,96 +75,93 @@ def generate_invoice_pdf(
invoice_data = [
["Invoice Number:", invoice_number],
["Invoice Date:", invoice_date],
["Client:", client.get('name', 'N/A')],
["Client:", client.get("name", "N/A")],
]
if client.get('company'):
invoice_data.append(["Company:", client['company']])
if client.get('email'):
invoice_data.append(["Email:", client['email']])
if client.get("company"):
invoice_data.append(["Company:", client["company"]])
if client.get("email"):
invoice_data.append(["Email:", client["email"]])
invoice_table = Table(invoice_data, colWidths=[2*inch, 4*inch])
invoice_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#374151')),
('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#1F2937')),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
]))
invoice_table = Table(invoice_data, colWidths=[2 * inch, 4 * inch])
invoice_table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor("#374151")),
("TEXTCOLOR", (1, 0), (1, -1), colors.HexColor("#1F2937")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
elements.append(invoice_table)
elements.append(Spacer(1, 0.4 * inch))
# Time Entries Table
table_data = [
["Date", "Description", "Hours", "Rate", "Amount"]
]
table_data = [["Date", "Description", "Hours", "Rate", "Amount"]]
total_hours = 0
total_amount = 0
for entry in time_entries:
hours = entry['duration_minutes'] / 60
rate = entry.get('hourly_rate', hourly_rate)
hours = entry["duration_minutes"] / 60
rate = entry.get("hourly_rate", hourly_rate)
amount = hours * rate
total_hours += hours
total_amount += amount
date_str = entry['started_at'].strftime("%Y-%m-%d") if entry.get('started_at') else "N/A"
desc = entry.get('description', 'Work performed')
date_str = entry["started_at"].strftime("%Y-%m-%d") if entry.get("started_at") else "N/A"
desc = entry.get("description", "Work performed")
table_data.append([
date_str,
desc[:50] + "..." if len(desc) > 50 else desc,
f"{hours:.2f}",
f"${rate:.2f}",
f"${amount:.2f}"
])
table_data.append(
[date_str, desc[:50] + "..." if len(desc) > 50 else desc, f"{hours:.2f}", f"${rate:.2f}", f"${amount:.2f}"]
)
# Add totals row
table_data.append([
"", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"
])
table_data.append(["", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"])
# Create table
time_table = Table(table_data, colWidths=[1.2*inch, 2.8*inch, 0.8*inch, 0.8*inch, 1*inch])
time_table.setStyle(TableStyle([
# Header row
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4F46E5')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 11),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
# Data rows
('FONTNAME', (0, 1), (-1, -2), 'Helvetica'),
('FONTSIZE', (0, 1), (-1, -2), 9),
('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#F9FAFB')]),
('GRID', (0, 0), (-1, -2), 0.5, colors.HexColor('#E5E7EB')),
# Total row
('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#F3F4F6')),
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, -1), (-1, -1), 11),
('TOPPADDING', (0, -1), (-1, -1), 12),
('BOTTOMPADDING', (0, -1), (-1, -1), 12),
# Alignment
('ALIGN', (2, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
time_table = Table(table_data, colWidths=[1.2 * inch, 2.8 * inch, 0.8 * inch, 0.8 * inch, 1 * inch])
time_table.setStyle(
TableStyle(
[
# Header row
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4F46E5")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 11),
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
# Data rows
("FONTNAME", (0, 1), (-1, -2), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -2), 9),
("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, colors.HexColor("#F9FAFB")]),
("GRID", (0, 0), (-1, -2), 0.5, colors.HexColor("#E5E7EB")),
# Total row
("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#F3F4F6")),
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
("FONTSIZE", (0, -1), (-1, -1), 11),
("TOPPADDING", (0, -1), (-1, -1), 12),
("BOTTOMPADDING", (0, -1), (-1, -1), 12),
# Alignment
("ALIGN", (2, 0), (-1, -1), "RIGHT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]
)
)
elements.append(time_table)
elements.append(Spacer(1, 0.5 * inch))
# Payment Terms
terms_style = ParagraphStyle(
'Terms',
parent=styles['Normal'],
"Terms",
parent=styles["Normal"],
fontSize=9,
textColor=colors.HexColor('#6B7280'),
textColor=colors.HexColor("#6B7280"),
)
elements.append(Paragraph("<b>Payment Terms:</b> Due within 30 days", terms_style))
elements.append(Spacer(1, 0.1 * inch))
@@ -179,10 +177,7 @@ def generate_invoice_pdf(
def generate_invoice_from_project(
project_id: int,
client: Dict[str, Any],
time_entries: List[Dict[str, Any]],
invoice_number: str = None
project_id: int, client: dict[str, Any], time_entries: list[dict[str, Any]], invoice_number: str = None
) -> bytes:
"""Generate invoice for a project"""
if not invoice_number:
@@ -195,5 +190,5 @@ def generate_invoice_from_project(
company_name="Your OPC",
company_address="123 Business St, City, State 12345",
company_email="billing@youropc.com",
company_phone="+1 (555) 123-4567"
company_phone="+1 (555) 123-4567",
)