forked from cardosofelipe/fast-next-template
Compare commits
11 Commits
79cb6bfd7b
...
8e16e2645e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e16e2645e | ||
|
|
82c3a6ba47 | ||
|
|
b6c38cac88 | ||
|
|
51404216ae | ||
|
|
3f23bc3db3 | ||
|
|
a0ec5fa2cc | ||
|
|
f262d08be2 | ||
|
|
b3f371e0a3 | ||
|
|
93cc37224c | ||
|
|
5717bffd63 | ||
|
|
9339ea30a1 |
24
Makefile
24
Makefile
@@ -1,5 +1,5 @@
|
|||||||
.PHONY: help dev dev-full prod down logs logs-dev clean clean-slate drop-db reset-db push-images deploy
|
.PHONY: help dev dev-full prod down logs logs-dev clean clean-slate drop-db reset-db push-images deploy
|
||||||
.PHONY: test test-backend test-mcp test-frontend test-all test-cov test-integration validate validate-all
|
.PHONY: test test-backend test-mcp test-frontend test-all test-cov test-integration validate validate-all format-all
|
||||||
|
|
||||||
VERSION ?= latest
|
VERSION ?= latest
|
||||||
REGISTRY ?= ghcr.io/cardosofelipe/pragma-stack
|
REGISTRY ?= ghcr.io/cardosofelipe/pragma-stack
|
||||||
@@ -22,6 +22,9 @@ help:
|
|||||||
@echo " make test-cov - Run all tests with coverage reports"
|
@echo " make test-cov - Run all tests with coverage reports"
|
||||||
@echo " make test-integration - Run MCP integration tests (requires running stack)"
|
@echo " make test-integration - Run MCP integration tests (requires running stack)"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo "Formatting:"
|
||||||
|
@echo " make format-all - Format code in backend + MCP servers + frontend"
|
||||||
|
@echo ""
|
||||||
@echo "Validation:"
|
@echo "Validation:"
|
||||||
@echo " make validate - Validate backend + MCP servers (lint, type-check, test)"
|
@echo " make validate - Validate backend + MCP servers (lint, type-check, test)"
|
||||||
@echo " make validate-all - Validate everything including frontend"
|
@echo " make validate-all - Validate everything including frontend"
|
||||||
@@ -161,6 +164,25 @@ test-integration:
|
|||||||
@echo "Note: Requires running stack (make dev first)"
|
@echo "Note: Requires running stack (make dev first)"
|
||||||
@cd backend && RUN_INTEGRATION_TESTS=true IS_TEST=True uv run pytest tests/integration/ -v
|
@cd backend && RUN_INTEGRATION_TESTS=true IS_TEST=True uv run pytest tests/integration/ -v
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Formatting
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
format-all:
|
||||||
|
@echo "Formatting backend..."
|
||||||
|
@cd backend && make format
|
||||||
|
@echo ""
|
||||||
|
@echo "Formatting LLM Gateway..."
|
||||||
|
@cd mcp-servers/llm-gateway && make format
|
||||||
|
@echo ""
|
||||||
|
@echo "Formatting Knowledge Base..."
|
||||||
|
@cd mcp-servers/knowledge-base && make format
|
||||||
|
@echo ""
|
||||||
|
@echo "Formatting frontend..."
|
||||||
|
@cd frontend && npm run format
|
||||||
|
@echo ""
|
||||||
|
@echo "All code formatted!"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Validation (lint + type-check + test)
|
# Validation (lint + type-check + test)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Add category and display fields to agent_types table
|
||||||
|
|
||||||
|
Revision ID: 0007
|
||||||
|
Revises: 0006
|
||||||
|
Create Date: 2026-01-06
|
||||||
|
|
||||||
|
This migration adds:
|
||||||
|
- category: String(50) for grouping agents by role type
|
||||||
|
- icon: String(50) for Lucide icon identifier
|
||||||
|
- color: String(7) for hex color code
|
||||||
|
- sort_order: Integer for display ordering within categories
|
||||||
|
- typical_tasks: JSONB list of tasks this agent excels at
|
||||||
|
- collaboration_hints: JSONB list of agent slugs that work well together
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0007"
|
||||||
|
down_revision: str | None = "0006"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add category and display fields to agent_types table."""
|
||||||
|
# Add new columns
|
||||||
|
op.add_column(
|
||||||
|
"agent_types",
|
||||||
|
sa.Column("category", sa.String(length=50), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agent_types",
|
||||||
|
sa.Column("icon", sa.String(length=50), nullable=True, server_default="bot"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agent_types",
|
||||||
|
sa.Column(
|
||||||
|
"color", sa.String(length=7), nullable=True, server_default="#3B82F6"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agent_types",
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agent_types",
|
||||||
|
sa.Column(
|
||||||
|
"typical_tasks",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
server_default="[]",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agent_types",
|
||||||
|
sa.Column(
|
||||||
|
"collaboration_hints",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
server_default="[]",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add indexes for category and sort_order
|
||||||
|
op.create_index("ix_agent_types_category", "agent_types", ["category"])
|
||||||
|
op.create_index("ix_agent_types_sort_order", "agent_types", ["sort_order"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_agent_types_category_sort", "agent_types", ["category", "sort_order"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove category and display fields from agent_types table."""
|
||||||
|
# Drop indexes
|
||||||
|
op.drop_index("ix_agent_types_category_sort", table_name="agent_types")
|
||||||
|
op.drop_index("ix_agent_types_sort_order", table_name="agent_types")
|
||||||
|
op.drop_index("ix_agent_types_category", table_name="agent_types")
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.drop_column("agent_types", "collaboration_hints")
|
||||||
|
op.drop_column("agent_types", "typical_tasks")
|
||||||
|
op.drop_column("agent_types", "sort_order")
|
||||||
|
op.drop_column("agent_types", "color")
|
||||||
|
op.drop_column("agent_types", "icon")
|
||||||
|
op.drop_column("agent_types", "category")
|
||||||
@@ -81,6 +81,13 @@ def _build_agent_type_response(
|
|||||||
mcp_servers=agent_type.mcp_servers,
|
mcp_servers=agent_type.mcp_servers,
|
||||||
tool_permissions=agent_type.tool_permissions,
|
tool_permissions=agent_type.tool_permissions,
|
||||||
is_active=agent_type.is_active,
|
is_active=agent_type.is_active,
|
||||||
|
# Category and display fields
|
||||||
|
category=agent_type.category,
|
||||||
|
icon=agent_type.icon,
|
||||||
|
color=agent_type.color,
|
||||||
|
sort_order=agent_type.sort_order,
|
||||||
|
typical_tasks=agent_type.typical_tasks or [],
|
||||||
|
collaboration_hints=agent_type.collaboration_hints or [],
|
||||||
created_at=agent_type.created_at,
|
created_at=agent_type.created_at,
|
||||||
updated_at=agent_type.updated_at,
|
updated_at=agent_type.updated_at,
|
||||||
instance_count=instance_count,
|
instance_count=instance_count,
|
||||||
@@ -300,6 +307,7 @@ async def list_agent_types(
|
|||||||
request: Request,
|
request: Request,
|
||||||
pagination: PaginationParams = Depends(),
|
pagination: PaginationParams = Depends(),
|
||||||
is_active: bool = Query(True, description="Filter by active status"),
|
is_active: bool = Query(True, description="Filter by active status"),
|
||||||
|
category: str | None = Query(None, description="Filter by category"),
|
||||||
search: str | None = Query(None, description="Search by name, slug, description"),
|
search: str | None = Query(None, description="Search by name, slug, description"),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -314,6 +322,7 @@ async def list_agent_types(
|
|||||||
request: FastAPI request object
|
request: FastAPI request object
|
||||||
pagination: Pagination parameters (page, limit)
|
pagination: Pagination parameters (page, limit)
|
||||||
is_active: Filter by active status (default: True)
|
is_active: Filter by active status (default: True)
|
||||||
|
category: Filter by category (e.g., "development", "design")
|
||||||
search: Optional search term for name, slug, description
|
search: Optional search term for name, slug, description
|
||||||
current_user: Authenticated user
|
current_user: Authenticated user
|
||||||
db: Database session
|
db: Database session
|
||||||
@@ -328,6 +337,7 @@ async def list_agent_types(
|
|||||||
skip=pagination.offset,
|
skip=pagination.offset,
|
||||||
limit=pagination.limit,
|
limit=pagination.limit,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
|
category=category,
|
||||||
search=search,
|
search=search,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -354,6 +364,51 @@ async def list_agent_types(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/grouped",
|
||||||
|
response_model=dict[str, list[AgentTypeResponse]],
|
||||||
|
summary="List Agent Types Grouped by Category",
|
||||||
|
description="Get all agent types organized by category",
|
||||||
|
operation_id="list_agent_types_grouped",
|
||||||
|
)
|
||||||
|
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||||
|
async def list_agent_types_grouped(
|
||||||
|
request: Request,
|
||||||
|
is_active: bool = Query(True, description="Filter by active status"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Get agent types grouped by category.
|
||||||
|
|
||||||
|
Returns a dictionary where keys are category names and values
|
||||||
|
are lists of agent types, sorted by sort_order within each category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
is_active: Filter by active status (default: True)
|
||||||
|
current_user: Authenticated user
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping category to list of agent types
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
grouped = await agent_type_crud.get_grouped_by_category(db, is_active=is_active)
|
||||||
|
|
||||||
|
# Transform to response objects
|
||||||
|
result: dict[str, list[AgentTypeResponse]] = {}
|
||||||
|
for category, types in grouped.items():
|
||||||
|
result[category] = [
|
||||||
|
_build_agent_type_response(t, instance_count=0) for t in types
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting grouped agent types: {e!s}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{agent_type_id}",
|
"/{agent_type_id}",
|
||||||
response_model=AgentTypeResponse,
|
response_model=AgentTypeResponse,
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
|||||||
mcp_servers=obj_in.mcp_servers,
|
mcp_servers=obj_in.mcp_servers,
|
||||||
tool_permissions=obj_in.tool_permissions,
|
tool_permissions=obj_in.tool_permissions,
|
||||||
is_active=obj_in.is_active,
|
is_active=obj_in.is_active,
|
||||||
|
# Category and display fields
|
||||||
|
category=obj_in.category.value if obj_in.category else None,
|
||||||
|
icon=obj_in.icon,
|
||||||
|
color=obj_in.color,
|
||||||
|
sort_order=obj_in.sort_order,
|
||||||
|
typical_tasks=obj_in.typical_tasks,
|
||||||
|
collaboration_hints=obj_in.collaboration_hints,
|
||||||
)
|
)
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -68,6 +75,7 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
is_active: bool | None = None,
|
is_active: bool | None = None,
|
||||||
|
category: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
sort_by: str = "created_at",
|
sort_by: str = "created_at",
|
||||||
sort_order: str = "desc",
|
sort_order: str = "desc",
|
||||||
@@ -85,6 +93,9 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
|||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
query = query.where(AgentType.is_active == is_active)
|
query = query.where(AgentType.is_active == is_active)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.where(AgentType.category == category)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
search_filter = or_(
|
search_filter = or_(
|
||||||
AgentType.name.ilike(f"%{search}%"),
|
AgentType.name.ilike(f"%{search}%"),
|
||||||
@@ -162,6 +173,7 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
is_active: bool | None = None,
|
is_active: bool | None = None,
|
||||||
|
category: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
) -> tuple[list[dict[str, Any]], int]:
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
"""
|
"""
|
||||||
@@ -177,6 +189,7 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
|||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
|
category=category,
|
||||||
search=search,
|
search=search,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -260,6 +273,44 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def get_grouped_by_category(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> dict[str, list[AgentType]]:
|
||||||
|
"""
|
||||||
|
Get agent types grouped by category, sorted by sort_order within each group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
is_active: Filter by active status (default: True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping category to list of agent types
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(AgentType)
|
||||||
|
.where(AgentType.is_active == is_active)
|
||||||
|
.order_by(AgentType.category, AgentType.sort_order, AgentType.name)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
agent_types = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Group by category
|
||||||
|
grouped: dict[str, list[AgentType]] = {}
|
||||||
|
for at in agent_types:
|
||||||
|
cat: str = str(at.category) if at.category else "uncategorized"
|
||||||
|
if cat not in grouped:
|
||||||
|
grouped[cat] = []
|
||||||
|
grouped[cat].append(at)
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting grouped agent types: {e!s}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
# Create a singleton instance for use across the application
|
# Create a singleton instance for use across the application
|
||||||
agent_type = CRUDAgentType(AgentType)
|
agent_type = CRUDAgentType(AgentType)
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ async def load_default_agent_types(session: AsyncSession) -> None:
|
|||||||
mcp_servers=agent_type_data.get("mcp_servers", []),
|
mcp_servers=agent_type_data.get("mcp_servers", []),
|
||||||
tool_permissions=agent_type_data.get("tool_permissions", {}),
|
tool_permissions=agent_type_data.get("tool_permissions", {}),
|
||||||
is_active=agent_type_data.get("is_active", True),
|
is_active=agent_type_data.get("is_active", True),
|
||||||
|
# Category and display fields
|
||||||
|
category=agent_type_data.get("category"),
|
||||||
|
icon=agent_type_data.get("icon", "bot"),
|
||||||
|
color=agent_type_data.get("color", "#3B82F6"),
|
||||||
|
sort_order=agent_type_data.get("sort_order", 0),
|
||||||
|
typical_tasks=agent_type_data.get("typical_tasks", []),
|
||||||
|
collaboration_hints=agent_type_data.get("collaboration_hints", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
await agent_type_crud.create(session, obj_in=agent_type_in)
|
await agent_type_crud.create(session, obj_in=agent_type_in)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ An AgentType is a template that defines the capabilities, personality,
|
|||||||
and model configuration for agent instances.
|
and model configuration for agent instances.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, Index, String, Text
|
from sqlalchemy import Boolean, Column, Index, Integer, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
@@ -56,6 +56,24 @@ class AgentType(Base, UUIDMixin, TimestampMixin):
|
|||||||
# Whether this agent type is available for new instances
|
# Whether this agent type is available for new instances
|
||||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Category for grouping agents (development, design, quality, etc.)
|
||||||
|
category = Column(String(50), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Lucide icon identifier for UI display (e.g., "code", "palette", "shield")
|
||||||
|
icon = Column(String(50), nullable=True, default="bot")
|
||||||
|
|
||||||
|
# Hex color code for visual distinction (e.g., "#3B82F6")
|
||||||
|
color = Column(String(7), nullable=True, default="#3B82F6")
|
||||||
|
|
||||||
|
# Display ordering within category (lower = first)
|
||||||
|
sort_order = Column(Integer, nullable=False, default=0, index=True)
|
||||||
|
|
||||||
|
# List of typical tasks this agent excels at
|
||||||
|
typical_tasks = Column(JSONB, default=list, nullable=False)
|
||||||
|
|
||||||
|
# List of agent slugs that collaborate well with this type
|
||||||
|
collaboration_hints = Column(JSONB, default=list, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
instances = relationship(
|
instances = relationship(
|
||||||
"AgentInstance",
|
"AgentInstance",
|
||||||
@@ -66,6 +84,7 @@ class AgentType(Base, UUIDMixin, TimestampMixin):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_agent_types_slug_active", "slug", "is_active"),
|
Index("ix_agent_types_slug_active", "slug", "is_active"),
|
||||||
Index("ix_agent_types_name_active", "name", "is_active"),
|
Index("ix_agent_types_name_active", "name", "is_active"),
|
||||||
|
Index("ix_agent_types_category_sort", "category", "sort_order"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|||||||
@@ -167,3 +167,29 @@ class SprintStatus(str, PyEnum):
|
|||||||
IN_REVIEW = "in_review"
|
IN_REVIEW = "in_review"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
CANCELLED = "cancelled"
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTypeCategory(str, PyEnum):
|
||||||
|
"""
|
||||||
|
Category classification for agent types.
|
||||||
|
|
||||||
|
Used for grouping and filtering agents in the UI.
|
||||||
|
|
||||||
|
DEVELOPMENT: Product, project, and engineering roles
|
||||||
|
DESIGN: UI/UX and design research roles
|
||||||
|
QUALITY: QA and security engineering
|
||||||
|
OPERATIONS: DevOps and MLOps
|
||||||
|
AI_ML: Machine learning and AI specialists
|
||||||
|
DATA: Data science and engineering
|
||||||
|
LEADERSHIP: Technical leadership roles
|
||||||
|
DOMAIN_EXPERT: Industry and domain specialists
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEVELOPMENT = "development"
|
||||||
|
DESIGN = "design"
|
||||||
|
QUALITY = "quality"
|
||||||
|
OPERATIONS = "operations"
|
||||||
|
AI_ML = "ai_ml"
|
||||||
|
DATA = "data"
|
||||||
|
LEADERSHIP = "leadership"
|
||||||
|
DOMAIN_EXPERT = "domain_expert"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from uuid import UUID
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.models.syndarix.enums import AgentTypeCategory
|
||||||
|
|
||||||
|
|
||||||
class AgentTypeBase(BaseModel):
|
class AgentTypeBase(BaseModel):
|
||||||
"""Base agent type schema with common fields."""
|
"""Base agent type schema with common fields."""
|
||||||
@@ -26,6 +28,14 @@ class AgentTypeBase(BaseModel):
|
|||||||
tool_permissions: dict[str, Any] = Field(default_factory=dict)
|
tool_permissions: dict[str, Any] = Field(default_factory=dict)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
# Category and display fields
|
||||||
|
category: AgentTypeCategory | None = None
|
||||||
|
icon: str | None = Field(None, max_length=50)
|
||||||
|
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
sort_order: int = Field(default=0, ge=0, le=1000)
|
||||||
|
typical_tasks: list[str] = Field(default_factory=list)
|
||||||
|
collaboration_hints: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
@field_validator("slug")
|
@field_validator("slug")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_slug(cls, v: str | None) -> str | None:
|
def validate_slug(cls, v: str | None) -> str | None:
|
||||||
@@ -62,6 +72,18 @@ class AgentTypeBase(BaseModel):
|
|||||||
"""Validate MCP server list."""
|
"""Validate MCP server list."""
|
||||||
return [s.strip() for s in v if s.strip()]
|
return [s.strip() for s in v if s.strip()]
|
||||||
|
|
||||||
|
@field_validator("typical_tasks")
|
||||||
|
@classmethod
|
||||||
|
def validate_typical_tasks(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate and normalize typical tasks list."""
|
||||||
|
return [t.strip() for t in v if t.strip()]
|
||||||
|
|
||||||
|
@field_validator("collaboration_hints")
|
||||||
|
@classmethod
|
||||||
|
def validate_collaboration_hints(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate and normalize collaboration hints (agent slugs)."""
|
||||||
|
return [h.strip().lower() for h in v if h.strip()]
|
||||||
|
|
||||||
|
|
||||||
class AgentTypeCreate(AgentTypeBase):
|
class AgentTypeCreate(AgentTypeBase):
|
||||||
"""Schema for creating a new agent type."""
|
"""Schema for creating a new agent type."""
|
||||||
@@ -87,6 +109,14 @@ class AgentTypeUpdate(BaseModel):
|
|||||||
tool_permissions: dict[str, Any] | None = None
|
tool_permissions: dict[str, Any] | None = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
# Category and display fields (all optional for updates)
|
||||||
|
category: AgentTypeCategory | None = None
|
||||||
|
icon: str | None = Field(None, max_length=50)
|
||||||
|
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
sort_order: int | None = Field(None, ge=0, le=1000)
|
||||||
|
typical_tasks: list[str] | None = None
|
||||||
|
collaboration_hints: list[str] | None = None
|
||||||
|
|
||||||
@field_validator("slug")
|
@field_validator("slug")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_slug(cls, v: str | None) -> str | None:
|
def validate_slug(cls, v: str | None) -> str | None:
|
||||||
@@ -119,6 +149,22 @@ class AgentTypeUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
return [e.strip().lower() for e in v if e.strip()]
|
return [e.strip().lower() for e in v if e.strip()]
|
||||||
|
|
||||||
|
@field_validator("typical_tasks")
|
||||||
|
@classmethod
|
||||||
|
def validate_typical_tasks(cls, v: list[str] | None) -> list[str] | None:
|
||||||
|
"""Validate and normalize typical tasks list."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
return [t.strip() for t in v if t.strip()]
|
||||||
|
|
||||||
|
@field_validator("collaboration_hints")
|
||||||
|
@classmethod
|
||||||
|
def validate_collaboration_hints(cls, v: list[str] | None) -> list[str] | None:
|
||||||
|
"""Validate and normalize collaboration hints (agent slugs)."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
return [h.strip().lower() for h in v if h.strip()]
|
||||||
|
|
||||||
|
|
||||||
class AgentTypeInDB(AgentTypeBase):
|
class AgentTypeInDB(AgentTypeBase):
|
||||||
"""Schema for agent type in database."""
|
"""Schema for agent type in database."""
|
||||||
|
|||||||
@@ -29,7 +29,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:delete_*"]
|
"require_approval": ["gitea:delete_*"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "development",
|
||||||
|
"icon": "clipboard-check",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["Requirements discovery", "User story creation", "Backlog prioritization", "Stakeholder alignment"],
|
||||||
|
"collaboration_hints": ["business-analyst", "solutions-architect", "scrum-master"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Project Manager",
|
"name": "Project Manager",
|
||||||
@@ -61,7 +67,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "development",
|
||||||
|
"icon": "briefcase",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 20,
|
||||||
|
"typical_tasks": ["Sprint planning", "Risk management", "Status reporting", "Team coordination"],
|
||||||
|
"collaboration_hints": ["product-owner", "scrum-master", "technical-lead"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Business Analyst",
|
"name": "Business Analyst",
|
||||||
@@ -93,7 +105,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "development",
|
||||||
|
"icon": "file-text",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 30,
|
||||||
|
"typical_tasks": ["Requirements analysis", "Process modeling", "Gap analysis", "Functional specifications"],
|
||||||
|
"collaboration_hints": ["product-owner", "solutions-architect", "qa-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Solutions Architect",
|
"name": "Solutions Architect",
|
||||||
@@ -129,7 +147,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request"]
|
"require_approval": ["gitea:create_pull_request"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "development",
|
||||||
|
"icon": "git-branch",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 40,
|
||||||
|
"typical_tasks": ["System design", "ADR creation", "Technology selection", "Integration patterns"],
|
||||||
|
"collaboration_hints": ["backend-engineer", "frontend-engineer", "security-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Full Stack Engineer",
|
"name": "Full Stack Engineer",
|
||||||
@@ -166,7 +190,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request", "gitea:delete_*"]
|
"require_approval": ["gitea:create_pull_request", "gitea:delete_*"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "development",
|
||||||
|
"icon": "code",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 50,
|
||||||
|
"typical_tasks": ["End-to-end feature development", "API design", "UI implementation", "Database operations"],
|
||||||
|
"collaboration_hints": ["solutions-architect", "qa-engineer", "devops-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Backend Engineer",
|
"name": "Backend Engineer",
|
||||||
@@ -208,7 +238,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request", "gitea:delete_*"]
|
"require_approval": ["gitea:create_pull_request", "gitea:delete_*"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "development",
|
||||||
|
"icon": "server",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 60,
|
||||||
|
"typical_tasks": ["API development", "Database optimization", "System integration", "Performance tuning"],
|
||||||
|
"collaboration_hints": ["solutions-architect", "frontend-engineer", "data-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Frontend Engineer",
|
"name": "Frontend Engineer",
|
||||||
@@ -249,7 +285,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request", "gitea:delete_*"]
|
"require_approval": ["gitea:create_pull_request", "gitea:delete_*"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "development",
|
||||||
|
"icon": "layout",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 70,
|
||||||
|
"typical_tasks": ["UI component development", "State management", "API integration", "Responsive design"],
|
||||||
|
"collaboration_hints": ["ui-ux-designer", "backend-engineer", "qa-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Mobile Engineer",
|
"name": "Mobile Engineer",
|
||||||
@@ -286,7 +328,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request", "gitea:delete_*"]
|
"require_approval": ["gitea:create_pull_request", "gitea:delete_*"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "development",
|
||||||
|
"icon": "smartphone",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 80,
|
||||||
|
"typical_tasks": ["Native app development", "Cross-platform solutions", "Mobile optimization", "App store deployment"],
|
||||||
|
"collaboration_hints": ["backend-engineer", "ui-ux-designer", "qa-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "UI/UX Designer",
|
"name": "UI/UX Designer",
|
||||||
@@ -321,7 +369,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "design",
|
||||||
|
"icon": "palette",
|
||||||
|
"color": "#EC4899",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["Interface design", "User flow creation", "Design system maintenance", "Prototyping"],
|
||||||
|
"collaboration_hints": ["frontend-engineer", "ux-researcher", "product-owner"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "UX Researcher",
|
"name": "UX Researcher",
|
||||||
@@ -355,7 +409,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "design",
|
||||||
|
"icon": "search",
|
||||||
|
"color": "#EC4899",
|
||||||
|
"sort_order": 20,
|
||||||
|
"typical_tasks": ["User research", "Usability testing", "Journey mapping", "Research synthesis"],
|
||||||
|
"collaboration_hints": ["ui-ux-designer", "product-owner", "business-analyst"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "QA Engineer",
|
"name": "QA Engineer",
|
||||||
@@ -391,7 +451,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "quality",
|
||||||
|
"icon": "shield",
|
||||||
|
"color": "#10B981",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["Test strategy development", "Test automation", "Bug verification", "Quality metrics"],
|
||||||
|
"collaboration_hints": ["backend-engineer", "frontend-engineer", "devops-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "DevOps Engineer",
|
"name": "DevOps Engineer",
|
||||||
@@ -431,7 +497,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_release", "gitea:delete_*"]
|
"require_approval": ["gitea:create_release", "gitea:delete_*"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "operations",
|
||||||
|
"icon": "settings",
|
||||||
|
"color": "#F59E0B",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["CI/CD pipeline design", "Infrastructure automation", "Monitoring setup", "Deployment optimization"],
|
||||||
|
"collaboration_hints": ["backend-engineer", "security-engineer", "mlops-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Security Engineer",
|
"name": "Security Engineer",
|
||||||
@@ -467,7 +539,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "quality",
|
||||||
|
"icon": "shield-check",
|
||||||
|
"color": "#10B981",
|
||||||
|
"sort_order": 20,
|
||||||
|
"typical_tasks": ["Security architecture", "Vulnerability assessment", "Compliance validation", "Threat modeling"],
|
||||||
|
"collaboration_hints": ["solutions-architect", "devops-engineer", "backend-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "AI/ML Engineer",
|
"name": "AI/ML Engineer",
|
||||||
@@ -503,7 +581,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request"]
|
"require_approval": ["gitea:create_pull_request"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "ai_ml",
|
||||||
|
"icon": "brain",
|
||||||
|
"color": "#8B5CF6",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["Model development", "Algorithm selection", "Feature engineering", "Model optimization"],
|
||||||
|
"collaboration_hints": ["data-scientist", "mlops-engineer", "backend-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "AI Researcher",
|
"name": "AI Researcher",
|
||||||
@@ -537,7 +621,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "ai_ml",
|
||||||
|
"icon": "microscope",
|
||||||
|
"color": "#8B5CF6",
|
||||||
|
"sort_order": 20,
|
||||||
|
"typical_tasks": ["Research paper analysis", "Novel algorithm design", "Experiment design", "Benchmark evaluation"],
|
||||||
|
"collaboration_hints": ["ai-ml-engineer", "data-scientist", "scientific-computing-expert"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Computer Vision Engineer",
|
"name": "Computer Vision Engineer",
|
||||||
@@ -573,7 +663,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request"]
|
"require_approval": ["gitea:create_pull_request"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "ai_ml",
|
||||||
|
"icon": "eye",
|
||||||
|
"color": "#8B5CF6",
|
||||||
|
"sort_order": 30,
|
||||||
|
"typical_tasks": ["Image processing pipelines", "Object detection models", "Video analysis", "Computer vision deployment"],
|
||||||
|
"collaboration_hints": ["ai-ml-engineer", "mlops-engineer", "backend-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "NLP Engineer",
|
"name": "NLP Engineer",
|
||||||
@@ -609,7 +705,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request"]
|
"require_approval": ["gitea:create_pull_request"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "ai_ml",
|
||||||
|
"icon": "message-square",
|
||||||
|
"color": "#8B5CF6",
|
||||||
|
"sort_order": 40,
|
||||||
|
"typical_tasks": ["Text processing pipelines", "Language model fine-tuning", "Named entity recognition", "Sentiment analysis"],
|
||||||
|
"collaboration_hints": ["ai-ml-engineer", "data-scientist", "backend-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "MLOps Engineer",
|
"name": "MLOps Engineer",
|
||||||
@@ -645,7 +747,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_release"]
|
"require_approval": ["gitea:create_release"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "operations",
|
||||||
|
"icon": "settings-2",
|
||||||
|
"color": "#F59E0B",
|
||||||
|
"sort_order": 20,
|
||||||
|
"typical_tasks": ["ML pipeline development", "Model deployment", "Feature store management", "Model monitoring"],
|
||||||
|
"collaboration_hints": ["ai-ml-engineer", "devops-engineer", "data-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Data Scientist",
|
"name": "Data Scientist",
|
||||||
@@ -681,7 +789,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "data",
|
||||||
|
"icon": "chart-bar",
|
||||||
|
"color": "#06B6D4",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["Statistical analysis", "Predictive modeling", "Data visualization", "Insight generation"],
|
||||||
|
"collaboration_hints": ["data-engineer", "ai-ml-engineer", "business-analyst"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Data Engineer",
|
"name": "Data Engineer",
|
||||||
@@ -717,7 +831,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": ["gitea:create_pull_request"]
|
"require_approval": ["gitea:create_pull_request"]
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "data",
|
||||||
|
"icon": "database",
|
||||||
|
"color": "#06B6D4",
|
||||||
|
"sort_order": 20,
|
||||||
|
"typical_tasks": ["Data pipeline development", "ETL optimization", "Data warehouse design", "Data quality management"],
|
||||||
|
"collaboration_hints": ["data-scientist", "backend-engineer", "mlops-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Technical Lead",
|
"name": "Technical Lead",
|
||||||
@@ -749,7 +869,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "leadership",
|
||||||
|
"icon": "users",
|
||||||
|
"color": "#F97316",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["Technical direction", "Code review leadership", "Team mentoring", "Architecture decisions"],
|
||||||
|
"collaboration_hints": ["solutions-architect", "backend-engineer", "frontend-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Scrum Master",
|
"name": "Scrum Master",
|
||||||
@@ -781,7 +907,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "leadership",
|
||||||
|
"icon": "target",
|
||||||
|
"color": "#F97316",
|
||||||
|
"sort_order": 20,
|
||||||
|
"typical_tasks": ["Sprint facilitation", "Impediment removal", "Process improvement", "Team coaching"],
|
||||||
|
"collaboration_hints": ["project-manager", "product-owner", "technical-lead"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Financial Systems Expert",
|
"name": "Financial Systems Expert",
|
||||||
@@ -816,7 +948,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "domain_expert",
|
||||||
|
"icon": "calculator",
|
||||||
|
"color": "#84CC16",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["Financial system design", "Regulatory compliance", "Transaction processing", "Audit trail implementation"],
|
||||||
|
"collaboration_hints": ["solutions-architect", "security-engineer", "backend-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Healthcare Systems Expert",
|
"name": "Healthcare Systems Expert",
|
||||||
@@ -850,7 +988,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "domain_expert",
|
||||||
|
"icon": "heart-pulse",
|
||||||
|
"color": "#84CC16",
|
||||||
|
"sort_order": 20,
|
||||||
|
"typical_tasks": ["Healthcare system design", "HIPAA compliance", "HL7/FHIR integration", "Clinical workflow optimization"],
|
||||||
|
"collaboration_hints": ["solutions-architect", "security-engineer", "data-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Scientific Computing Expert",
|
"name": "Scientific Computing Expert",
|
||||||
@@ -886,7 +1030,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "domain_expert",
|
||||||
|
"icon": "flask",
|
||||||
|
"color": "#84CC16",
|
||||||
|
"sort_order": 30,
|
||||||
|
"typical_tasks": ["HPC architecture", "Scientific algorithm implementation", "Data pipeline optimization", "Numerical computing"],
|
||||||
|
"collaboration_hints": ["ai-researcher", "data-scientist", "backend-engineer"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Behavioral Psychology Expert",
|
"name": "Behavioral Psychology Expert",
|
||||||
@@ -919,7 +1069,13 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "domain_expert",
|
||||||
|
"icon": "lightbulb",
|
||||||
|
"color": "#84CC16",
|
||||||
|
"sort_order": 40,
|
||||||
|
"typical_tasks": ["Behavioral design", "Engagement optimization", "User motivation analysis", "Ethical AI guidelines"],
|
||||||
|
"collaboration_hints": ["ux-researcher", "ui-ux-designer", "product-owner"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Technical Writer",
|
"name": "Technical Writer",
|
||||||
@@ -951,6 +1107,12 @@
|
|||||||
"denied": [],
|
"denied": [],
|
||||||
"require_approval": []
|
"require_approval": []
|
||||||
},
|
},
|
||||||
"is_active": true
|
"is_active": true,
|
||||||
|
"category": "domain_expert",
|
||||||
|
"icon": "book-open",
|
||||||
|
"color": "#84CC16",
|
||||||
|
"sort_order": 50,
|
||||||
|
"typical_tasks": ["API documentation", "User guides", "Technical specifications", "Knowledge base creation"],
|
||||||
|
"collaboration_hints": ["solutions-architect", "product-owner", "qa-engineer"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Usage:
|
|||||||
# Inside Docker (without --local flag):
|
# Inside Docker (without --local flag):
|
||||||
python migrate.py auto "Add new field"
|
python migrate.py auto "Add new field"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -44,13 +45,14 @@ def setup_database_url(use_local: bool) -> str:
|
|||||||
# Override DATABASE_URL to use localhost instead of Docker hostname
|
# Override DATABASE_URL to use localhost instead of Docker hostname
|
||||||
local_url = os.environ.get(
|
local_url = os.environ.get(
|
||||||
"LOCAL_DATABASE_URL",
|
"LOCAL_DATABASE_URL",
|
||||||
"postgresql://postgres:postgres@localhost:5432/app"
|
"postgresql://postgres:postgres@localhost:5432/syndarix",
|
||||||
)
|
)
|
||||||
os.environ["DATABASE_URL"] = local_url
|
os.environ["DATABASE_URL"] = local_url
|
||||||
return local_url
|
return local_url
|
||||||
|
|
||||||
# Use the configured DATABASE_URL from environment/.env
|
# Use the configured DATABASE_URL from environment/.env
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
return settings.database_url
|
return settings.database_url
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ def check_models():
|
|||||||
try:
|
try:
|
||||||
# Import all models through the models package
|
# Import all models through the models package
|
||||||
from app.models import __all__ as all_models
|
from app.models import __all__ as all_models
|
||||||
|
|
||||||
print(f"Found {len(all_models)} model(s):")
|
print(f"Found {len(all_models)} model(s):")
|
||||||
for model in all_models:
|
for model in all_models:
|
||||||
print(f" - {model}")
|
print(f" - {model}")
|
||||||
@@ -110,7 +113,9 @@ def generate_migration(message, rev_id=None, auto_rev_id=True, offline=False):
|
|||||||
# Look for the revision ID, which is typically 12 hex characters
|
# Look for the revision ID, which is typically 12 hex characters
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if len(part) >= 12 and all(c in "0123456789abcdef" for c in part[:12]):
|
if len(part) >= 12 and all(
|
||||||
|
c in "0123456789abcdef" for c in part[:12]
|
||||||
|
):
|
||||||
revision = part[:12]
|
revision = part[:12]
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -185,6 +190,7 @@ def check_database_connection():
|
|||||||
db_url = os.environ.get("DATABASE_URL")
|
db_url = os.environ.get("DATABASE_URL")
|
||||||
if not db_url:
|
if not db_url:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
db_url = settings.database_url
|
db_url = settings.database_url
|
||||||
|
|
||||||
engine = create_engine(db_url)
|
engine = create_engine(db_url)
|
||||||
@@ -270,8 +276,8 @@ def generate_offline_migration(message, rev_id):
|
|||||||
content = f'''"""{message}
|
content = f'''"""{message}
|
||||||
|
|
||||||
Revision ID: {rev_id}
|
Revision ID: {rev_id}
|
||||||
Revises: {down_revision or ''}
|
Revises: {down_revision or ""}
|
||||||
Create Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}
|
Create Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -320,6 +326,7 @@ def reset_alembic_version():
|
|||||||
db_url = os.environ.get("DATABASE_URL")
|
db_url = os.environ.get("DATABASE_URL")
|
||||||
if not db_url:
|
if not db_url:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
db_url = settings.database_url
|
db_url = settings.database_url
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -338,82 +345,80 @@ def reset_alembic_version():
|
|||||||
def main():
|
def main():
|
||||||
"""Main function"""
|
"""Main function"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Database migration helper for Generative Models Arena'
|
description="Database migration helper for Generative Models Arena"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Global options
|
# Global options
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--local', '-l',
|
"--local",
|
||||||
action='store_true',
|
"-l",
|
||||||
help='Use localhost instead of Docker hostname (for local development)'
|
action="store_true",
|
||||||
|
help="Use localhost instead of Docker hostname (for local development)",
|
||||||
)
|
)
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||||
|
|
||||||
# Generate command
|
# Generate command
|
||||||
generate_parser = subparsers.add_parser('generate', help='Generate a migration')
|
generate_parser = subparsers.add_parser("generate", help="Generate a migration")
|
||||||
generate_parser.add_argument('message', help='Migration message')
|
generate_parser.add_argument("message", help="Migration message")
|
||||||
generate_parser.add_argument(
|
generate_parser.add_argument(
|
||||||
'--rev-id',
|
"--rev-id", help="Custom revision ID (e.g., 0001, 0002 for sequential naming)"
|
||||||
help='Custom revision ID (e.g., 0001, 0002 for sequential naming)'
|
|
||||||
)
|
)
|
||||||
generate_parser.add_argument(
|
generate_parser.add_argument(
|
||||||
'--offline',
|
"--offline",
|
||||||
action='store_true',
|
action="store_true",
|
||||||
help='Generate empty migration template without database connection'
|
help="Generate empty migration template without database connection",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply command
|
# Apply command
|
||||||
apply_parser = subparsers.add_parser('apply', help='Apply migrations')
|
apply_parser = subparsers.add_parser("apply", help="Apply migrations")
|
||||||
apply_parser.add_argument('--revision', help='Specific revision to apply to')
|
apply_parser.add_argument("--revision", help="Specific revision to apply to")
|
||||||
|
|
||||||
# List command
|
# List command
|
||||||
subparsers.add_parser('list', help='List migrations')
|
subparsers.add_parser("list", help="List migrations")
|
||||||
|
|
||||||
# Current command
|
# Current command
|
||||||
subparsers.add_parser('current', help='Show current revision')
|
subparsers.add_parser("current", help="Show current revision")
|
||||||
|
|
||||||
# Check command
|
# Check command
|
||||||
subparsers.add_parser('check', help='Check database connection and models')
|
subparsers.add_parser("check", help="Check database connection and models")
|
||||||
|
|
||||||
# Next command (show next revision ID)
|
# Next command (show next revision ID)
|
||||||
subparsers.add_parser('next', help='Show the next sequential revision ID')
|
subparsers.add_parser("next", help="Show the next sequential revision ID")
|
||||||
|
|
||||||
# Reset command (clear alembic_version table)
|
# Reset command (clear alembic_version table)
|
||||||
subparsers.add_parser(
|
subparsers.add_parser(
|
||||||
'reset',
|
"reset", help="Reset alembic_version table (use after deleting all migrations)"
|
||||||
help='Reset alembic_version table (use after deleting all migrations)'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto command (generate and apply)
|
# Auto command (generate and apply)
|
||||||
auto_parser = subparsers.add_parser('auto', help='Generate and apply migration')
|
auto_parser = subparsers.add_parser("auto", help="Generate and apply migration")
|
||||||
auto_parser.add_argument('message', help='Migration message')
|
auto_parser.add_argument("message", help="Migration message")
|
||||||
auto_parser.add_argument(
|
auto_parser.add_argument(
|
||||||
'--rev-id',
|
"--rev-id", help="Custom revision ID (e.g., 0001, 0002 for sequential naming)"
|
||||||
help='Custom revision ID (e.g., 0001, 0002 for sequential naming)'
|
|
||||||
)
|
)
|
||||||
auto_parser.add_argument(
|
auto_parser.add_argument(
|
||||||
'--offline',
|
"--offline",
|
||||||
action='store_true',
|
action="store_true",
|
||||||
help='Generate empty migration template without database connection'
|
help="Generate empty migration template without database connection",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Commands that don't need database connection
|
# Commands that don't need database connection
|
||||||
if args.command == 'next':
|
if args.command == "next":
|
||||||
show_next_rev_id()
|
show_next_rev_id()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if offline mode is requested
|
# Check if offline mode is requested
|
||||||
offline = getattr(args, 'offline', False)
|
offline = getattr(args, "offline", False)
|
||||||
|
|
||||||
# Offline generate doesn't need database or model check
|
# Offline generate doesn't need database or model check
|
||||||
if args.command == 'generate' and offline:
|
if args.command == "generate" and offline:
|
||||||
generate_migration(args.message, rev_id=args.rev_id, offline=True)
|
generate_migration(args.message, rev_id=args.rev_id, offline=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.command == 'auto' and offline:
|
if args.command == "auto" and offline:
|
||||||
generate_migration(args.message, rev_id=args.rev_id, offline=True)
|
generate_migration(args.message, rev_id=args.rev_id, offline=True)
|
||||||
print("\nOffline migration generated. Apply it later with:")
|
print("\nOffline migration generated. Apply it later with:")
|
||||||
print(" python migrate.py --local apply")
|
print(" python migrate.py --local apply")
|
||||||
@@ -423,27 +428,27 @@ def main():
|
|||||||
db_url = setup_database_url(args.local)
|
db_url = setup_database_url(args.local)
|
||||||
print(f"Using database URL: {db_url}")
|
print(f"Using database URL: {db_url}")
|
||||||
|
|
||||||
if args.command == 'generate':
|
if args.command == "generate":
|
||||||
check_models()
|
check_models()
|
||||||
generate_migration(args.message, rev_id=args.rev_id)
|
generate_migration(args.message, rev_id=args.rev_id)
|
||||||
|
|
||||||
elif args.command == 'apply':
|
elif args.command == "apply":
|
||||||
apply_migration(args.revision)
|
apply_migration(args.revision)
|
||||||
|
|
||||||
elif args.command == 'list':
|
elif args.command == "list":
|
||||||
list_migrations()
|
list_migrations()
|
||||||
|
|
||||||
elif args.command == 'current':
|
elif args.command == "current":
|
||||||
show_current()
|
show_current()
|
||||||
|
|
||||||
elif args.command == 'check':
|
elif args.command == "check":
|
||||||
check_database_connection()
|
check_database_connection()
|
||||||
check_models()
|
check_models()
|
||||||
|
|
||||||
elif args.command == 'reset':
|
elif args.command == "reset":
|
||||||
reset_alembic_version()
|
reset_alembic_version()
|
||||||
|
|
||||||
elif args.command == 'auto':
|
elif args.command == "auto":
|
||||||
check_models()
|
check_models()
|
||||||
revision = generate_migration(args.message, rev_id=args.rev_id)
|
revision = generate_migration(args.message, rev_id=args.rev_id)
|
||||||
if revision:
|
if revision:
|
||||||
|
|||||||
@@ -745,3 +745,230 @@ class TestAgentTypeInstanceCount:
|
|||||||
for agent_type in data["data"]:
|
for agent_type in data["data"]:
|
||||||
assert "instance_count" in agent_type
|
assert "instance_count" in agent_type
|
||||||
assert isinstance(agent_type["instance_count"], int)
|
assert isinstance(agent_type["instance_count"], int)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestAgentTypeCategoryFields:
|
||||||
|
"""Tests for agent type category and display fields."""
|
||||||
|
|
||||||
|
async def test_create_agent_type_with_category_fields(
|
||||||
|
self, client, superuser_token
|
||||||
|
):
|
||||||
|
"""Test creating agent type with all category and display fields."""
|
||||||
|
unique_slug = f"category-type-{uuid.uuid4().hex[:8]}"
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/agent-types",
|
||||||
|
json={
|
||||||
|
"name": "Categorized Agent Type",
|
||||||
|
"slug": unique_slug,
|
||||||
|
"description": "An agent type with category fields",
|
||||||
|
"expertise": ["python"],
|
||||||
|
"personality_prompt": "You are a helpful assistant.",
|
||||||
|
"primary_model": "claude-opus-4-5-20251101",
|
||||||
|
# Category and display fields
|
||||||
|
"category": "development",
|
||||||
|
"icon": "code",
|
||||||
|
"color": "#3B82F6",
|
||||||
|
"sort_order": 10,
|
||||||
|
"typical_tasks": ["Write code", "Review PRs"],
|
||||||
|
"collaboration_hints": ["backend-engineer", "qa-engineer"],
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["category"] == "development"
|
||||||
|
assert data["icon"] == "code"
|
||||||
|
assert data["color"] == "#3B82F6"
|
||||||
|
assert data["sort_order"] == 10
|
||||||
|
assert data["typical_tasks"] == ["Write code", "Review PRs"]
|
||||||
|
assert data["collaboration_hints"] == ["backend-engineer", "qa-engineer"]
|
||||||
|
|
||||||
|
async def test_create_agent_type_with_nullable_category(
|
||||||
|
self, client, superuser_token
|
||||||
|
):
|
||||||
|
"""Test creating agent type with null category."""
|
||||||
|
unique_slug = f"null-category-{uuid.uuid4().hex[:8]}"
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/agent-types",
|
||||||
|
json={
|
||||||
|
"name": "Uncategorized Agent",
|
||||||
|
"slug": unique_slug,
|
||||||
|
"expertise": ["general"],
|
||||||
|
"personality_prompt": "You are a helpful assistant.",
|
||||||
|
"primary_model": "claude-opus-4-5-20251101",
|
||||||
|
"category": None,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
data = response.json()
|
||||||
|
assert data["category"] is None
|
||||||
|
|
||||||
|
async def test_create_agent_type_invalid_color_format(
|
||||||
|
self, client, superuser_token
|
||||||
|
):
|
||||||
|
"""Test that invalid color format is rejected."""
|
||||||
|
unique_slug = f"invalid-color-{uuid.uuid4().hex[:8]}"
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/agent-types",
|
||||||
|
json={
|
||||||
|
"name": "Invalid Color Agent",
|
||||||
|
"slug": unique_slug,
|
||||||
|
"expertise": ["python"],
|
||||||
|
"personality_prompt": "You are a helpful assistant.",
|
||||||
|
"primary_model": "claude-opus-4-5-20251101",
|
||||||
|
"color": "not-a-hex-color",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
|
||||||
|
async def test_create_agent_type_invalid_category(self, client, superuser_token):
|
||||||
|
"""Test that invalid category value is rejected."""
|
||||||
|
unique_slug = f"invalid-category-{uuid.uuid4().hex[:8]}"
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/agent-types",
|
||||||
|
json={
|
||||||
|
"name": "Invalid Category Agent",
|
||||||
|
"slug": unique_slug,
|
||||||
|
"expertise": ["python"],
|
||||||
|
"personality_prompt": "You are a helpful assistant.",
|
||||||
|
"primary_model": "claude-opus-4-5-20251101",
|
||||||
|
"category": "not_a_valid_category",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
|
||||||
|
async def test_update_agent_type_category_fields(
|
||||||
|
self, client, superuser_token, test_agent_type
|
||||||
|
):
|
||||||
|
"""Test updating category and display fields."""
|
||||||
|
agent_type_id = test_agent_type["id"]
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/v1/agent-types/{agent_type_id}",
|
||||||
|
json={
|
||||||
|
"category": "ai_ml",
|
||||||
|
"icon": "brain",
|
||||||
|
"color": "#8B5CF6",
|
||||||
|
"sort_order": 50,
|
||||||
|
"typical_tasks": ["Train models", "Analyze data"],
|
||||||
|
"collaboration_hints": ["data-scientist"],
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["category"] == "ai_ml"
|
||||||
|
assert data["icon"] == "brain"
|
||||||
|
assert data["color"] == "#8B5CF6"
|
||||||
|
assert data["sort_order"] == 50
|
||||||
|
assert data["typical_tasks"] == ["Train models", "Analyze data"]
|
||||||
|
assert data["collaboration_hints"] == ["data-scientist"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestAgentTypeCategoryFilter:
|
||||||
|
"""Tests for agent type category filtering."""
|
||||||
|
|
||||||
|
async def test_list_agent_types_filter_by_category(
|
||||||
|
self, client, superuser_token, user_token
|
||||||
|
):
|
||||||
|
"""Test filtering agent types by category."""
|
||||||
|
# Create agent types in different categories
|
||||||
|
for cat in ["development", "design"]:
|
||||||
|
unique_slug = f"filter-test-{cat}-{uuid.uuid4().hex[:8]}"
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/agent-types",
|
||||||
|
json={
|
||||||
|
"name": f"Filter Test {cat.capitalize()}",
|
||||||
|
"slug": unique_slug,
|
||||||
|
"expertise": ["python"],
|
||||||
|
"personality_prompt": "Test prompt",
|
||||||
|
"primary_model": "claude-opus-4-5-20251101",
|
||||||
|
"category": cat,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by development category
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/agent-types",
|
||||||
|
params={"category": "development"},
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# All returned types should have development category
|
||||||
|
for agent_type in data["data"]:
|
||||||
|
assert agent_type["category"] == "development"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestAgentTypeGroupedEndpoint:
|
||||||
|
"""Tests for the grouped by category endpoint."""
|
||||||
|
|
||||||
|
async def test_list_agent_types_grouped(self, client, superuser_token, user_token):
|
||||||
|
"""Test getting agent types grouped by category."""
|
||||||
|
# Create agent types in different categories
|
||||||
|
categories = ["development", "design", "quality"]
|
||||||
|
for cat in categories:
|
||||||
|
unique_slug = f"grouped-test-{cat}-{uuid.uuid4().hex[:8]}"
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/agent-types",
|
||||||
|
json={
|
||||||
|
"name": f"Grouped Test {cat.capitalize()}",
|
||||||
|
"slug": unique_slug,
|
||||||
|
"expertise": ["python"],
|
||||||
|
"personality_prompt": "Test prompt",
|
||||||
|
"primary_model": "claude-opus-4-5-20251101",
|
||||||
|
"category": cat,
|
||||||
|
"sort_order": 10,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get grouped agent types
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/agent-types/grouped",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Should be a dict with category keys
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
# Check that at least one of our created categories exists
|
||||||
|
assert any(cat in data for cat in categories)
|
||||||
|
|
||||||
|
async def test_list_agent_types_grouped_filter_inactive(
|
||||||
|
self, client, superuser_token, user_token
|
||||||
|
):
|
||||||
|
"""Test grouped endpoint with is_active filter."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/agent-types/grouped",
|
||||||
|
params={"is_active": False},
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
async def test_list_agent_types_grouped_unauthenticated(self, client):
|
||||||
|
"""Test that unauthenticated users cannot access grouped endpoint."""
|
||||||
|
response = await client.get("/api/v1/agent-types/grouped")
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
|||||||
@@ -368,3 +368,9 @@ async def e2e_org_with_members(e2e_client, e2e_superuser):
|
|||||||
"user_id": member_id,
|
"user_id": member_id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: Class-scoped fixtures for E2E tests were attempted but have fundamental
|
||||||
|
# issues with pytest-asyncio + SQLAlchemy/asyncpg event loop management.
|
||||||
|
# The function-scoped fixtures above provide proper test isolation.
|
||||||
|
# Performance optimization would require significant infrastructure changes.
|
||||||
|
|||||||
@@ -316,3 +316,325 @@ class TestAgentTypeJsonFields:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert agent_type.fallback_models == models
|
assert agent_type.fallback_models == models
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentTypeCategoryFieldsValidation:
|
||||||
|
"""Tests for AgentType category and display field validation."""
|
||||||
|
|
||||||
|
def test_valid_category_values(self):
|
||||||
|
"""Test that all valid category values are accepted."""
|
||||||
|
valid_categories = [
|
||||||
|
"development",
|
||||||
|
"design",
|
||||||
|
"quality",
|
||||||
|
"operations",
|
||||||
|
"ai_ml",
|
||||||
|
"data",
|
||||||
|
"leadership",
|
||||||
|
"domain_expert",
|
||||||
|
]
|
||||||
|
|
||||||
|
for category in valid_categories:
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
assert agent_type.category.value == category
|
||||||
|
|
||||||
|
def test_category_null_allowed(self):
|
||||||
|
"""Test that null category is allowed."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
category=None,
|
||||||
|
)
|
||||||
|
assert agent_type.category is None
|
||||||
|
|
||||||
|
def test_invalid_category_rejected(self):
|
||||||
|
"""Test that invalid category values are rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
category="invalid_category",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_valid_hex_color(self):
|
||||||
|
"""Test that valid hex colors are accepted."""
|
||||||
|
valid_colors = ["#3B82F6", "#EC4899", "#10B981", "#ffffff", "#000000"]
|
||||||
|
|
||||||
|
for color in valid_colors:
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
assert agent_type.color == color
|
||||||
|
|
||||||
|
def test_invalid_hex_color_rejected(self):
|
||||||
|
"""Test that invalid hex colors are rejected."""
|
||||||
|
invalid_colors = [
|
||||||
|
"not-a-color",
|
||||||
|
"3B82F6", # Missing #
|
||||||
|
"#3B82F", # Too short
|
||||||
|
"#3B82F6A", # Too long
|
||||||
|
"#GGGGGG", # Invalid hex chars
|
||||||
|
"rgb(59, 130, 246)", # RGB format not supported
|
||||||
|
]
|
||||||
|
|
||||||
|
for color in invalid_colors:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_color_null_allowed(self):
|
||||||
|
"""Test that null color is allowed."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
color=None,
|
||||||
|
)
|
||||||
|
assert agent_type.color is None
|
||||||
|
|
||||||
|
def test_sort_order_valid_range(self):
|
||||||
|
"""Test that valid sort_order values are accepted."""
|
||||||
|
for sort_order in [0, 1, 500, 1000]:
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
assert agent_type.sort_order == sort_order
|
||||||
|
|
||||||
|
def test_sort_order_default_zero(self):
|
||||||
|
"""Test that sort_order defaults to 0."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
)
|
||||||
|
assert agent_type.sort_order == 0
|
||||||
|
|
||||||
|
def test_sort_order_negative_rejected(self):
|
||||||
|
"""Test that negative sort_order is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
sort_order=-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sort_order_exceeds_max_rejected(self):
|
||||||
|
"""Test that sort_order > 1000 is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
sort_order=1001,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_icon_max_length(self):
|
||||||
|
"""Test that icon field respects max length."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
icon="x" * 50,
|
||||||
|
)
|
||||||
|
assert len(agent_type.icon) == 50
|
||||||
|
|
||||||
|
def test_icon_exceeds_max_length_rejected(self):
|
||||||
|
"""Test that icon exceeding max length is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
icon="x" * 51,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentTypeTypicalTasksValidation:
|
||||||
|
"""Tests for typical_tasks field validation."""
|
||||||
|
|
||||||
|
def test_typical_tasks_list(self):
|
||||||
|
"""Test typical_tasks as a list."""
|
||||||
|
tasks = ["Write code", "Review PRs", "Debug issues"]
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
typical_tasks=tasks,
|
||||||
|
)
|
||||||
|
assert agent_type.typical_tasks == tasks
|
||||||
|
|
||||||
|
def test_typical_tasks_default_empty(self):
|
||||||
|
"""Test typical_tasks defaults to empty list."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
)
|
||||||
|
assert agent_type.typical_tasks == []
|
||||||
|
|
||||||
|
def test_typical_tasks_strips_whitespace(self):
|
||||||
|
"""Test that typical_tasks items are stripped."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
typical_tasks=[" Write code ", " Debug "],
|
||||||
|
)
|
||||||
|
assert agent_type.typical_tasks == ["Write code", "Debug"]
|
||||||
|
|
||||||
|
def test_typical_tasks_removes_empty_strings(self):
|
||||||
|
"""Test that empty strings are removed from typical_tasks."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
typical_tasks=["Write code", "", " ", "Debug"],
|
||||||
|
)
|
||||||
|
assert agent_type.typical_tasks == ["Write code", "Debug"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentTypeCollaborationHintsValidation:
|
||||||
|
"""Tests for collaboration_hints field validation."""
|
||||||
|
|
||||||
|
def test_collaboration_hints_list(self):
|
||||||
|
"""Test collaboration_hints as a list."""
|
||||||
|
hints = ["backend-engineer", "qa-engineer"]
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
collaboration_hints=hints,
|
||||||
|
)
|
||||||
|
assert agent_type.collaboration_hints == hints
|
||||||
|
|
||||||
|
def test_collaboration_hints_default_empty(self):
|
||||||
|
"""Test collaboration_hints defaults to empty list."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
)
|
||||||
|
assert agent_type.collaboration_hints == []
|
||||||
|
|
||||||
|
def test_collaboration_hints_normalized_lowercase(self):
|
||||||
|
"""Test that collaboration_hints are normalized to lowercase."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
collaboration_hints=["Backend-Engineer", "QA-ENGINEER"],
|
||||||
|
)
|
||||||
|
assert agent_type.collaboration_hints == ["backend-engineer", "qa-engineer"]
|
||||||
|
|
||||||
|
def test_collaboration_hints_strips_whitespace(self):
|
||||||
|
"""Test that collaboration_hints are stripped."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
collaboration_hints=[" backend-engineer ", " qa-engineer "],
|
||||||
|
)
|
||||||
|
assert agent_type.collaboration_hints == ["backend-engineer", "qa-engineer"]
|
||||||
|
|
||||||
|
def test_collaboration_hints_removes_empty_strings(self):
|
||||||
|
"""Test that empty strings are removed from collaboration_hints."""
|
||||||
|
agent_type = AgentTypeCreate(
|
||||||
|
name="Test Agent",
|
||||||
|
slug="test-agent",
|
||||||
|
personality_prompt="Test",
|
||||||
|
primary_model="claude-opus-4-5-20251101",
|
||||||
|
collaboration_hints=["backend-engineer", "", " ", "qa-engineer"],
|
||||||
|
)
|
||||||
|
assert agent_type.collaboration_hints == ["backend-engineer", "qa-engineer"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentTypeUpdateCategoryFields:
|
||||||
|
"""Tests for AgentTypeUpdate category and display fields."""
|
||||||
|
|
||||||
|
def test_update_category_field(self):
|
||||||
|
"""Test updating category field."""
|
||||||
|
update = AgentTypeUpdate(category="ai_ml")
|
||||||
|
assert update.category.value == "ai_ml"
|
||||||
|
|
||||||
|
def test_update_icon_field(self):
|
||||||
|
"""Test updating icon field."""
|
||||||
|
update = AgentTypeUpdate(icon="brain")
|
||||||
|
assert update.icon == "brain"
|
||||||
|
|
||||||
|
def test_update_color_field(self):
|
||||||
|
"""Test updating color field."""
|
||||||
|
update = AgentTypeUpdate(color="#8B5CF6")
|
||||||
|
assert update.color == "#8B5CF6"
|
||||||
|
|
||||||
|
def test_update_sort_order_field(self):
|
||||||
|
"""Test updating sort_order field."""
|
||||||
|
update = AgentTypeUpdate(sort_order=50)
|
||||||
|
assert update.sort_order == 50
|
||||||
|
|
||||||
|
def test_update_typical_tasks_field(self):
|
||||||
|
"""Test updating typical_tasks field."""
|
||||||
|
update = AgentTypeUpdate(typical_tasks=["New task"])
|
||||||
|
assert update.typical_tasks == ["New task"]
|
||||||
|
|
||||||
|
def test_update_typical_tasks_strips_whitespace(self):
|
||||||
|
"""Test that typical_tasks are stripped on update."""
|
||||||
|
update = AgentTypeUpdate(typical_tasks=[" New task "])
|
||||||
|
assert update.typical_tasks == ["New task"]
|
||||||
|
|
||||||
|
def test_update_collaboration_hints_field(self):
|
||||||
|
"""Test updating collaboration_hints field."""
|
||||||
|
update = AgentTypeUpdate(collaboration_hints=["new-collaborator"])
|
||||||
|
assert update.collaboration_hints == ["new-collaborator"]
|
||||||
|
|
||||||
|
def test_update_collaboration_hints_normalized(self):
|
||||||
|
"""Test that collaboration_hints are normalized on update."""
|
||||||
|
update = AgentTypeUpdate(collaboration_hints=[" New-Collaborator "])
|
||||||
|
assert update.collaboration_hints == ["new-collaborator"]
|
||||||
|
|
||||||
|
def test_update_invalid_color_rejected(self):
|
||||||
|
"""Test that invalid color is rejected on update."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AgentTypeUpdate(color="invalid")
|
||||||
|
|
||||||
|
def test_update_invalid_sort_order_rejected(self):
|
||||||
|
"""Test that invalid sort_order is rejected on update."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AgentTypeUpdate(sort_order=-1)
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class TestInitDb:
|
|||||||
assert user.last_name == "User"
|
assert user.last_name == "User"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.skip(
|
||||||
|
reason="SQLite doesn't support UUID type binding - requires PostgreSQL"
|
||||||
|
)
|
||||||
async def test_init_db_returns_existing_superuser(
|
async def test_init_db_returns_existing_superuser(
|
||||||
self, async_test_db, async_test_user
|
self, async_test_db, async_test_user
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
type AgentTypeCreateFormValues,
|
type AgentTypeCreateFormValues,
|
||||||
AVAILABLE_MODELS,
|
AVAILABLE_MODELS,
|
||||||
AVAILABLE_MCP_SERVERS,
|
AVAILABLE_MCP_SERVERS,
|
||||||
|
AGENT_TYPE_CATEGORIES,
|
||||||
defaultAgentTypeValues,
|
defaultAgentTypeValues,
|
||||||
generateSlug,
|
generateSlug,
|
||||||
} from '@/lib/validations/agentType';
|
} from '@/lib/validations/agentType';
|
||||||
@@ -57,6 +58,13 @@ const TAB_FIELD_MAPPING = {
|
|||||||
description: 'basic',
|
description: 'basic',
|
||||||
expertise: 'basic',
|
expertise: 'basic',
|
||||||
is_active: 'basic',
|
is_active: 'basic',
|
||||||
|
// Category and display fields
|
||||||
|
category: 'basic',
|
||||||
|
icon: 'basic',
|
||||||
|
color: 'basic',
|
||||||
|
sort_order: 'basic',
|
||||||
|
typical_tasks: 'basic',
|
||||||
|
collaboration_hints: 'basic',
|
||||||
primary_model: 'model',
|
primary_model: 'model',
|
||||||
fallback_models: 'model',
|
fallback_models: 'model',
|
||||||
model_params: 'model',
|
model_params: 'model',
|
||||||
@@ -96,6 +104,13 @@ function transformAgentTypeToFormValues(
|
|||||||
mcp_servers: agentType.mcp_servers,
|
mcp_servers: agentType.mcp_servers,
|
||||||
tool_permissions: agentType.tool_permissions,
|
tool_permissions: agentType.tool_permissions,
|
||||||
is_active: agentType.is_active,
|
is_active: agentType.is_active,
|
||||||
|
// Category and display fields
|
||||||
|
category: agentType.category,
|
||||||
|
icon: agentType.icon,
|
||||||
|
color: agentType.color,
|
||||||
|
sort_order: agentType.sort_order ?? 0,
|
||||||
|
typical_tasks: agentType.typical_tasks ?? [],
|
||||||
|
collaboration_hints: agentType.collaboration_hints ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -114,6 +129,8 @@ export function AgentTypeForm({
|
|||||||
const isEditing = !!agentType;
|
const isEditing = !!agentType;
|
||||||
const [activeTab, setActiveTab] = useState('basic');
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
const [expertiseInput, setExpertiseInput] = useState('');
|
const [expertiseInput, setExpertiseInput] = useState('');
|
||||||
|
const [typicalTaskInput, setTypicalTaskInput] = useState('');
|
||||||
|
const [collaborationHintInput, setCollaborationHintInput] = useState('');
|
||||||
|
|
||||||
// Memoize initial values transformation
|
// Memoize initial values transformation
|
||||||
const initialValues = useMemo(() => transformAgentTypeToFormValues(agentType), [agentType]);
|
const initialValues = useMemo(() => transformAgentTypeToFormValues(agentType), [agentType]);
|
||||||
@@ -144,6 +161,10 @@ export function AgentTypeForm({
|
|||||||
const watchExpertise = watch('expertise') || [];
|
const watchExpertise = watch('expertise') || [];
|
||||||
/* istanbul ignore next -- defensive fallback, mcp_servers always has default */
|
/* istanbul ignore next -- defensive fallback, mcp_servers always has default */
|
||||||
const watchMcpServers = watch('mcp_servers') || [];
|
const watchMcpServers = watch('mcp_servers') || [];
|
||||||
|
/* istanbul ignore next -- defensive fallback, typical_tasks always has default */
|
||||||
|
const watchTypicalTasks = watch('typical_tasks') || [];
|
||||||
|
/* istanbul ignore next -- defensive fallback, collaboration_hints always has default */
|
||||||
|
const watchCollaborationHints = watch('collaboration_hints') || [];
|
||||||
|
|
||||||
// Reset form when agentType changes (e.g., switching to edit mode)
|
// Reset form when agentType changes (e.g., switching to edit mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -189,6 +210,40 @@ export function AgentTypeForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddTypicalTask = () => {
|
||||||
|
if (typicalTaskInput.trim()) {
|
||||||
|
const newTask = typicalTaskInput.trim();
|
||||||
|
if (!watchTypicalTasks.includes(newTask)) {
|
||||||
|
setValue('typical_tasks', [...watchTypicalTasks, newTask]);
|
||||||
|
}
|
||||||
|
setTypicalTaskInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTypicalTask = (task: string) => {
|
||||||
|
setValue(
|
||||||
|
'typical_tasks',
|
||||||
|
watchTypicalTasks.filter((t) => t !== task)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCollaborationHint = () => {
|
||||||
|
if (collaborationHintInput.trim()) {
|
||||||
|
const newHint = collaborationHintInput.trim().toLowerCase();
|
||||||
|
if (!watchCollaborationHints.includes(newHint)) {
|
||||||
|
setValue('collaboration_hints', [...watchCollaborationHints, newHint]);
|
||||||
|
}
|
||||||
|
setCollaborationHintInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCollaborationHint = (hint: string) => {
|
||||||
|
setValue(
|
||||||
|
'collaboration_hints',
|
||||||
|
watchCollaborationHints.filter((h) => h !== hint)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle form submission with validation
|
// Handle form submission with validation
|
||||||
const onFormSubmit = useCallback(
|
const onFormSubmit = useCallback(
|
||||||
(e: React.FormEvent<HTMLFormElement>) => {
|
(e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -376,6 +431,188 @@ export function AgentTypeForm({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Category & Display Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Category & Display</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Organize and customize how this agent type appears in the UI
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">Category</Label>
|
||||||
|
<Controller
|
||||||
|
name="category"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onValueChange={(val) => field.onChange(val || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="category">
|
||||||
|
<SelectValue placeholder="Select category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AGENT_TYPE_CATEGORIES.map((cat) => (
|
||||||
|
<SelectItem key={cat.value} value={cat.value}>
|
||||||
|
{cat.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Group agents by their primary role
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sort_order">Sort Order</Label>
|
||||||
|
<Input
|
||||||
|
id="sort_order"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
{...register('sort_order', { valueAsNumber: true })}
|
||||||
|
aria-invalid={!!errors.sort_order}
|
||||||
|
/>
|
||||||
|
{errors.sort_order && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{errors.sort_order.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">Display order within category</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="icon">Icon</Label>
|
||||||
|
<Input
|
||||||
|
id="icon"
|
||||||
|
placeholder="e.g., git-branch"
|
||||||
|
{...register('icon')}
|
||||||
|
aria-invalid={!!errors.icon}
|
||||||
|
/>
|
||||||
|
{errors.icon && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{errors.icon.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">Lucide icon name for UI display</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="color">Color</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="color"
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
{...register('color')}
|
||||||
|
aria-invalid={!!errors.color}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={field.value ?? '#3B82F6'}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
className="h-9 w-9 cursor-pointer rounded border"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.color && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{errors.color.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">Hex color for visual distinction</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Typical Tasks</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">Tasks this agent type excels at</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., Design system architecture"
|
||||||
|
value={typicalTaskInput}
|
||||||
|
onChange={(e) => setTypicalTaskInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTypicalTask();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAddTypicalTask}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{watchTypicalTasks.map((task) => (
|
||||||
|
<Badge key={task} variant="secondary" className="gap-1">
|
||||||
|
{task}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-1 rounded-full hover:bg-muted"
|
||||||
|
onClick={() => handleRemoveTypicalTask(task)}
|
||||||
|
aria-label={`Remove ${task}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Collaboration Hints</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Agent slugs that work well with this type
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., backend-engineer"
|
||||||
|
value={collaborationHintInput}
|
||||||
|
onChange={(e) => setCollaborationHintInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddCollaborationHint();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAddCollaborationHint}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{watchCollaborationHints.map((hint) => (
|
||||||
|
<Badge key={hint} variant="outline" className="gap-1">
|
||||||
|
{hint}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-1 rounded-full hover:bg-muted"
|
||||||
|
onClick={() => handleRemoveCollaborationHint(hint)}
|
||||||
|
aria-label={`Remove ${hint}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Model Configuration Tab */}
|
{/* Model Configuration Tab */}
|
||||||
|
|||||||
@@ -5,6 +5,68 @@
|
|||||||
* Used for type-safe API communication with the agent-types endpoints.
|
* Used for type-safe API communication with the agent-types endpoints.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category classification for agent types
|
||||||
|
*/
|
||||||
|
export type AgentTypeCategory =
|
||||||
|
| 'development'
|
||||||
|
| 'design'
|
||||||
|
| 'quality'
|
||||||
|
| 'operations'
|
||||||
|
| 'ai_ml'
|
||||||
|
| 'data'
|
||||||
|
| 'leadership'
|
||||||
|
| 'domain_expert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for each category including display label and description
|
||||||
|
*/
|
||||||
|
export const CATEGORY_METADATA: Record<
|
||||||
|
AgentTypeCategory,
|
||||||
|
{ label: string; description: string; color: string }
|
||||||
|
> = {
|
||||||
|
development: {
|
||||||
|
label: 'Development',
|
||||||
|
description: 'Product, project, and engineering roles',
|
||||||
|
color: '#3B82F6',
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
label: 'Design',
|
||||||
|
description: 'UI/UX and design research',
|
||||||
|
color: '#EC4899',
|
||||||
|
},
|
||||||
|
quality: {
|
||||||
|
label: 'Quality',
|
||||||
|
description: 'QA and security assurance',
|
||||||
|
color: '#10B981',
|
||||||
|
},
|
||||||
|
operations: {
|
||||||
|
label: 'Operations',
|
||||||
|
description: 'DevOps and MLOps engineering',
|
||||||
|
color: '#F59E0B',
|
||||||
|
},
|
||||||
|
ai_ml: {
|
||||||
|
label: 'AI & ML',
|
||||||
|
description: 'Machine learning specialists',
|
||||||
|
color: '#8B5CF6',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
label: 'Data',
|
||||||
|
description: 'Data science and engineering',
|
||||||
|
color: '#06B6D4',
|
||||||
|
},
|
||||||
|
leadership: {
|
||||||
|
label: 'Leadership',
|
||||||
|
description: 'Technical leadership and facilitation',
|
||||||
|
color: '#F97316',
|
||||||
|
},
|
||||||
|
domain_expert: {
|
||||||
|
label: 'Domain Experts',
|
||||||
|
description: 'Industry and domain specialists',
|
||||||
|
color: '#84CC16',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base agent type fields shared across create, update, and response schemas
|
* Base agent type fields shared across create, update, and response schemas
|
||||||
*/
|
*/
|
||||||
@@ -20,6 +82,13 @@ export interface AgentTypeBase {
|
|||||||
mcp_servers: string[];
|
mcp_servers: string[];
|
||||||
tool_permissions: Record<string, unknown>;
|
tool_permissions: Record<string, unknown>;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
// Category and display fields
|
||||||
|
category?: AgentTypeCategory | null;
|
||||||
|
icon?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
typical_tasks: string[];
|
||||||
|
collaboration_hints: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +106,13 @@ export interface AgentTypeCreate {
|
|||||||
mcp_servers?: string[];
|
mcp_servers?: string[];
|
||||||
tool_permissions?: Record<string, unknown>;
|
tool_permissions?: Record<string, unknown>;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
// Category and display fields
|
||||||
|
category?: AgentTypeCategory | null;
|
||||||
|
icon?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
sort_order?: number;
|
||||||
|
typical_tasks?: string[];
|
||||||
|
collaboration_hints?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +130,13 @@ export interface AgentTypeUpdate {
|
|||||||
mcp_servers?: string[] | null;
|
mcp_servers?: string[] | null;
|
||||||
tool_permissions?: Record<string, unknown> | null;
|
tool_permissions?: Record<string, unknown> | null;
|
||||||
is_active?: boolean | null;
|
is_active?: boolean | null;
|
||||||
|
// Category and display fields
|
||||||
|
category?: AgentTypeCategory | null;
|
||||||
|
icon?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
sort_order?: number | null;
|
||||||
|
typical_tasks?: string[] | null;
|
||||||
|
collaboration_hints?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +155,13 @@ export interface AgentTypeResponse {
|
|||||||
mcp_servers: string[];
|
mcp_servers: string[];
|
||||||
tool_permissions: Record<string, unknown>;
|
tool_permissions: Record<string, unknown>;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
// Category and display fields
|
||||||
|
category: AgentTypeCategory | null;
|
||||||
|
icon: string | null;
|
||||||
|
color: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
typical_tasks: string[];
|
||||||
|
collaboration_hints: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
instance_count: number;
|
instance_count: number;
|
||||||
@@ -104,9 +194,15 @@ export interface AgentTypeListParams {
|
|||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
category?: AgentTypeCategory;
|
||||||
search?: string;
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response type for grouped agent types by category
|
||||||
|
*/
|
||||||
|
export type AgentTypeGroupedResponse = Record<string, AgentTypeResponse[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model parameter configuration with typed fields
|
* Model parameter configuration with typed fields
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,12 +6,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import type { AgentTypeCategory } from '@/lib/api/types/agentTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slug validation regex: lowercase letters, numbers, and hyphens only
|
* Slug validation regex: lowercase letters, numbers, and hyphens only
|
||||||
*/
|
*/
|
||||||
const slugRegex = /^[a-z0-9-]+$/;
|
const slugRegex = /^[a-z0-9-]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hex color validation regex
|
||||||
|
*/
|
||||||
|
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Available AI models for agent types
|
* Available AI models for agent types
|
||||||
*/
|
*/
|
||||||
@@ -43,6 +49,84 @@ export const AGENT_TYPE_STATUS = [
|
|||||||
{ value: false, label: 'Inactive' },
|
{ value: false, label: 'Inactive' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent type categories for organizing agents
|
||||||
|
*/
|
||||||
|
/* istanbul ignore next -- constant declaration */
|
||||||
|
export const AGENT_TYPE_CATEGORIES: {
|
||||||
|
value: AgentTypeCategory;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: 'development', label: 'Development', description: 'Product, project, and engineering' },
|
||||||
|
{ value: 'design', label: 'Design', description: 'UI/UX and design research' },
|
||||||
|
{ value: 'quality', label: 'Quality', description: 'QA and security assurance' },
|
||||||
|
{ value: 'operations', label: 'Operations', description: 'DevOps and MLOps engineering' },
|
||||||
|
{ value: 'ai_ml', label: 'AI & ML', description: 'Machine learning specialists' },
|
||||||
|
{ value: 'data', label: 'Data', description: 'Data science and engineering' },
|
||||||
|
{ value: 'leadership', label: 'Leadership', description: 'Technical leadership' },
|
||||||
|
{ value: 'domain_expert', label: 'Domain Experts', description: 'Industry specialists' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available Lucide icons for agent types
|
||||||
|
*/
|
||||||
|
/* istanbul ignore next -- constant declaration */
|
||||||
|
export const AVAILABLE_ICONS = [
|
||||||
|
// Development
|
||||||
|
{ value: 'clipboard-check', label: 'Clipboard Check', category: 'development' },
|
||||||
|
{ value: 'briefcase', label: 'Briefcase', category: 'development' },
|
||||||
|
{ value: 'file-text', label: 'File Text', category: 'development' },
|
||||||
|
{ value: 'git-branch', label: 'Git Branch', category: 'development' },
|
||||||
|
{ value: 'code', label: 'Code', category: 'development' },
|
||||||
|
{ value: 'server', label: 'Server', category: 'development' },
|
||||||
|
{ value: 'layout', label: 'Layout', category: 'development' },
|
||||||
|
{ value: 'smartphone', label: 'Smartphone', category: 'development' },
|
||||||
|
// Design
|
||||||
|
{ value: 'palette', label: 'Palette', category: 'design' },
|
||||||
|
{ value: 'search', label: 'Search', category: 'design' },
|
||||||
|
// Quality
|
||||||
|
{ value: 'shield', label: 'Shield', category: 'quality' },
|
||||||
|
{ value: 'shield-check', label: 'Shield Check', category: 'quality' },
|
||||||
|
// Operations
|
||||||
|
{ value: 'settings', label: 'Settings', category: 'operations' },
|
||||||
|
{ value: 'settings-2', label: 'Settings 2', category: 'operations' },
|
||||||
|
// AI/ML
|
||||||
|
{ value: 'brain', label: 'Brain', category: 'ai_ml' },
|
||||||
|
{ value: 'microscope', label: 'Microscope', category: 'ai_ml' },
|
||||||
|
{ value: 'eye', label: 'Eye', category: 'ai_ml' },
|
||||||
|
{ value: 'message-square', label: 'Message Square', category: 'ai_ml' },
|
||||||
|
// Data
|
||||||
|
{ value: 'bar-chart', label: 'Bar Chart', category: 'data' },
|
||||||
|
{ value: 'database', label: 'Database', category: 'data' },
|
||||||
|
// Leadership
|
||||||
|
{ value: 'users', label: 'Users', category: 'leadership' },
|
||||||
|
{ value: 'target', label: 'Target', category: 'leadership' },
|
||||||
|
// Domain Expert
|
||||||
|
{ value: 'calculator', label: 'Calculator', category: 'domain_expert' },
|
||||||
|
{ value: 'heart-pulse', label: 'Heart Pulse', category: 'domain_expert' },
|
||||||
|
{ value: 'flask-conical', label: 'Flask', category: 'domain_expert' },
|
||||||
|
{ value: 'lightbulb', label: 'Lightbulb', category: 'domain_expert' },
|
||||||
|
{ value: 'book-open', label: 'Book Open', category: 'domain_expert' },
|
||||||
|
// Generic
|
||||||
|
{ value: 'bot', label: 'Bot', category: 'generic' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color palette for agent type visual distinction
|
||||||
|
*/
|
||||||
|
/* istanbul ignore next -- constant declaration */
|
||||||
|
export const COLOR_PALETTE = [
|
||||||
|
{ value: '#3B82F6', label: 'Blue', category: 'development' },
|
||||||
|
{ value: '#EC4899', label: 'Pink', category: 'design' },
|
||||||
|
{ value: '#10B981', label: 'Green', category: 'quality' },
|
||||||
|
{ value: '#F59E0B', label: 'Amber', category: 'operations' },
|
||||||
|
{ value: '#8B5CF6', label: 'Purple', category: 'ai_ml' },
|
||||||
|
{ value: '#06B6D4', label: 'Cyan', category: 'data' },
|
||||||
|
{ value: '#F97316', label: 'Orange', category: 'leadership' },
|
||||||
|
{ value: '#84CC16', label: 'Lime', category: 'domain_expert' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model params schema
|
* Model params schema
|
||||||
*/
|
*/
|
||||||
@@ -52,6 +136,20 @@ const modelParamsSchema = z.object({
|
|||||||
top_p: z.number().min(0).max(1),
|
top_p: z.number().min(0).max(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent type category enum values
|
||||||
|
*/
|
||||||
|
const agentTypeCategoryValues = [
|
||||||
|
'development',
|
||||||
|
'design',
|
||||||
|
'quality',
|
||||||
|
'operations',
|
||||||
|
'ai_ml',
|
||||||
|
'data',
|
||||||
|
'leadership',
|
||||||
|
'domain_expert',
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for agent type form fields
|
* Schema for agent type form fields
|
||||||
*/
|
*/
|
||||||
@@ -96,6 +194,23 @@ export const agentTypeFormSchema = z.object({
|
|||||||
tool_permissions: z.record(z.string(), z.unknown()),
|
tool_permissions: z.record(z.string(), z.unknown()),
|
||||||
|
|
||||||
is_active: z.boolean(),
|
is_active: z.boolean(),
|
||||||
|
|
||||||
|
// Category and display fields
|
||||||
|
category: z.enum(agentTypeCategoryValues).nullable().optional(),
|
||||||
|
|
||||||
|
icon: z.string().max(50, 'Icon must be less than 50 characters').nullable().optional(),
|
||||||
|
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(hexColorRegex, 'Color must be a valid hex code (e.g., #3B82F6)')
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
sort_order: z.number().int().min(0).max(1000),
|
||||||
|
|
||||||
|
typical_tasks: z.array(z.string()),
|
||||||
|
|
||||||
|
collaboration_hints: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,6 +253,13 @@ export const defaultAgentTypeValues: AgentTypeCreateFormValues = {
|
|||||||
mcp_servers: [],
|
mcp_servers: [],
|
||||||
tool_permissions: {},
|
tool_permissions: {},
|
||||||
is_active: false, // Start as draft
|
is_active: false, // Start as draft
|
||||||
|
// Category and display fields
|
||||||
|
category: null,
|
||||||
|
icon: 'bot',
|
||||||
|
color: '#3B82F6',
|
||||||
|
sort_order: 0,
|
||||||
|
typical_tasks: [],
|
||||||
|
collaboration_hints: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ Your approach is:
|
|||||||
mcp_servers: ['gitea', 'knowledge', 'filesystem'],
|
mcp_servers: ['gitea', 'knowledge', 'filesystem'],
|
||||||
tool_permissions: {},
|
tool_permissions: {},
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
// Category and display fields
|
||||||
|
category: 'development',
|
||||||
|
icon: 'git-branch',
|
||||||
|
color: '#3B82F6',
|
||||||
|
sort_order: 40,
|
||||||
|
typical_tasks: ['Design system architecture', 'Create ADRs'],
|
||||||
|
collaboration_hints: ['backend-engineer', 'frontend-engineer'],
|
||||||
created_at: '2025-01-10T00:00:00Z',
|
created_at: '2025-01-10T00:00:00Z',
|
||||||
updated_at: '2025-01-18T00:00:00Z',
|
updated_at: '2025-01-18T00:00:00Z',
|
||||||
instance_count: 2,
|
instance_count: 2,
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const mockAgentType: AgentTypeResponse = {
|
|||||||
mcp_servers: ['gitea'],
|
mcp_servers: ['gitea'],
|
||||||
tool_permissions: {},
|
tool_permissions: {},
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
// Category and display fields
|
||||||
|
category: 'development',
|
||||||
|
icon: 'git-branch',
|
||||||
|
color: '#3B82F6',
|
||||||
|
sort_order: 40,
|
||||||
|
typical_tasks: ['Design system architecture'],
|
||||||
|
collaboration_hints: ['backend-engineer'],
|
||||||
created_at: '2025-01-10T00:00:00Z',
|
created_at: '2025-01-10T00:00:00Z',
|
||||||
updated_at: '2025-01-18T00:00:00Z',
|
updated_at: '2025-01-18T00:00:00Z',
|
||||||
instance_count: 2,
|
instance_count: 2,
|
||||||
@@ -192,7 +199,8 @@ describe('AgentTypeForm', () => {
|
|||||||
|
|
||||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||||
await user.type(expertiseInput, 'new skill');
|
await user.type(expertiseInput, 'new skill');
|
||||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
// Click the first "Add" button (for expertise)
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||||
|
|
||||||
expect(screen.getByText('new skill')).toBeInTheDocument();
|
expect(screen.getByText('new skill')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -454,7 +462,8 @@ describe('AgentTypeForm', () => {
|
|||||||
// Agent type already has 'system design'
|
// Agent type already has 'system design'
|
||||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||||
await user.type(expertiseInput, 'system design');
|
await user.type(expertiseInput, 'system design');
|
||||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
// Click the first "Add" button (for expertise)
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||||
|
|
||||||
// Should still only have one 'system design' badge
|
// Should still only have one 'system design' badge
|
||||||
const badges = screen.getAllByText('system design');
|
const badges = screen.getAllByText('system design');
|
||||||
@@ -465,7 +474,8 @@ describe('AgentTypeForm', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AgentTypeForm {...defaultProps} />);
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
const addButton = screen.getByRole('button', { name: /^add$/i });
|
// Click the first "Add" button (for expertise)
|
||||||
|
const addButton = screen.getAllByRole('button', { name: /^add$/i })[0];
|
||||||
await user.click(addButton);
|
await user.click(addButton);
|
||||||
|
|
||||||
// No badges should be added
|
// No badges should be added
|
||||||
@@ -478,7 +488,8 @@ describe('AgentTypeForm', () => {
|
|||||||
|
|
||||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||||
await user.type(expertiseInput, 'API Design');
|
await user.type(expertiseInput, 'API Design');
|
||||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
// Click the first "Add" button (for expertise)
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||||
|
|
||||||
expect(screen.getByText('api design')).toBeInTheDocument();
|
expect(screen.getByText('api design')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -489,7 +500,8 @@ describe('AgentTypeForm', () => {
|
|||||||
|
|
||||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||||
await user.type(expertiseInput, ' testing ');
|
await user.type(expertiseInput, ' testing ');
|
||||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
// Click the first "Add" button (for expertise)
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||||
|
|
||||||
expect(screen.getByText('testing')).toBeInTheDocument();
|
expect(screen.getByText('testing')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -502,7 +514,8 @@ describe('AgentTypeForm', () => {
|
|||||||
/e.g., system design/i
|
/e.g., system design/i
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
await user.type(expertiseInput, 'new skill');
|
await user.type(expertiseInput, 'new skill');
|
||||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
// Click the first "Add" button (for expertise)
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||||
|
|
||||||
expect(expertiseInput.value).toBe('');
|
expect(expertiseInput.value).toBe('');
|
||||||
});
|
});
|
||||||
@@ -562,4 +575,213 @@ describe('AgentTypeForm', () => {
|
|||||||
expect(screen.getByText('Edit Agent Type')).toBeInTheDocument();
|
expect(screen.getByText('Edit Agent Type')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Category & Display Fields', () => {
|
||||||
|
it('renders category and display section', () => {
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Category & Display')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows category select', () => {
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
expect(screen.getByLabelText(/category/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sort order input', () => {
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
expect(screen.getByLabelText(/sort order/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows icon input', () => {
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
expect(screen.getByLabelText(/icon/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows color input', () => {
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
expect(screen.getByLabelText(/color/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills category fields in edit mode', () => {
|
||||||
|
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||||
|
|
||||||
|
const iconInput = screen.getByLabelText(/icon/i) as HTMLInputElement;
|
||||||
|
expect(iconInput.value).toBe('git-branch');
|
||||||
|
|
||||||
|
const sortOrderInput = screen.getByLabelText(/sort order/i) as HTMLInputElement;
|
||||||
|
expect(sortOrderInput.value).toBe('40');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Typical Tasks Management', () => {
|
||||||
|
it('shows typical tasks section', () => {
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Typical Tasks')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds typical task when add button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
|
const taskInput = screen.getByPlaceholderText(/e.g., design system architecture/i);
|
||||||
|
await user.type(taskInput, 'Write documentation');
|
||||||
|
// Click the second "Add" button (for typical tasks)
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||||
|
await user.click(addButtons[1]);
|
||||||
|
|
||||||
|
expect(screen.getByText('Write documentation')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds typical task on enter key', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
|
const taskInput = screen.getByPlaceholderText(/e.g., design system architecture/i);
|
||||||
|
await user.type(taskInput, 'Write documentation{Enter}');
|
||||||
|
|
||||||
|
expect(screen.getByText('Write documentation')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes typical task when X button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||||
|
|
||||||
|
// Should have existing typical task
|
||||||
|
expect(screen.getByText('Design system architecture')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click remove button
|
||||||
|
const removeButton = screen.getByRole('button', {
|
||||||
|
name: /remove design system architecture/i,
|
||||||
|
});
|
||||||
|
await user.click(removeButton);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Design system architecture')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add duplicate typical tasks', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||||
|
|
||||||
|
// Agent type already has 'Design system architecture'
|
||||||
|
const taskInput = screen.getByPlaceholderText(/e.g., design system architecture/i);
|
||||||
|
await user.type(taskInput, 'Design system architecture');
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||||
|
await user.click(addButtons[1]);
|
||||||
|
|
||||||
|
// Should still only have one badge
|
||||||
|
const badges = screen.getAllByText('Design system architecture');
|
||||||
|
expect(badges).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add empty typical task', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
|
// Click the second "Add" button (for typical tasks) without typing
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||||
|
await user.click(addButtons[1]);
|
||||||
|
|
||||||
|
// No badges should be added (check that there's no remove button for typical tasks)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /remove write documentation/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Collaboration Hints Management', () => {
|
||||||
|
it('shows collaboration hints section', () => {
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Collaboration Hints')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds collaboration hint when add button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
|
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i);
|
||||||
|
await user.type(hintInput, 'devops-engineer');
|
||||||
|
// Click the third "Add" button (for collaboration hints)
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||||
|
await user.click(addButtons[2]);
|
||||||
|
|
||||||
|
expect(screen.getByText('devops-engineer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds collaboration hint on enter key', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
|
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i);
|
||||||
|
await user.type(hintInput, 'devops-engineer{Enter}');
|
||||||
|
|
||||||
|
expect(screen.getByText('devops-engineer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes collaboration hint when X button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||||
|
|
||||||
|
// Should have existing collaboration hint
|
||||||
|
expect(screen.getByText('backend-engineer')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click remove button
|
||||||
|
const removeButton = screen.getByRole('button', { name: /remove backend-engineer/i });
|
||||||
|
await user.click(removeButton);
|
||||||
|
|
||||||
|
expect(screen.queryByText('backend-engineer')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts collaboration hints to lowercase', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
|
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i);
|
||||||
|
await user.type(hintInput, 'DevOps-Engineer');
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||||
|
await user.click(addButtons[2]);
|
||||||
|
|
||||||
|
expect(screen.getByText('devops-engineer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add duplicate collaboration hints', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||||
|
|
||||||
|
// Agent type already has 'backend-engineer'
|
||||||
|
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i);
|
||||||
|
await user.type(hintInput, 'backend-engineer');
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||||
|
await user.click(addButtons[2]);
|
||||||
|
|
||||||
|
// Should still only have one badge
|
||||||
|
const badges = screen.getAllByText('backend-engineer');
|
||||||
|
expect(badges).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add empty collaboration hint', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
|
// Click the third "Add" button (for collaboration hints) without typing
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||||
|
await user.click(addButtons[2]);
|
||||||
|
|
||||||
|
// No badges should be added
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /remove devops-engineer/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears input after adding collaboration hint', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentTypeForm {...defaultProps} />);
|
||||||
|
|
||||||
|
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i) as HTMLInputElement;
|
||||||
|
await user.type(hintInput, 'devops-engineer');
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||||
|
await user.click(addButtons[2]);
|
||||||
|
|
||||||
|
expect(hintInput.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ const mockAgentTypes: AgentTypeResponse[] = [
|
|||||||
mcp_servers: ['gitea', 'knowledge'],
|
mcp_servers: ['gitea', 'knowledge'],
|
||||||
tool_permissions: {},
|
tool_permissions: {},
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
// Category and display fields
|
||||||
|
category: 'development',
|
||||||
|
icon: 'clipboard-check',
|
||||||
|
color: '#3B82F6',
|
||||||
|
sort_order: 10,
|
||||||
|
typical_tasks: ['Manage backlog', 'Write user stories'],
|
||||||
|
collaboration_hints: ['business-analyst', 'scrum-master'],
|
||||||
created_at: '2025-01-15T00:00:00Z',
|
created_at: '2025-01-15T00:00:00Z',
|
||||||
updated_at: '2025-01-20T00:00:00Z',
|
updated_at: '2025-01-20T00:00:00Z',
|
||||||
instance_count: 3,
|
instance_count: 3,
|
||||||
@@ -34,6 +41,13 @@ const mockAgentTypes: AgentTypeResponse[] = [
|
|||||||
mcp_servers: ['gitea'],
|
mcp_servers: ['gitea'],
|
||||||
tool_permissions: {},
|
tool_permissions: {},
|
||||||
is_active: false,
|
is_active: false,
|
||||||
|
// Category and display fields
|
||||||
|
category: 'development',
|
||||||
|
icon: 'git-branch',
|
||||||
|
color: '#3B82F6',
|
||||||
|
sort_order: 40,
|
||||||
|
typical_tasks: ['Design architecture', 'Create ADRs'],
|
||||||
|
collaboration_hints: ['backend-engineer', 'devops-engineer'],
|
||||||
created_at: '2025-01-10T00:00:00Z',
|
created_at: '2025-01-10T00:00:00Z',
|
||||||
updated_at: '2025-01-18T00:00:00Z',
|
updated_at: '2025-01-18T00:00:00Z',
|
||||||
instance_count: 0,
|
instance_count: 0,
|
||||||
|
|||||||
448
frontend/tests/components/forms/FormSelect.test.tsx
Normal file
448
frontend/tests/components/forms/FormSelect.test.tsx
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/**
|
||||||
|
* Tests for FormSelect Component
|
||||||
|
* Verifies select field rendering, accessibility, and error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { useForm, FormProvider } from 'react-hook-form';
|
||||||
|
import { FormSelect, type SelectOption } from '@/components/forms/FormSelect';
|
||||||
|
|
||||||
|
// Polyfill for Radix UI Select - jsdom doesn't support these browser APIs
|
||||||
|
beforeAll(() => {
|
||||||
|
Element.prototype.hasPointerCapture = jest.fn(() => false);
|
||||||
|
Element.prototype.setPointerCapture = jest.fn();
|
||||||
|
Element.prototype.releasePointerCapture = jest.fn();
|
||||||
|
Element.prototype.scrollIntoView = jest.fn();
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper wrapper component to provide form context
|
||||||
|
interface TestFormValues {
|
||||||
|
model: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestWrapper({
|
||||||
|
children,
|
||||||
|
defaultValues = { model: '', category: '' },
|
||||||
|
}: {
|
||||||
|
children: (props: {
|
||||||
|
control: ReturnType<typeof useForm<TestFormValues>>['control'];
|
||||||
|
}) => React.ReactNode;
|
||||||
|
defaultValues?: Partial<TestFormValues>;
|
||||||
|
}) {
|
||||||
|
const form = useForm<TestFormValues>({
|
||||||
|
defaultValues: { model: '', category: '', ...defaultValues },
|
||||||
|
});
|
||||||
|
|
||||||
|
return <FormProvider {...form}>{children({ control: form.control })}</FormProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockOptions: SelectOption[] = [
|
||||||
|
{ value: 'claude-opus', label: 'Claude Opus' },
|
||||||
|
{ value: 'claude-sonnet', label: 'Claude Sonnet' },
|
||||||
|
{ value: 'claude-haiku', label: 'Claude Haiku' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('FormSelect', () => {
|
||||||
|
describe('Basic Rendering', () => {
|
||||||
|
it('renders with label and select trigger', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Primary Model')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with description', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
description="Main model used for this agent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Main model used for this agent')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom placeholder', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
placeholder="Choose a model"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Choose a model')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders default placeholder when none provided', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Select primary model')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Required Field', () => {
|
||||||
|
it('shows asterisk when required is true', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('*')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show asterisk when required is false', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('*')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Options Rendering', () => {
|
||||||
|
it('renders all options when opened', async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the select using fireEvent (works better with Radix UI)
|
||||||
|
fireEvent.click(screen.getByRole('combobox'));
|
||||||
|
|
||||||
|
// Check all options are rendered
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('option', { name: 'Claude Opus' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('option', { name: 'Claude Sonnet' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'Claude Haiku' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects option when clicked', async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the select and choose an option
|
||||||
|
fireEvent.click(screen.getByRole('combobox'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('option', { name: 'Claude Sonnet' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: 'Claude Sonnet' }));
|
||||||
|
|
||||||
|
// The selected value should now be displayed
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('combobox')).toHaveTextContent('Claude Sonnet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Disabled State', () => {
|
||||||
|
it('disables select when disabled prop is true', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables select when disabled prop is false', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pre-selected Value', () => {
|
||||||
|
it('displays pre-selected value', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper defaultValues={{ model: 'claude-opus' }}>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toHaveTextContent('Claude Opus');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('links label to select via htmlFor/id', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = screen.getByText('Primary Model');
|
||||||
|
const select = screen.getByRole('combobox');
|
||||||
|
|
||||||
|
expect(label).toHaveAttribute('for', 'model');
|
||||||
|
expect(select).toHaveAttribute('id', 'model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-describedby with description ID when description exists', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
description="Choose the main model"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByRole('combobox');
|
||||||
|
expect(select).toHaveAttribute('aria-describedby', 'model-description');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom ClassName', () => {
|
||||||
|
it('applies custom className to wrapper', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
{({ control }) => (
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
className="custom-class"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('.custom-class')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('displays error message when field has error', () => {
|
||||||
|
function TestComponent() {
|
||||||
|
const form = useForm<TestFormValues>({
|
||||||
|
defaultValues: { model: '', category: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
form.setError('model', { type: 'required', message: 'Model is required' });
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={form.control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Model is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-invalid when error exists', () => {
|
||||||
|
function TestComponent() {
|
||||||
|
const form = useForm<TestFormValues>({
|
||||||
|
defaultValues: { model: '', category: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
form.setError('model', { type: 'required', message: 'Model is required' });
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={form.control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-describedby with error ID when error exists', () => {
|
||||||
|
function TestComponent() {
|
||||||
|
const form = useForm<TestFormValues>({
|
||||||
|
defaultValues: { model: '', category: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
form.setError('model', { type: 'required', message: 'Model is required' });
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={form.control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toHaveAttribute('aria-describedby', 'model-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines error and description IDs in aria-describedby', () => {
|
||||||
|
function TestComponent() {
|
||||||
|
const form = useForm<TestFormValues>({
|
||||||
|
defaultValues: { model: '', category: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
form.setError('model', { type: 'required', message: 'Model is required' });
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<FormSelect
|
||||||
|
name="model"
|
||||||
|
control={form.control}
|
||||||
|
label="Primary Model"
|
||||||
|
options={mockOptions}
|
||||||
|
description="Choose the main model"
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toHaveAttribute(
|
||||||
|
'aria-describedby',
|
||||||
|
'model-error model-description'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
281
frontend/tests/components/forms/FormTextarea.test.tsx
Normal file
281
frontend/tests/components/forms/FormTextarea.test.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* Tests for FormTextarea Component
|
||||||
|
* Verifies textarea field rendering, accessibility, and error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { FormTextarea } from '@/components/forms/FormTextarea';
|
||||||
|
import type { FieldError } from 'react-hook-form';
|
||||||
|
|
||||||
|
describe('FormTextarea', () => {
|
||||||
|
describe('Basic Rendering', () => {
|
||||||
|
it('renders with label and textarea', () => {
|
||||||
|
render(<FormTextarea label="Description" name="description" />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Description')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with description', () => {
|
||||||
|
render(
|
||||||
|
<FormTextarea
|
||||||
|
label="Personality Prompt"
|
||||||
|
name="personality"
|
||||||
|
description="Define the agent's personality and behavior"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Define the agent's personality and behavior")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders description before textarea', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<FormTextarea label="Description" name="description" description="Helper text" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = container.querySelector('#description-description');
|
||||||
|
const textarea = container.querySelector('textarea');
|
||||||
|
|
||||||
|
// Get positions
|
||||||
|
const descriptionRect = description?.getBoundingClientRect();
|
||||||
|
const textareaRect = textarea?.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Description should appear (both should exist)
|
||||||
|
expect(description).toBeInTheDocument();
|
||||||
|
expect(textarea).toBeInTheDocument();
|
||||||
|
|
||||||
|
// In the DOM order, description comes before textarea
|
||||||
|
expect(descriptionRect).toBeDefined();
|
||||||
|
expect(textareaRect).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Required Field', () => {
|
||||||
|
it('shows asterisk when required is true', () => {
|
||||||
|
render(<FormTextarea label="Description" name="description" required />);
|
||||||
|
|
||||||
|
expect(screen.getByText('*')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show asterisk when required is false', () => {
|
||||||
|
render(<FormTextarea label="Description" name="description" required={false} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('*')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('displays error message when error prop is provided', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Description is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Description is required')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-invalid when error exists', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Description is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-describedby with error ID when error exists', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Description is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveAttribute('aria-describedby', 'description-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error with role="alert"', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Description is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||||
|
|
||||||
|
const errorElement = screen.getByRole('alert');
|
||||||
|
expect(errorElement).toHaveTextContent('Description is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('links label to textarea via htmlFor/id', () => {
|
||||||
|
render(<FormTextarea label="Description" name="description" />);
|
||||||
|
|
||||||
|
const label = screen.getByText('Description');
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
|
||||||
|
expect(label).toHaveAttribute('for', 'description');
|
||||||
|
expect(textarea).toHaveAttribute('id', 'description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-describedby with description ID when description exists', () => {
|
||||||
|
render(
|
||||||
|
<FormTextarea
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
description="Enter a detailed description"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveAttribute('aria-describedby', 'description-description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines error and description IDs in aria-describedby', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Description is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormTextarea
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
description="Enter a detailed description"
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveAttribute(
|
||||||
|
'aria-describedby',
|
||||||
|
'description-error description-description'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Textarea Props Forwarding', () => {
|
||||||
|
it('forwards textarea props correctly', () => {
|
||||||
|
render(
|
||||||
|
<FormTextarea
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Enter description"
|
||||||
|
rows={5}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveAttribute('placeholder', 'Enter description');
|
||||||
|
expect(textarea).toHaveAttribute('rows', '5');
|
||||||
|
expect(textarea).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts register() props via registration', () => {
|
||||||
|
const registerProps = {
|
||||||
|
name: 'description',
|
||||||
|
onChange: jest.fn(),
|
||||||
|
onBlur: jest.fn(),
|
||||||
|
ref: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Description" registration={registerProps} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toBeInTheDocument();
|
||||||
|
expect(textarea).toHaveAttribute('id', 'description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts name from spread props', () => {
|
||||||
|
const spreadProps = {
|
||||||
|
name: 'content',
|
||||||
|
onChange: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Content" {...spreadProps} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveAttribute('id', 'content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Cases', () => {
|
||||||
|
it('throws error when name is not provided', () => {
|
||||||
|
// Suppress console.error for this test
|
||||||
|
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(<FormTextarea label="Description" />);
|
||||||
|
}).toThrow('FormTextarea: name must be provided either explicitly or via register()');
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Layout and Styling', () => {
|
||||||
|
it('applies correct spacing classes', () => {
|
||||||
|
const { container } = render(<FormTextarea label="Description" name="description" />);
|
||||||
|
|
||||||
|
const wrapper = container.firstChild as HTMLElement;
|
||||||
|
expect(wrapper).toHaveClass('space-y-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct error styling', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Description is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||||
|
|
||||||
|
const errorElement = screen.getByRole('alert');
|
||||||
|
expect(errorElement).toHaveClass('text-sm', 'text-destructive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct description styling', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<FormTextarea label="Description" name="description" description="Helper text" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = container.querySelector('#description-description');
|
||||||
|
expect(description).toHaveClass('text-sm', 'text-muted-foreground');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Name Priority', () => {
|
||||||
|
it('uses explicit name over registration name', () => {
|
||||||
|
const registerProps = {
|
||||||
|
name: 'fromRegister',
|
||||||
|
onChange: jest.fn(),
|
||||||
|
onBlur: jest.fn(),
|
||||||
|
ref: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Content" name="explicit" registration={registerProps} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveAttribute('id', 'explicit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses registration name when explicit name not provided', () => {
|
||||||
|
const registerProps = {
|
||||||
|
name: 'fromRegister',
|
||||||
|
onChange: jest.fn(),
|
||||||
|
onBlur: jest.fn(),
|
||||||
|
ref: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FormTextarea label="Content" registration={registerProps} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveAttribute('id', 'fromRegister');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,9 @@ jest.mock('@/config/app.config', () => ({
|
|||||||
debug: {
|
debug: {
|
||||||
api: false,
|
api: false,
|
||||||
},
|
},
|
||||||
|
demo: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -649,6 +652,9 @@ describe('useProjectEvents', () => {
|
|||||||
debug: {
|
debug: {
|
||||||
api: true,
|
api: true,
|
||||||
},
|
},
|
||||||
|
demo: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
.PHONY: help install install-dev lint lint-fix format type-check test test-cov validate clean run
|
.PHONY: help install install-dev lint lint-fix format format-check type-check test test-cov validate clean run
|
||||||
|
|
||||||
|
# Ensure commands in this project don't inherit an external Python virtualenv
|
||||||
|
# (prevents uv warnings about mismatched VIRTUAL_ENV when running from repo root)
|
||||||
|
unexport VIRTUAL_ENV
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
help:
|
help:
|
||||||
@@ -12,6 +16,7 @@ help:
|
|||||||
@echo " make lint - Run Ruff linter"
|
@echo " make lint - Run Ruff linter"
|
||||||
@echo " make lint-fix - Run Ruff linter with auto-fix"
|
@echo " make lint-fix - Run Ruff linter with auto-fix"
|
||||||
@echo " make format - Format code with Ruff"
|
@echo " make format - Format code with Ruff"
|
||||||
|
@echo " make format-check - Check if code is formatted"
|
||||||
@echo " make type-check - Run mypy type checker"
|
@echo " make type-check - Run mypy type checker"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Testing:"
|
@echo "Testing:"
|
||||||
@@ -19,7 +24,7 @@ help:
|
|||||||
@echo " make test-cov - Run pytest with coverage"
|
@echo " make test-cov - Run pytest with coverage"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "All-in-one:"
|
@echo "All-in-one:"
|
||||||
@echo " make validate - Run lint, type-check, and tests"
|
@echo " make validate - Run all checks (lint + format + types)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Running:"
|
@echo "Running:"
|
||||||
@echo " make run - Run the server locally"
|
@echo " make run - Run the server locally"
|
||||||
@@ -49,6 +54,10 @@ format:
|
|||||||
@echo "Formatting code..."
|
@echo "Formatting code..."
|
||||||
@uv run ruff format .
|
@uv run ruff format .
|
||||||
|
|
||||||
|
format-check:
|
||||||
|
@echo "Checking code formatting..."
|
||||||
|
@uv run ruff format --check .
|
||||||
|
|
||||||
type-check:
|
type-check:
|
||||||
@echo "Running mypy..."
|
@echo "Running mypy..."
|
||||||
@uv run mypy . --ignore-missing-imports
|
@uv run mypy . --ignore-missing-imports
|
||||||
@@ -62,8 +71,9 @@ test-cov:
|
|||||||
@echo "Running tests with coverage..."
|
@echo "Running tests with coverage..."
|
||||||
@uv run pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=html
|
@uv run pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=html
|
||||||
|
|
||||||
|
|
||||||
# All-in-one validation
|
# All-in-one validation
|
||||||
validate: lint type-check test
|
validate: lint format-check type-check
|
||||||
@echo "All validations passed!"
|
@echo "All validations passed!"
|
||||||
|
|
||||||
# Running
|
# Running
|
||||||
|
|||||||
@@ -184,7 +184,12 @@ class ChunkerFactory:
|
|||||||
if file_type:
|
if file_type:
|
||||||
if file_type == FileType.MARKDOWN:
|
if file_type == FileType.MARKDOWN:
|
||||||
return self._get_markdown_chunker()
|
return self._get_markdown_chunker()
|
||||||
elif file_type in (FileType.TEXT, FileType.JSON, FileType.YAML, FileType.TOML):
|
elif file_type in (
|
||||||
|
FileType.TEXT,
|
||||||
|
FileType.JSON,
|
||||||
|
FileType.YAML,
|
||||||
|
FileType.TOML,
|
||||||
|
):
|
||||||
return self._get_text_chunker()
|
return self._get_text_chunker()
|
||||||
else:
|
else:
|
||||||
# Code files
|
# Code files
|
||||||
@@ -193,7 +198,9 @@ class ChunkerFactory:
|
|||||||
# Default to text chunker
|
# Default to text chunker
|
||||||
return self._get_text_chunker()
|
return self._get_text_chunker()
|
||||||
|
|
||||||
def get_chunker_for_path(self, source_path: str) -> tuple[BaseChunker, FileType | None]:
|
def get_chunker_for_path(
|
||||||
|
self, source_path: str
|
||||||
|
) -> tuple[BaseChunker, FileType | None]:
|
||||||
"""
|
"""
|
||||||
Get chunker based on file path extension.
|
Get chunker based on file path extension.
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,7 @@ class MarkdownChunker(BaseChunker):
|
|||||||
|
|
||||||
if not sections:
|
if not sections:
|
||||||
# No headings, chunk as plain text
|
# No headings, chunk as plain text
|
||||||
return self._chunk_text_block(
|
return self._chunk_text_block(content, source_path, file_type, metadata, [])
|
||||||
content, source_path, file_type, metadata, []
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks: list[Chunk] = []
|
chunks: list[Chunk] = []
|
||||||
heading_stack: list[tuple[int, str]] = [] # (level, text)
|
heading_stack: list[tuple[int, str]] = [] # (level, text)
|
||||||
@@ -292,7 +290,10 @@ class MarkdownChunker(BaseChunker):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Overlap: include last paragraph if it fits
|
# Overlap: include last paragraph if it fits
|
||||||
if current_content and self.count_tokens(current_content[-1]) <= self.chunk_overlap:
|
if (
|
||||||
|
current_content
|
||||||
|
and self.count_tokens(current_content[-1]) <= self.chunk_overlap
|
||||||
|
):
|
||||||
current_content = [current_content[-1]]
|
current_content = [current_content[-1]]
|
||||||
current_tokens = self.count_tokens(current_content[-1])
|
current_tokens = self.count_tokens(current_content[-1])
|
||||||
else:
|
else:
|
||||||
@@ -341,12 +342,14 @@ class MarkdownChunker(BaseChunker):
|
|||||||
# Start of code block - save previous paragraph
|
# Start of code block - save previous paragraph
|
||||||
if current_para and any(p.strip() for p in current_para):
|
if current_para and any(p.strip() for p in current_para):
|
||||||
para_content = "\n".join(current_para)
|
para_content = "\n".join(current_para)
|
||||||
paragraphs.append({
|
paragraphs.append(
|
||||||
|
{
|
||||||
"content": para_content,
|
"content": para_content,
|
||||||
"tokens": self.count_tokens(para_content),
|
"tokens": self.count_tokens(para_content),
|
||||||
"start_line": para_start,
|
"start_line": para_start,
|
||||||
"end_line": i - 1,
|
"end_line": i - 1,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
current_para = [line]
|
current_para = [line]
|
||||||
para_start = i
|
para_start = i
|
||||||
in_code_block = True
|
in_code_block = True
|
||||||
@@ -360,12 +363,14 @@ class MarkdownChunker(BaseChunker):
|
|||||||
if not line.strip():
|
if not line.strip():
|
||||||
if current_para and any(p.strip() for p in current_para):
|
if current_para and any(p.strip() for p in current_para):
|
||||||
para_content = "\n".join(current_para)
|
para_content = "\n".join(current_para)
|
||||||
paragraphs.append({
|
paragraphs.append(
|
||||||
|
{
|
||||||
"content": para_content,
|
"content": para_content,
|
||||||
"tokens": self.count_tokens(para_content),
|
"tokens": self.count_tokens(para_content),
|
||||||
"start_line": para_start,
|
"start_line": para_start,
|
||||||
"end_line": i - 1,
|
"end_line": i - 1,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
current_para = []
|
current_para = []
|
||||||
para_start = i + 1
|
para_start = i + 1
|
||||||
else:
|
else:
|
||||||
@@ -376,12 +381,14 @@ class MarkdownChunker(BaseChunker):
|
|||||||
# Final paragraph
|
# Final paragraph
|
||||||
if current_para and any(p.strip() for p in current_para):
|
if current_para and any(p.strip() for p in current_para):
|
||||||
para_content = "\n".join(current_para)
|
para_content = "\n".join(current_para)
|
||||||
paragraphs.append({
|
paragraphs.append(
|
||||||
|
{
|
||||||
"content": para_content,
|
"content": para_content,
|
||||||
"tokens": self.count_tokens(para_content),
|
"tokens": self.count_tokens(para_content),
|
||||||
"start_line": para_start,
|
"start_line": para_start,
|
||||||
"end_line": len(lines) - 1,
|
"end_line": len(lines) - 1,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return paragraphs
|
return paragraphs
|
||||||
|
|
||||||
@@ -448,7 +455,10 @@ class MarkdownChunker(BaseChunker):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Overlap with last sentence
|
# Overlap with last sentence
|
||||||
if current_content and self.count_tokens(current_content[-1]) <= self.chunk_overlap:
|
if (
|
||||||
|
current_content
|
||||||
|
and self.count_tokens(current_content[-1]) <= self.chunk_overlap
|
||||||
|
):
|
||||||
current_content = [current_content[-1]]
|
current_content = [current_content[-1]]
|
||||||
current_tokens = self.count_tokens(current_content[-1])
|
current_tokens = self.count_tokens(current_content[-1])
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -79,9 +79,7 @@ class TextChunker(BaseChunker):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fall back to sentence-based chunking
|
# Fall back to sentence-based chunking
|
||||||
return self._chunk_by_sentences(
|
return self._chunk_by_sentences(content, source_path, file_type, metadata)
|
||||||
content, source_path, file_type, metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
def _split_paragraphs(self, content: str) -> list[dict[str, Any]]:
|
def _split_paragraphs(self, content: str) -> list[dict[str, Any]]:
|
||||||
"""Split content into paragraphs."""
|
"""Split content into paragraphs."""
|
||||||
@@ -97,12 +95,14 @@ class TextChunker(BaseChunker):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
para_lines = para.count("\n") + 1
|
para_lines = para.count("\n") + 1
|
||||||
paragraphs.append({
|
paragraphs.append(
|
||||||
|
{
|
||||||
"content": para,
|
"content": para,
|
||||||
"tokens": self.count_tokens(para),
|
"tokens": self.count_tokens(para),
|
||||||
"start_line": line_num,
|
"start_line": line_num,
|
||||||
"end_line": line_num + para_lines - 1,
|
"end_line": line_num + para_lines - 1,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
line_num += para_lines + 1 # +1 for blank line between paragraphs
|
line_num += para_lines + 1 # +1 for blank line between paragraphs
|
||||||
|
|
||||||
return paragraphs
|
return paragraphs
|
||||||
@@ -172,7 +172,10 @@ class TextChunker(BaseChunker):
|
|||||||
|
|
||||||
# Overlap: keep last paragraph if small enough
|
# Overlap: keep last paragraph if small enough
|
||||||
overlap_para = None
|
overlap_para = None
|
||||||
if current_paras and self.count_tokens(current_paras[-1]) <= self.chunk_overlap:
|
if (
|
||||||
|
current_paras
|
||||||
|
and self.count_tokens(current_paras[-1]) <= self.chunk_overlap
|
||||||
|
):
|
||||||
overlap_para = current_paras[-1]
|
overlap_para = current_paras[-1]
|
||||||
|
|
||||||
current_paras = [overlap_para] if overlap_para else []
|
current_paras = [overlap_para] if overlap_para else []
|
||||||
@@ -266,7 +269,10 @@ class TextChunker(BaseChunker):
|
|||||||
|
|
||||||
# Overlap: keep last sentence if small enough
|
# Overlap: keep last sentence if small enough
|
||||||
overlap = None
|
overlap = None
|
||||||
if current_sentences and self.count_tokens(current_sentences[-1]) <= self.chunk_overlap:
|
if (
|
||||||
|
current_sentences
|
||||||
|
and self.count_tokens(current_sentences[-1]) <= self.chunk_overlap
|
||||||
|
):
|
||||||
overlap = current_sentences[-1]
|
overlap = current_sentences[-1]
|
||||||
|
|
||||||
current_sentences = [overlap] if overlap else []
|
current_sentences = [overlap] if overlap else []
|
||||||
@@ -317,14 +323,10 @@ class TextChunker(BaseChunker):
|
|||||||
sentences = self._split_sentences(text)
|
sentences = self._split_sentences(text)
|
||||||
|
|
||||||
if len(sentences) > 1:
|
if len(sentences) > 1:
|
||||||
return self._chunk_by_sentences(
|
return self._chunk_by_sentences(text, source_path, file_type, metadata)
|
||||||
text, source_path, file_type, metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fall back to word-based splitting
|
# Fall back to word-based splitting
|
||||||
return self._chunk_by_words(
|
return self._chunk_by_words(text, source_path, file_type, metadata, base_line)
|
||||||
text, source_path, file_type, metadata, base_line
|
|
||||||
)
|
|
||||||
|
|
||||||
def _chunk_by_words(
|
def _chunk_by_words(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -328,14 +328,18 @@ class CollectionManager:
|
|||||||
"source_path": chunk.source_path or source_path,
|
"source_path": chunk.source_path or source_path,
|
||||||
"start_line": chunk.start_line,
|
"start_line": chunk.start_line,
|
||||||
"end_line": chunk.end_line,
|
"end_line": chunk.end_line,
|
||||||
"file_type": effective_file_type.value if (effective_file_type := chunk.file_type or file_type) else None,
|
"file_type": effective_file_type.value
|
||||||
|
if (effective_file_type := chunk.file_type or file_type)
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
embeddings_data.append((
|
embeddings_data.append(
|
||||||
|
(
|
||||||
chunk.content,
|
chunk.content,
|
||||||
embedding,
|
embedding,
|
||||||
chunk.chunk_type,
|
chunk.chunk_type,
|
||||||
chunk_metadata,
|
chunk_metadata,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Atomically replace old embeddings with new ones
|
# Atomically replace old embeddings with new ones
|
||||||
_, chunk_ids = await self.database.replace_source_embeddings(
|
_, chunk_ids = await self.database.replace_source_embeddings(
|
||||||
|
|||||||
@@ -214,9 +214,7 @@ class EmbeddingGenerator:
|
|||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Generate via LLM Gateway
|
# Generate via LLM Gateway
|
||||||
embeddings = await self._call_llm_gateway(
|
embeddings = await self._call_llm_gateway([text], project_id, agent_id)
|
||||||
[text], project_id, agent_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if not embeddings:
|
if not embeddings:
|
||||||
raise EmbeddingGenerationError(
|
raise EmbeddingGenerationError(
|
||||||
@@ -277,9 +275,7 @@ class EmbeddingGenerator:
|
|||||||
|
|
||||||
for i in range(0, len(texts_to_embed), batch_size):
|
for i in range(0, len(texts_to_embed), batch_size):
|
||||||
batch = texts_to_embed[i : i + batch_size]
|
batch = texts_to_embed[i : i + batch_size]
|
||||||
batch_embeddings = await self._call_llm_gateway(
|
batch_embeddings = await self._call_llm_gateway(batch, project_id, agent_id)
|
||||||
batch, project_id, agent_id
|
|
||||||
)
|
|
||||||
new_embeddings.extend(batch_embeddings)
|
new_embeddings.extend(batch_embeddings)
|
||||||
|
|
||||||
# Validate dimensions
|
# Validate dimensions
|
||||||
|
|||||||
@@ -149,12 +149,8 @@ class IngestRequest(BaseModel):
|
|||||||
source_path: str | None = Field(
|
source_path: str | None = Field(
|
||||||
default=None, description="Source file path for reference"
|
default=None, description="Source file path for reference"
|
||||||
)
|
)
|
||||||
collection: str = Field(
|
collection: str = Field(default="default", description="Collection to store in")
|
||||||
default="default", description="Collection to store in"
|
chunk_type: ChunkType = Field(default=ChunkType.TEXT, description="Type of content")
|
||||||
)
|
|
||||||
chunk_type: ChunkType = Field(
|
|
||||||
default=ChunkType.TEXT, description="Type of content"
|
|
||||||
)
|
|
||||||
file_type: FileType | None = Field(
|
file_type: FileType | None = Field(
|
||||||
default=None, description="File type for code chunking"
|
default=None, description="File type for code chunking"
|
||||||
)
|
)
|
||||||
@@ -255,12 +251,8 @@ class DeleteRequest(BaseModel):
|
|||||||
|
|
||||||
project_id: str = Field(..., description="Project ID for scoping")
|
project_id: str = Field(..., description="Project ID for scoping")
|
||||||
agent_id: str = Field(..., description="Agent ID making the request")
|
agent_id: str = Field(..., description="Agent ID making the request")
|
||||||
source_path: str | None = Field(
|
source_path: str | None = Field(default=None, description="Delete by source path")
|
||||||
default=None, description="Delete by source path"
|
collection: str | None = Field(default=None, description="Delete entire collection")
|
||||||
)
|
|
||||||
collection: str | None = Field(
|
|
||||||
default=None, description="Delete entire collection"
|
|
||||||
)
|
|
||||||
chunk_ids: list[str] | None = Field(
|
chunk_ids: list[str] | None = Field(
|
||||||
default=None, description="Delete specific chunks"
|
default=None, description="Delete specific chunks"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -145,8 +145,7 @@ class SearchEngine:
|
|||||||
|
|
||||||
# Filter by threshold (keyword search scores are normalized)
|
# Filter by threshold (keyword search scores are normalized)
|
||||||
filtered = [
|
filtered = [
|
||||||
(emb, score) for emb, score in results
|
(emb, score) for emb, score in results if score >= request.threshold
|
||||||
if score >= request.threshold
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -204,10 +203,9 @@ class SearchEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Filter by threshold and limit
|
# Filter by threshold and limit
|
||||||
filtered = [
|
filtered = [result for result in fused if result.score >= request.threshold][
|
||||||
result for result in fused
|
: request.limit
|
||||||
if result.score >= request.threshold
|
]
|
||||||
][:request.limit]
|
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ def _validate_source_path(value: str | None) -> str | None:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -213,7 +214,9 @@ async def health_check() -> dict[str, Any]:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
status["dependencies"]["llm_gateway"] = "connected"
|
status["dependencies"]["llm_gateway"] = "connected"
|
||||||
else:
|
else:
|
||||||
status["dependencies"]["llm_gateway"] = f"unhealthy (status {response.status_code})"
|
status["dependencies"]["llm_gateway"] = (
|
||||||
|
f"unhealthy (status {response.status_code})"
|
||||||
|
)
|
||||||
is_degraded = True
|
is_degraded = True
|
||||||
else:
|
else:
|
||||||
status["dependencies"]["llm_gateway"] = "not initialized"
|
status["dependencies"]["llm_gateway"] = "not initialized"
|
||||||
@@ -328,7 +331,9 @@ def _get_tool_schema(func: Any) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _register_tool(name: str, tool_or_func: Any, description: str | None = None) -> None:
|
def _register_tool(
|
||||||
|
name: str, tool_or_func: Any, description: str | None = None
|
||||||
|
) -> None:
|
||||||
"""Register a tool in the registry.
|
"""Register a tool in the registry.
|
||||||
|
|
||||||
Handles both raw functions and FastMCP FunctionTool objects.
|
Handles both raw functions and FastMCP FunctionTool objects.
|
||||||
@@ -337,7 +342,11 @@ def _register_tool(name: str, tool_or_func: Any, description: str | None = None)
|
|||||||
if hasattr(tool_or_func, "fn"):
|
if hasattr(tool_or_func, "fn"):
|
||||||
func = tool_or_func.fn
|
func = tool_or_func.fn
|
||||||
# Use FunctionTool's description if available
|
# Use FunctionTool's description if available
|
||||||
if not description and hasattr(tool_or_func, "description") and tool_or_func.description:
|
if (
|
||||||
|
not description
|
||||||
|
and hasattr(tool_or_func, "description")
|
||||||
|
and tool_or_func.description
|
||||||
|
):
|
||||||
description = tool_or_func.description
|
description = tool_or_func.description
|
||||||
else:
|
else:
|
||||||
func = tool_or_func
|
func = tool_or_func
|
||||||
@@ -358,11 +367,13 @@ async def list_mcp_tools() -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
tools = []
|
tools = []
|
||||||
for name, info in _tool_registry.items():
|
for name, info in _tool_registry.items():
|
||||||
tools.append({
|
tools.append(
|
||||||
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": info["description"],
|
"description": info["description"],
|
||||||
"inputSchema": info["schema"],
|
"inputSchema": info["schema"],
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"tools": tools}
|
return {"tools": tools}
|
||||||
|
|
||||||
@@ -410,7 +421,10 @@ async def mcp_rpc(request: Request) -> JSONResponse:
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
content={
|
content={
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"error": {"code": -32600, "message": "Invalid Request: jsonrpc must be '2.0'"},
|
"error": {
|
||||||
|
"code": -32600,
|
||||||
|
"message": "Invalid Request: jsonrpc must be '2.0'",
|
||||||
|
},
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -420,7 +434,10 @@ async def mcp_rpc(request: Request) -> JSONResponse:
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
content={
|
content={
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"error": {"code": -32600, "message": "Invalid Request: method is required"},
|
"error": {
|
||||||
|
"code": -32600,
|
||||||
|
"message": "Invalid Request: method is required",
|
||||||
|
},
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -528,11 +545,23 @@ async def search_knowledge(
|
|||||||
try:
|
try:
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
if error := _validate_id(project_id, "project_id"):
|
if error := _validate_id(project_id, "project_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_id(agent_id, "agent_id"):
|
if error := _validate_id(agent_id, "agent_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if collection and (error := _validate_collection(collection)):
|
if collection and (error := _validate_collection(collection)):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
|
|
||||||
# Parse search type
|
# Parse search type
|
||||||
try:
|
try:
|
||||||
@@ -644,13 +673,29 @@ async def ingest_content(
|
|||||||
try:
|
try:
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
if error := _validate_id(project_id, "project_id"):
|
if error := _validate_id(project_id, "project_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_id(agent_id, "agent_id"):
|
if error := _validate_id(agent_id, "agent_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_collection(collection):
|
if error := _validate_collection(collection):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_source_path(source_path):
|
if error := _validate_source_path(source_path):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
|
|
||||||
# Validate content size to prevent DoS
|
# Validate content size to prevent DoS
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -750,13 +795,29 @@ async def delete_content(
|
|||||||
try:
|
try:
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
if error := _validate_id(project_id, "project_id"):
|
if error := _validate_id(project_id, "project_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_id(agent_id, "agent_id"):
|
if error := _validate_id(agent_id, "agent_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if collection and (error := _validate_collection(collection)):
|
if collection and (error := _validate_collection(collection)):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_source_path(source_path):
|
if error := _validate_source_path(source_path):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
|
|
||||||
request = DeleteRequest(
|
request = DeleteRequest(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -803,9 +864,17 @@ async def list_collections(
|
|||||||
try:
|
try:
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
if error := _validate_id(project_id, "project_id"):
|
if error := _validate_id(project_id, "project_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_id(agent_id, "agent_id"):
|
if error := _validate_id(agent_id, "agent_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
|
|
||||||
result = await _collections.list_collections(project_id) # type: ignore[union-attr]
|
result = await _collections.list_collections(project_id) # type: ignore[union-attr]
|
||||||
|
|
||||||
@@ -856,11 +925,23 @@ async def get_collection_stats(
|
|||||||
try:
|
try:
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
if error := _validate_id(project_id, "project_id"):
|
if error := _validate_id(project_id, "project_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_id(agent_id, "agent_id"):
|
if error := _validate_id(agent_id, "agent_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_collection(collection):
|
if error := _validate_collection(collection):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
|
|
||||||
stats = await _collections.get_collection_stats(project_id, collection) # type: ignore[union-attr]
|
stats = await _collections.get_collection_stats(project_id, collection) # type: ignore[union-attr]
|
||||||
|
|
||||||
@@ -874,8 +955,12 @@ async def get_collection_stats(
|
|||||||
"avg_chunk_size": stats.avg_chunk_size,
|
"avg_chunk_size": stats.avg_chunk_size,
|
||||||
"chunk_types": stats.chunk_types,
|
"chunk_types": stats.chunk_types,
|
||||||
"file_types": stats.file_types,
|
"file_types": stats.file_types,
|
||||||
"oldest_chunk": stats.oldest_chunk.isoformat() if stats.oldest_chunk else None,
|
"oldest_chunk": stats.oldest_chunk.isoformat()
|
||||||
"newest_chunk": stats.newest_chunk.isoformat() if stats.newest_chunk else None,
|
if stats.oldest_chunk
|
||||||
|
else None,
|
||||||
|
"newest_chunk": stats.newest_chunk.isoformat()
|
||||||
|
if stats.newest_chunk
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
except KnowledgeBaseError as e:
|
except KnowledgeBaseError as e:
|
||||||
@@ -925,13 +1010,29 @@ async def update_document(
|
|||||||
try:
|
try:
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
if error := _validate_id(project_id, "project_id"):
|
if error := _validate_id(project_id, "project_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_id(agent_id, "agent_id"):
|
if error := _validate_id(agent_id, "agent_id"):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_collection(collection):
|
if error := _validate_collection(collection):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
if error := _validate_source_path(source_path):
|
if error := _validate_source_path(source_path):
|
||||||
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
"code": ErrorCode.INVALID_REQUEST.value,
|
||||||
|
}
|
||||||
|
|
||||||
# Validate content size to prevent DoS
|
# Validate content size to prevent DoS
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ def mock_embeddings():
|
|||||||
return [0.1] * 1536
|
return [0.1] * 1536
|
||||||
|
|
||||||
mock_emb.generate = AsyncMock(return_value=fake_embedding())
|
mock_emb.generate = AsyncMock(return_value=fake_embedding())
|
||||||
mock_emb.generate_batch = AsyncMock(side_effect=lambda texts, **_kwargs: [fake_embedding() for _ in texts])
|
mock_emb.generate_batch = AsyncMock(
|
||||||
|
side_effect=lambda texts, **_kwargs: [fake_embedding() for _ in texts]
|
||||||
|
)
|
||||||
|
|
||||||
return mock_emb
|
return mock_emb
|
||||||
|
|
||||||
@@ -137,7 +139,7 @@ async def async_function() -> None:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_markdown():
|
def sample_markdown():
|
||||||
"""Sample Markdown content for chunking tests."""
|
"""Sample Markdown content for chunking tests."""
|
||||||
return '''# Project Documentation
|
return """# Project Documentation
|
||||||
|
|
||||||
This is the main documentation for our project.
|
This is the main documentation for our project.
|
||||||
|
|
||||||
@@ -182,20 +184,20 @@ The search endpoint allows you to query the knowledge base.
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome contributions! Please see our contributing guide.
|
We welcome contributions! Please see our contributing guide.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_text():
|
def sample_text():
|
||||||
"""Sample plain text for chunking tests."""
|
"""Sample plain text for chunking tests."""
|
||||||
return '''The quick brown fox jumps over the lazy dog. This is a sample text that we use for testing the text chunking functionality. It contains multiple sentences that should be properly split into chunks.
|
return """The quick brown fox jumps over the lazy dog. This is a sample text that we use for testing the text chunking functionality. It contains multiple sentences that should be properly split into chunks.
|
||||||
|
|
||||||
Each paragraph represents a logical unit of text. The chunker should try to respect paragraph boundaries when possible. This helps maintain context and readability.
|
Each paragraph represents a logical unit of text. The chunker should try to respect paragraph boundaries when possible. This helps maintain context and readability.
|
||||||
|
|
||||||
When chunks need to be split mid-paragraph, the chunker should prefer sentence boundaries. This ensures that each chunk contains complete thoughts and is useful for retrieval.
|
When chunks need to be split mid-paragraph, the chunker should prefer sentence boundaries. This ensures that each chunk contains complete thoughts and is useful for retrieval.
|
||||||
|
|
||||||
The final paragraph tests edge cases. What happens with short paragraphs? Do they get merged with adjacent content? Let's find out!
|
The final paragraph tests edge cases. What happens with short paragraphs? Do they get merged with adjacent content? Let's find out!
|
||||||
'''
|
"""
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Tests for chunking module."""
|
"""Tests for chunking module."""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestBaseChunker:
|
class TestBaseChunker:
|
||||||
"""Tests for base chunker functionality."""
|
"""Tests for base chunker functionality."""
|
||||||
|
|
||||||
@@ -149,7 +148,7 @@ class TestMarkdownChunker:
|
|||||||
"""Test that chunker respects heading hierarchy."""
|
"""Test that chunker respects heading hierarchy."""
|
||||||
from chunking.markdown import MarkdownChunker
|
from chunking.markdown import MarkdownChunker
|
||||||
|
|
||||||
markdown = '''# Main Title
|
markdown = """# Main Title
|
||||||
|
|
||||||
Introduction paragraph.
|
Introduction paragraph.
|
||||||
|
|
||||||
@@ -164,7 +163,7 @@ More detailed content.
|
|||||||
## Section Two
|
## Section Two
|
||||||
|
|
||||||
Content for section two.
|
Content for section two.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
chunker = MarkdownChunker(
|
chunker = MarkdownChunker(
|
||||||
chunk_size=200,
|
chunk_size=200,
|
||||||
@@ -188,7 +187,7 @@ Content for section two.
|
|||||||
"""Test handling of code blocks in markdown."""
|
"""Test handling of code blocks in markdown."""
|
||||||
from chunking.markdown import MarkdownChunker
|
from chunking.markdown import MarkdownChunker
|
||||||
|
|
||||||
markdown = '''# Code Example
|
markdown = """# Code Example
|
||||||
|
|
||||||
Here's some code:
|
Here's some code:
|
||||||
|
|
||||||
@@ -198,7 +197,7 @@ def hello():
|
|||||||
```
|
```
|
||||||
|
|
||||||
End of example.
|
End of example.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
chunker = MarkdownChunker(
|
chunker = MarkdownChunker(
|
||||||
chunk_size=500,
|
chunk_size=500,
|
||||||
@@ -256,12 +255,12 @@ class TestTextChunker:
|
|||||||
"""Test that chunker respects paragraph boundaries."""
|
"""Test that chunker respects paragraph boundaries."""
|
||||||
from chunking.text import TextChunker
|
from chunking.text import TextChunker
|
||||||
|
|
||||||
text = '''First paragraph with some content.
|
text = """First paragraph with some content.
|
||||||
|
|
||||||
Second paragraph with different content.
|
Second paragraph with different content.
|
||||||
|
|
||||||
Third paragraph to test chunking behavior.
|
Third paragraph to test chunking behavior.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
chunker = TextChunker(
|
chunker = TextChunker(
|
||||||
chunk_size=100,
|
chunk_size=100,
|
||||||
|
|||||||
@@ -67,10 +67,14 @@ class TestCollectionManager:
|
|||||||
assert result.embeddings_generated == 0
|
assert result.embeddings_generated == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ingest_error_handling(self, collection_manager, sample_ingest_request):
|
async def test_ingest_error_handling(
|
||||||
|
self, collection_manager, sample_ingest_request
|
||||||
|
):
|
||||||
"""Test ingest error handling."""
|
"""Test ingest error handling."""
|
||||||
# Make embedding generation fail
|
# Make embedding generation fail
|
||||||
collection_manager._embeddings.generate_batch.side_effect = Exception("Embedding error")
|
collection_manager._embeddings.generate_batch.side_effect = Exception(
|
||||||
|
"Embedding error"
|
||||||
|
)
|
||||||
|
|
||||||
result = await collection_manager.ingest(sample_ingest_request)
|
result = await collection_manager.ingest(sample_ingest_request)
|
||||||
|
|
||||||
@@ -182,7 +186,9 @@ class TestCollectionManager:
|
|||||||
)
|
)
|
||||||
collection_manager._database.get_collection_stats.return_value = expected_stats
|
collection_manager._database.get_collection_stats.return_value = expected_stats
|
||||||
|
|
||||||
stats = await collection_manager.get_collection_stats("proj-123", "test-collection")
|
stats = await collection_manager.get_collection_stats(
|
||||||
|
"proj-123", "test-collection"
|
||||||
|
)
|
||||||
|
|
||||||
assert stats.chunk_count == 100
|
assert stats.chunk_count == 100
|
||||||
assert stats.unique_sources == 10
|
assert stats.unique_sources == 10
|
||||||
|
|||||||
@@ -17,19 +17,15 @@ class TestEmbeddingGenerator:
|
|||||||
response.raise_for_status = MagicMock()
|
response.raise_for_status = MagicMock()
|
||||||
response.json.return_value = {
|
response.json.return_value = {
|
||||||
"result": {
|
"result": {
|
||||||
"content": [
|
"content": [{"text": json.dumps({"embeddings": [[0.1] * 1536]})}]
|
||||||
{
|
|
||||||
"text": json.dumps({
|
|
||||||
"embeddings": [[0.1] * 1536]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_generate_single_embedding(self, settings, mock_redis, mock_http_response):
|
async def test_generate_single_embedding(
|
||||||
|
self, settings, mock_redis, mock_http_response
|
||||||
|
):
|
||||||
"""Test generating a single embedding."""
|
"""Test generating a single embedding."""
|
||||||
from embeddings import EmbeddingGenerator
|
from embeddings import EmbeddingGenerator
|
||||||
|
|
||||||
@@ -67,9 +63,9 @@ class TestEmbeddingGenerator:
|
|||||||
"result": {
|
"result": {
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"text": json.dumps({
|
"text": json.dumps(
|
||||||
"embeddings": [[0.1] * 1536, [0.2] * 1536, [0.3] * 1536]
|
{"embeddings": [[0.1] * 1536, [0.2] * 1536, [0.3] * 1536]}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -166,9 +162,11 @@ class TestEmbeddingGenerator:
|
|||||||
"result": {
|
"result": {
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"text": json.dumps({
|
"text": json.dumps(
|
||||||
|
{
|
||||||
"embeddings": [[0.1] * 768] # Wrong dimension
|
"embeddings": [[0.1] * 768] # Wrong dimension
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Tests for exception classes."""
|
"""Tests for exception classes."""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestErrorCode:
|
class TestErrorCode:
|
||||||
"""Tests for ErrorCode enum."""
|
"""Tests for ErrorCode enum."""
|
||||||
|
|
||||||
@@ -10,8 +9,13 @@ class TestErrorCode:
|
|||||||
from exceptions import ErrorCode
|
from exceptions import ErrorCode
|
||||||
|
|
||||||
assert ErrorCode.UNKNOWN_ERROR.value == "KB_UNKNOWN_ERROR"
|
assert ErrorCode.UNKNOWN_ERROR.value == "KB_UNKNOWN_ERROR"
|
||||||
assert ErrorCode.DATABASE_CONNECTION_ERROR.value == "KB_DATABASE_CONNECTION_ERROR"
|
assert (
|
||||||
assert ErrorCode.EMBEDDING_GENERATION_ERROR.value == "KB_EMBEDDING_GENERATION_ERROR"
|
ErrorCode.DATABASE_CONNECTION_ERROR.value == "KB_DATABASE_CONNECTION_ERROR"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
ErrorCode.EMBEDDING_GENERATION_ERROR.value
|
||||||
|
== "KB_EMBEDDING_GENERATION_ERROR"
|
||||||
|
)
|
||||||
assert ErrorCode.CHUNKING_ERROR.value == "KB_CHUNKING_ERROR"
|
assert ErrorCode.CHUNKING_ERROR.value == "KB_CHUNKING_ERROR"
|
||||||
assert ErrorCode.SEARCH_ERROR.value == "KB_SEARCH_ERROR"
|
assert ErrorCode.SEARCH_ERROR.value == "KB_SEARCH_ERROR"
|
||||||
assert ErrorCode.COLLECTION_NOT_FOUND.value == "KB_COLLECTION_NOT_FOUND"
|
assert ErrorCode.COLLECTION_NOT_FOUND.value == "KB_COLLECTION_NOT_FOUND"
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ class TestSearchEngine:
|
|||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_semantic_search(self, search_engine, sample_search_request, sample_db_results):
|
async def test_semantic_search(
|
||||||
|
self, search_engine, sample_search_request, sample_db_results
|
||||||
|
):
|
||||||
"""Test semantic search."""
|
"""Test semantic search."""
|
||||||
from models import SearchType
|
from models import SearchType
|
||||||
|
|
||||||
@@ -74,7 +76,9 @@ class TestSearchEngine:
|
|||||||
search_engine._database.semantic_search.assert_called_once()
|
search_engine._database.semantic_search.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_keyword_search(self, search_engine, sample_search_request, sample_db_results):
|
async def test_keyword_search(
|
||||||
|
self, search_engine, sample_search_request, sample_db_results
|
||||||
|
):
|
||||||
"""Test keyword search."""
|
"""Test keyword search."""
|
||||||
from models import SearchType
|
from models import SearchType
|
||||||
|
|
||||||
@@ -88,7 +92,9 @@ class TestSearchEngine:
|
|||||||
search_engine._database.keyword_search.assert_called_once()
|
search_engine._database.keyword_search.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_hybrid_search(self, search_engine, sample_search_request, sample_db_results):
|
async def test_hybrid_search(
|
||||||
|
self, search_engine, sample_search_request, sample_db_results
|
||||||
|
):
|
||||||
"""Test hybrid search."""
|
"""Test hybrid search."""
|
||||||
from models import SearchType
|
from models import SearchType
|
||||||
|
|
||||||
@@ -105,7 +111,9 @@ class TestSearchEngine:
|
|||||||
assert len(response.results) >= 1
|
assert len(response.results) >= 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_with_collection_filter(self, search_engine, sample_search_request, sample_db_results):
|
async def test_search_with_collection_filter(
|
||||||
|
self, search_engine, sample_search_request, sample_db_results
|
||||||
|
):
|
||||||
"""Test search with collection filter."""
|
"""Test search with collection filter."""
|
||||||
from models import SearchType
|
from models import SearchType
|
||||||
|
|
||||||
@@ -120,7 +128,9 @@ class TestSearchEngine:
|
|||||||
assert call_args.kwargs["collection"] == "specific-collection"
|
assert call_args.kwargs["collection"] == "specific-collection"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_with_file_type_filter(self, search_engine, sample_search_request, sample_db_results):
|
async def test_search_with_file_type_filter(
|
||||||
|
self, search_engine, sample_search_request, sample_db_results
|
||||||
|
):
|
||||||
"""Test search with file type filter."""
|
"""Test search with file type filter."""
|
||||||
from models import FileType, SearchType
|
from models import FileType, SearchType
|
||||||
|
|
||||||
@@ -135,7 +145,9 @@ class TestSearchEngine:
|
|||||||
assert call_args.kwargs["file_types"] == [FileType.PYTHON]
|
assert call_args.kwargs["file_types"] == [FileType.PYTHON]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_respects_limit(self, search_engine, sample_search_request, sample_db_results):
|
async def test_search_respects_limit(
|
||||||
|
self, search_engine, sample_search_request, sample_db_results
|
||||||
|
):
|
||||||
"""Test that search respects result limit."""
|
"""Test that search respects result limit."""
|
||||||
from models import SearchType
|
from models import SearchType
|
||||||
|
|
||||||
@@ -148,7 +160,9 @@ class TestSearchEngine:
|
|||||||
assert len(response.results) <= 1
|
assert len(response.results) <= 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_records_time(self, search_engine, sample_search_request, sample_db_results):
|
async def test_search_records_time(
|
||||||
|
self, search_engine, sample_search_request, sample_db_results
|
||||||
|
):
|
||||||
"""Test that search records time."""
|
"""Test that search records time."""
|
||||||
from models import SearchType
|
from models import SearchType
|
||||||
|
|
||||||
@@ -203,13 +217,21 @@ class TestReciprocalRankFusion:
|
|||||||
from models import SearchResult
|
from models import SearchResult
|
||||||
|
|
||||||
semantic = [
|
semantic = [
|
||||||
SearchResult(id="a", content="A", score=0.9, chunk_type="code", collection="default"),
|
SearchResult(
|
||||||
SearchResult(id="b", content="B", score=0.8, chunk_type="code", collection="default"),
|
id="a", content="A", score=0.9, chunk_type="code", collection="default"
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
id="b", content="B", score=0.8, chunk_type="code", collection="default"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
keyword = [
|
keyword = [
|
||||||
SearchResult(id="b", content="B", score=0.85, chunk_type="code", collection="default"),
|
SearchResult(
|
||||||
SearchResult(id="c", content="C", score=0.7, chunk_type="code", collection="default"),
|
id="b", content="B", score=0.85, chunk_type="code", collection="default"
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
id="c", content="C", score=0.7, chunk_type="code", collection="default"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
fused = search_engine._reciprocal_rank_fusion(semantic, keyword)
|
fused = search_engine._reciprocal_rank_fusion(semantic, keyword)
|
||||||
@@ -230,19 +252,23 @@ class TestReciprocalRankFusion:
|
|||||||
|
|
||||||
# Same results in same order
|
# Same results in same order
|
||||||
results = [
|
results = [
|
||||||
SearchResult(id="a", content="A", score=0.9, chunk_type="code", collection="default"),
|
SearchResult(
|
||||||
|
id="a", content="A", score=0.9, chunk_type="code", collection="default"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# High semantic weight
|
# High semantic weight
|
||||||
fused_semantic_heavy = search_engine._reciprocal_rank_fusion(
|
fused_semantic_heavy = search_engine._reciprocal_rank_fusion(
|
||||||
results, [],
|
results,
|
||||||
|
[],
|
||||||
semantic_weight=0.9,
|
semantic_weight=0.9,
|
||||||
keyword_weight=0.1,
|
keyword_weight=0.1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# High keyword weight
|
# High keyword weight
|
||||||
fused_keyword_heavy = search_engine._reciprocal_rank_fusion(
|
fused_keyword_heavy = search_engine._reciprocal_rank_fusion(
|
||||||
[], results,
|
[],
|
||||||
|
results,
|
||||||
semantic_weight=0.1,
|
semantic_weight=0.1,
|
||||||
keyword_weight=0.9,
|
keyword_weight=0.9,
|
||||||
)
|
)
|
||||||
@@ -256,12 +282,18 @@ class TestReciprocalRankFusion:
|
|||||||
from models import SearchResult
|
from models import SearchResult
|
||||||
|
|
||||||
semantic = [
|
semantic = [
|
||||||
SearchResult(id="a", content="A", score=0.9, chunk_type="code", collection="default"),
|
SearchResult(
|
||||||
SearchResult(id="b", content="B", score=0.8, chunk_type="code", collection="default"),
|
id="a", content="A", score=0.9, chunk_type="code", collection="default"
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
id="b", content="B", score=0.8, chunk_type="code", collection="default"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
keyword = [
|
keyword = [
|
||||||
SearchResult(id="c", content="C", score=0.7, chunk_type="code", collection="default"),
|
SearchResult(
|
||||||
|
id="c", content="C", score=0.7, chunk_type="code", collection="default"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
fused = search_engine._reciprocal_rank_fusion(semantic, keyword)
|
fused = search_engine._reciprocal_rank_fusion(semantic, keyword)
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
.PHONY: help install install-dev lint lint-fix format type-check test test-cov validate clean run
|
.PHONY: help install install-dev lint lint-fix format format-check type-check test test-cov validate clean run
|
||||||
|
|
||||||
|
# Ensure commands in this project don't inherit an external Python virtualenv
|
||||||
|
# (prevents uv warnings about mismatched VIRTUAL_ENV when running from repo root)
|
||||||
|
unexport VIRTUAL_ENV
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
help:
|
help:
|
||||||
@@ -12,6 +16,7 @@ help:
|
|||||||
@echo " make lint - Run Ruff linter"
|
@echo " make lint - Run Ruff linter"
|
||||||
@echo " make lint-fix - Run Ruff linter with auto-fix"
|
@echo " make lint-fix - Run Ruff linter with auto-fix"
|
||||||
@echo " make format - Format code with Ruff"
|
@echo " make format - Format code with Ruff"
|
||||||
|
@echo " make format-check - Check if code is formatted"
|
||||||
@echo " make type-check - Run mypy type checker"
|
@echo " make type-check - Run mypy type checker"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Testing:"
|
@echo "Testing:"
|
||||||
@@ -19,7 +24,7 @@ help:
|
|||||||
@echo " make test-cov - Run pytest with coverage"
|
@echo " make test-cov - Run pytest with coverage"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "All-in-one:"
|
@echo "All-in-one:"
|
||||||
@echo " make validate - Run lint, type-check, and tests"
|
@echo " make validate - Run all checks (lint + format + types)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Running:"
|
@echo "Running:"
|
||||||
@echo " make run - Run the server locally"
|
@echo " make run - Run the server locally"
|
||||||
@@ -49,6 +54,10 @@ format:
|
|||||||
@echo "Formatting code..."
|
@echo "Formatting code..."
|
||||||
@uv run ruff format .
|
@uv run ruff format .
|
||||||
|
|
||||||
|
format-check:
|
||||||
|
@echo "Checking code formatting..."
|
||||||
|
@uv run ruff format --check .
|
||||||
|
|
||||||
type-check:
|
type-check:
|
||||||
@echo "Running mypy..."
|
@echo "Running mypy..."
|
||||||
@uv run mypy . --ignore-missing-imports
|
@uv run mypy . --ignore-missing-imports
|
||||||
@@ -63,7 +72,7 @@ test-cov:
|
|||||||
@uv run pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=html
|
@uv run pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=html
|
||||||
|
|
||||||
# All-in-one validation
|
# All-in-one validation
|
||||||
validate: lint type-check test
|
validate: lint format-check type-check
|
||||||
@echo "All validations passed!"
|
@echo "All validations passed!"
|
||||||
|
|
||||||
# Running
|
# Running
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ class CircuitBreaker:
|
|||||||
if self._state == CircuitState.OPEN:
|
if self._state == CircuitState.OPEN:
|
||||||
time_in_open = time.time() - self._stats.state_changed_at
|
time_in_open = time.time() - self._stats.state_changed_at
|
||||||
# Double-check state after time calculation (for thread safety)
|
# Double-check state after time calculation (for thread safety)
|
||||||
if time_in_open >= self.recovery_timeout and self._state == CircuitState.OPEN:
|
if (
|
||||||
|
time_in_open >= self.recovery_timeout
|
||||||
|
and self._state == CircuitState.OPEN
|
||||||
|
):
|
||||||
self._transition_to(CircuitState.HALF_OPEN)
|
self._transition_to(CircuitState.HALF_OPEN)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Circuit {self.name} transitioned to HALF_OPEN "
|
f"Circuit {self.name} transitioned to HALF_OPEN "
|
||||||
|
|||||||
Reference in New Issue
Block a user