forked from cardosofelipe/fast-next-template
Compare commits
14 Commits
79cb6bfd7b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad3d20cf2 | ||
|
|
8623eb56f5 | ||
|
|
3cb6c8d13b | ||
|
|
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": 20,
|
||||||
|
"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": 20,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 20,
|
||||||
|
"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": 20,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 30,
|
||||||
|
"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": 10,
|
||||||
|
"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": 50,
|
||||||
|
"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": 50,
|
||||||
|
"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": 50,
|
||||||
|
"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
|
||||||
):
|
):
|
||||||
|
|||||||
55
frontend/package-lock.json
generated
55
frontend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
@@ -4688,6 +4689,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-toggle": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-toggle-group": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-toggle": "1.1.10",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ export default function AgentTypeDetailPage() {
|
|||||||
mcp_servers: data.mcp_servers,
|
mcp_servers: data.mcp_servers,
|
||||||
tool_permissions: data.tool_permissions,
|
tool_permissions: data.tool_permissions,
|
||||||
is_active: data.is_active,
|
is_active: data.is_active,
|
||||||
|
// Category and display fields
|
||||||
|
category: data.category,
|
||||||
|
icon: data.icon,
|
||||||
|
color: data.color,
|
||||||
|
sort_order: data.sort_order,
|
||||||
|
typical_tasks: data.typical_tasks,
|
||||||
|
collaboration_hints: data.collaboration_hints,
|
||||||
});
|
});
|
||||||
toast.success('Agent type created', {
|
toast.success('Agent type created', {
|
||||||
description: `${result.name} has been created successfully`,
|
description: `${result.name} has been created successfully`,
|
||||||
@@ -94,6 +101,13 @@ export default function AgentTypeDetailPage() {
|
|||||||
mcp_servers: data.mcp_servers,
|
mcp_servers: data.mcp_servers,
|
||||||
tool_permissions: data.tool_permissions,
|
tool_permissions: data.tool_permissions,
|
||||||
is_active: data.is_active,
|
is_active: data.is_active,
|
||||||
|
// Category and display fields
|
||||||
|
category: data.category,
|
||||||
|
icon: data.icon,
|
||||||
|
color: data.color,
|
||||||
|
sort_order: data.sort_order,
|
||||||
|
typical_tasks: data.typical_tasks,
|
||||||
|
collaboration_hints: data.collaboration_hints,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success('Agent type updated', {
|
toast.success('Agent type updated', {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Agent Types List Page
|
* Agent Types List Page
|
||||||
*
|
*
|
||||||
* Displays a list of agent types with search and filter functionality.
|
* Displays a list of agent types with search, status, and category filters.
|
||||||
* Allows navigation to agent type detail and creation pages.
|
* Supports grid and list view modes with user preference persistence.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -10,9 +10,10 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useRouter } from '@/lib/i18n/routing';
|
import { useRouter } from '@/lib/i18n/routing';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AgentTypeList } from '@/components/agents';
|
import { AgentTypeList, type ViewMode } from '@/components/agents';
|
||||||
import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes';
|
import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes';
|
||||||
import { useDebounce } from '@/lib/hooks/useDebounce';
|
import { useDebounce } from '@/lib/hooks/useDebounce';
|
||||||
|
import type { AgentTypeCategory } from '@/lib/api/types/agentTypes';
|
||||||
|
|
||||||
export default function AgentTypesPage() {
|
export default function AgentTypesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,6 +21,8 @@ export default function AgentTypesPage() {
|
|||||||
// Filter state
|
// Filter state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
|
|
||||||
// Debounce search for API calls
|
// Debounce search for API calls
|
||||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||||
@@ -31,21 +34,25 @@ export default function AgentTypesPage() {
|
|||||||
return undefined; // 'all' returns undefined to not filter
|
return undefined; // 'all' returns undefined to not filter
|
||||||
}, [statusFilter]);
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
// Determine category filter value
|
||||||
|
const categoryFilterValue = useMemo(() => {
|
||||||
|
if (categoryFilter === 'all') return undefined;
|
||||||
|
return categoryFilter as AgentTypeCategory;
|
||||||
|
}, [categoryFilter]);
|
||||||
|
|
||||||
// Fetch agent types
|
// Fetch agent types
|
||||||
const { data, isLoading, error } = useAgentTypes({
|
const { data, isLoading, error } = useAgentTypes({
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
is_active: isActiveFilter,
|
is_active: isActiveFilter,
|
||||||
|
category: categoryFilterValue,
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter results client-side for 'all' status
|
// Get filtered and sorted agent types (sort by sort_order ascending - smaller first)
|
||||||
const filteredAgentTypes = useMemo(() => {
|
const filteredAgentTypes = useMemo(() => {
|
||||||
if (!data?.data) return [];
|
if (!data?.data) return [];
|
||||||
|
return [...data.data].sort((a, b) => a.sort_order - b.sort_order);
|
||||||
// When status is 'all', we need to fetch both and combine
|
|
||||||
// For now, the API returns based on is_active filter
|
|
||||||
return data.data;
|
|
||||||
}, [data?.data]);
|
}, [data?.data]);
|
||||||
|
|
||||||
// Handle navigation to agent type detail
|
// Handle navigation to agent type detail
|
||||||
@@ -71,6 +78,16 @@ export default function AgentTypesPage() {
|
|||||||
setStatusFilter(status);
|
setStatusFilter(status);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle category filter change
|
||||||
|
const handleCategoryFilterChange = useCallback((category: string) => {
|
||||||
|
setCategoryFilter(category);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle view mode change
|
||||||
|
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||||
|
setViewMode(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Show error toast if fetch fails
|
// Show error toast if fetch fails
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error('Failed to load agent types', {
|
toast.error('Failed to load agent types', {
|
||||||
@@ -87,6 +104,10 @@ export default function AgentTypesPage() {
|
|||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={handleStatusFilterChange}
|
onStatusFilterChange={handleStatusFilterChange}
|
||||||
|
categoryFilter={categoryFilter}
|
||||||
|
onCategoryFilterChange={handleCategoryFilterChange}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={handleViewModeChange}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* AgentTypeDetail Component
|
* AgentTypeDetail Component
|
||||||
*
|
*
|
||||||
* Displays detailed information about a single agent type.
|
* Displays detailed information about a single agent type.
|
||||||
* Shows model configuration, permissions, personality, and instance stats.
|
* Features a hero header with icon/color, category, typical tasks,
|
||||||
|
* collaboration hints, model configuration, and instance stats.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -36,8 +37,13 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
import { DynamicIcon } from '@/components/ui/dynamic-icon';
|
||||||
|
import type { AgentTypeResponse, AgentTypeCategory } from '@/lib/api/types/agentTypes';
|
||||||
|
import { CATEGORY_METADATA } from '@/lib/api/types/agentTypes';
|
||||||
import { AVAILABLE_MCP_SERVERS } from '@/lib/validations/agentType';
|
import { AVAILABLE_MCP_SERVERS } from '@/lib/validations/agentType';
|
||||||
|
|
||||||
interface AgentTypeDetailProps {
|
interface AgentTypeDetailProps {
|
||||||
@@ -51,6 +57,30 @@ interface AgentTypeDetailProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category badge with color
|
||||||
|
*/
|
||||||
|
function CategoryBadge({ category }: { category: AgentTypeCategory | null }) {
|
||||||
|
if (!category) return null;
|
||||||
|
|
||||||
|
const meta = CATEGORY_METADATA[category];
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-medium"
|
||||||
|
style={{
|
||||||
|
borderColor: meta.color,
|
||||||
|
color: meta.color,
|
||||||
|
backgroundColor: `${meta.color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status badge component for agent types
|
* Status badge component for agent types
|
||||||
*/
|
*/
|
||||||
@@ -81,11 +111,22 @@ function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
|
|||||||
function AgentTypeDetailSkeleton() {
|
function AgentTypeDetailSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
{/* Hero skeleton */}
|
||||||
<Skeleton className="h-10 w-10" />
|
<div className="rounded-xl border p-6">
|
||||||
<div className="flex-1">
|
<div className="flex items-start gap-6">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-20 w-20 rounded-xl" />
|
||||||
<Skeleton className="mt-2 h-4 w-48" />
|
<div className="flex-1 space-y-3">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-96" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
<Skeleton className="h-6 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-9 w-24" />
|
||||||
|
<Skeleton className="h-9 w-20" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
@@ -161,57 +202,134 @@ export function AgentTypeDetail({
|
|||||||
top_p?: number;
|
top_p?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const agentColor = agentType.color || '#3B82F6';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{/* Header */}
|
{/* Back button */}
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
Back to Agent Types
|
||||||
<span className="sr-only">Go back</span>
|
</Button>
|
||||||
</Button>
|
|
||||||
<div className="flex-1">
|
{/* Hero Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div
|
||||||
<h1 className="text-3xl font-bold">{agentType.name}</h1>
|
className="mb-6 overflow-hidden rounded-xl border"
|
||||||
<AgentTypeStatusBadge isActive={agentType.is_active} />
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${agentColor}08 0%, transparent 60%)`,
|
||||||
|
borderColor: `${agentColor}30`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-1.5 w-full"
|
||||||
|
style={{ background: `linear-gradient(90deg, ${agentColor}, ${agentColor}60)` }}
|
||||||
|
/>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${agentColor}15`,
|
||||||
|
boxShadow: `0 8px 32px ${agentColor}20`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicIcon
|
||||||
|
name={agentType.icon}
|
||||||
|
className="h-10 w-10"
|
||||||
|
style={{ color: agentColor }}
|
||||||
|
fallback="bot"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">{agentType.name}</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{agentType.description || 'No description provided'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<AgentTypeStatusBadge isActive={agentType.is_active} />
|
||||||
|
<CategoryBadge category={agentType.category} />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Last updated:{' '}
|
||||||
|
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onDuplicate}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onEdit}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Last modified:{' '}
|
|
||||||
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={onDuplicate}>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
Duplicate
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={onEdit}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
{/* Description Card */}
|
{/* What This Agent Does Best */}
|
||||||
<Card>
|
{agentType.typical_tasks.length > 0 && (
|
||||||
<CardHeader>
|
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardHeader className="pb-3">
|
||||||
<FileText className="h-5 w-5" />
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
Description
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
</CardTitle>
|
What This Agent Does Best
|
||||||
</CardHeader>
|
</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<p className="text-muted-foreground">
|
<CardContent>
|
||||||
{agentType.description || 'No description provided'}
|
<ul className="space-y-2">
|
||||||
</p>
|
{agentType.typical_tasks.map((task, index) => (
|
||||||
</CardContent>
|
<li key={index} className="flex items-start gap-2">
|
||||||
</Card>
|
<Check
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
||||||
|
style={{ color: agentColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{task}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Works Well With */}
|
||||||
|
{agentType.collaboration_hints.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Works Well With
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Agents that complement this type for effective collaboration
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{agentType.collaboration_hints.map((hint, index) => (
|
||||||
|
<Badge key={index} variant="secondary" className="text-sm">
|
||||||
|
{hint}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Expertise Card */}
|
{/* Expertise Card */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -355,7 +473,9 @@ export function AgentTypeDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-4xl font-bold text-primary">{agentType.instance_count}</p>
|
<p className="text-4xl font-bold" style={{ color: agentColor }}>
|
||||||
|
{agentType.instance_count}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">Active instances</p>
|
<p className="text-sm text-muted-foreground">Active instances</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="mt-4 w-full" size="sm" disabled>
|
<Button variant="outline" className="mt-4 w-full" size="sm" disabled>
|
||||||
@@ -364,6 +484,36 @@ export function AgentTypeDetail({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Agent Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Slug</span>
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{agentType.slug}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Sort Order</span>
|
||||||
|
<span>{agentType.sort_order}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Created</span>
|
||||||
|
<span>
|
||||||
|
{new Date(agentType.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<Card className="border-destructive/50">
|
<Card className="border-destructive/50">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* AgentTypeList Component
|
* AgentTypeList Component
|
||||||
*
|
*
|
||||||
* Displays a grid of agent type cards with search and filter functionality.
|
* Displays agent types in grid or list view with search, status, and category filters.
|
||||||
* Used on the main agent types page for browsing and selecting agent types.
|
* Shows icon, color accent, and category for each agent type.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -20,8 +20,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Bot, Plus, Search, Cpu } from 'lucide-react';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
import { Bot, Plus, Search, Cpu, LayoutGrid, List } from 'lucide-react';
|
||||||
|
import { DynamicIcon } from '@/components/ui/dynamic-icon';
|
||||||
|
import type { AgentTypeResponse, AgentTypeCategory } from '@/lib/api/types/agentTypes';
|
||||||
|
import { CATEGORY_METADATA } from '@/lib/api/types/agentTypes';
|
||||||
|
import { AGENT_TYPE_CATEGORIES } from '@/lib/validations/agentType';
|
||||||
|
|
||||||
|
export type ViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
interface AgentTypeListProps {
|
interface AgentTypeListProps {
|
||||||
agentTypes: AgentTypeResponse[];
|
agentTypes: AgentTypeResponse[];
|
||||||
@@ -30,6 +36,10 @@ interface AgentTypeListProps {
|
|||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
onStatusFilterChange: (status: string) => void;
|
onStatusFilterChange: (status: string) => void;
|
||||||
|
categoryFilter: string;
|
||||||
|
onCategoryFilterChange: (category: string) => void;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -60,11 +70,36 @@ function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading skeleton for agent type cards
|
* Category badge with color
|
||||||
|
*/
|
||||||
|
function CategoryBadge({ category }: { category: AgentTypeCategory | null }) {
|
||||||
|
if (!category) return null;
|
||||||
|
|
||||||
|
const meta = CATEGORY_METADATA[category];
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
borderColor: meta.color,
|
||||||
|
color: meta.color,
|
||||||
|
backgroundColor: `${meta.color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton for agent type cards (grid view)
|
||||||
*/
|
*/
|
||||||
function AgentTypeCardSkeleton() {
|
function AgentTypeCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="h-[200px]">
|
<Card className="h-[220px] overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-muted" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||||
@@ -91,6 +126,23 @@ function AgentTypeCardSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton for list view
|
||||||
|
*/
|
||||||
|
function AgentTypeListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border p-4">
|
||||||
|
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-96" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-20" />
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract model display name from model ID
|
* Extract model display name from model ID
|
||||||
*/
|
*/
|
||||||
@@ -103,6 +155,169 @@ function getModelDisplayName(modelId: string): string {
|
|||||||
return modelId;
|
return modelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid card view for agent type
|
||||||
|
*/
|
||||||
|
function AgentTypeGridCard({
|
||||||
|
type,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
type: AgentTypeResponse;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const agentColor = type.color || '#3B82F6';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer overflow-hidden transition-all hover:shadow-lg"
|
||||||
|
onClick={() => onSelect(type.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(type.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`View ${type.name} agent type`}
|
||||||
|
style={{
|
||||||
|
borderTopColor: agentColor,
|
||||||
|
borderTopWidth: '3px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${agentColor}15`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicIcon
|
||||||
|
name={type.icon}
|
||||||
|
className="h-5 w-5"
|
||||||
|
style={{ color: agentColor }}
|
||||||
|
fallback="bot"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<AgentTypeStatusBadge isActive={type.is_active} />
|
||||||
|
<CategoryBadge category={type.category} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="mt-3 line-clamp-1">{type.name}</CardTitle>
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{type.description || 'No description provided'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Expertise tags */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{type.expertise.slice(0, 3).map((skill) => (
|
||||||
|
<Badge key={skill} variant="secondary" className="text-xs">
|
||||||
|
{skill}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{type.expertise.length > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{type.expertise.length - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{type.expertise.length === 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">No expertise defined</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Cpu className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Bot className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs">{type.instance_count} instances</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List row view for agent type
|
||||||
|
*/
|
||||||
|
function AgentTypeListRow({
|
||||||
|
type,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
type: AgentTypeResponse;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const agentColor = type.color || '#3B82F6';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center gap-4 rounded-lg border p-4 transition-all hover:border-primary hover:shadow-md"
|
||||||
|
onClick={() => onSelect(type.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(type.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`View ${type.name} agent type`}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: agentColor,
|
||||||
|
borderLeftWidth: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
style={{ backgroundColor: `${agentColor}15` }}
|
||||||
|
>
|
||||||
|
<DynamicIcon
|
||||||
|
name={type.icon}
|
||||||
|
className="h-6 w-6"
|
||||||
|
style={{ color: agentColor }}
|
||||||
|
fallback="bot"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold">{type.name}</h3>
|
||||||
|
<CategoryBadge category={type.category} />
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-1 text-sm text-muted-foreground">
|
||||||
|
{type.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Cpu className="h-3 w-3" />
|
||||||
|
{getModelDisplayName(type.primary_model)}
|
||||||
|
</span>
|
||||||
|
<span>{type.expertise.length} expertise areas</span>
|
||||||
|
<span>{type.instance_count} instances</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<AgentTypeStatusBadge isActive={type.is_active} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentTypeList({
|
export function AgentTypeList({
|
||||||
agentTypes,
|
agentTypes,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -110,6 +325,10 @@ export function AgentTypeList({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
|
categoryFilter,
|
||||||
|
onCategoryFilterChange,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
onCreate,
|
onCreate,
|
||||||
className,
|
className,
|
||||||
@@ -131,7 +350,7 @@ export function AgentTypeList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -142,8 +361,25 @@ export function AgentTypeList({
|
|||||||
aria-label="Search agent types"
|
aria-label="Search agent types"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<Select value={categoryFilter} onValueChange={onCategoryFilterChange}>
|
||||||
|
<SelectTrigger className="w-full sm:w-44" aria-label="Filter by category">
|
||||||
|
<SelectValue placeholder="All Categories" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
|
{AGENT_TYPE_CATEGORIES.map((cat) => (
|
||||||
|
<SelectItem key={cat.value} value={cat.value}>
|
||||||
|
{cat.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||||
<SelectTrigger className="w-full sm:w-40" aria-label="Filter by status">
|
<SelectTrigger className="w-full sm:w-36" aria-label="Filter by status">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -152,10 +388,25 @@ export function AgentTypeList({
|
|||||||
<SelectItem value="inactive">Inactive</SelectItem>
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={viewMode}
|
||||||
|
onValueChange={(value: string) => value && onViewModeChange(value as ViewMode)}
|
||||||
|
className="hidden sm:flex"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="grid" aria-label="Grid view" size="sm">
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="list" aria-label="List view" size="sm">
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Loading State - Grid */}
|
||||||
{isLoading && (
|
{isLoading && viewMode === 'grid' && (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
<AgentTypeCardSkeleton key={i} />
|
<AgentTypeCardSkeleton key={i} />
|
||||||
@@ -163,71 +414,29 @@ export function AgentTypeList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Agent Type Grid */}
|
{/* Loading State - List */}
|
||||||
{!isLoading && agentTypes.length > 0 && (
|
{isLoading && viewMode === 'list' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<AgentTypeListSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent Type Grid View */}
|
||||||
|
{!isLoading && agentTypes.length > 0 && viewMode === 'grid' && (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{agentTypes.map((type) => (
|
{agentTypes.map((type) => (
|
||||||
<Card
|
<AgentTypeGridCard key={type.id} type={type} onSelect={onSelect} />
|
||||||
key={type.id}
|
))}
|
||||||
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
|
</div>
|
||||||
onClick={() => onSelect(type.id)}
|
)}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onSelect(type.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label={`View ${type.name} agent type`}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Bot className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<AgentTypeStatusBadge isActive={type.is_active} />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="mt-3">{type.name}</CardTitle>
|
|
||||||
<CardDescription className="line-clamp-2">
|
|
||||||
{type.description || 'No description provided'}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Expertise tags */}
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{type.expertise.slice(0, 3).map((skill) => (
|
|
||||||
<Badge key={skill} variant="secondary" className="text-xs">
|
|
||||||
{skill}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{type.expertise.length > 3 && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
+{type.expertise.length - 3}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{type.expertise.length === 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">No expertise defined</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
{/* Agent Type List View */}
|
||||||
|
{!isLoading && agentTypes.length > 0 && viewMode === 'list' && (
|
||||||
{/* Metadata */}
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
{agentTypes.map((type) => (
|
||||||
<div className="flex items-center gap-1">
|
<AgentTypeListRow key={type.id} type={type} onSelect={onSelect} />
|
||||||
<Cpu className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Bot className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-xs">{type.instance_count} instances</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -238,11 +447,11 @@ export function AgentTypeList({
|
|||||||
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
|
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||||
<h3 className="mt-4 font-semibold">No agent types found</h3>
|
<h3 className="mt-4 font-semibold">No agent types found</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{searchQuery || statusFilter !== 'all'
|
{searchQuery || statusFilter !== 'all' || categoryFilter !== 'all'
|
||||||
? 'Try adjusting your search or filters'
|
? 'Try adjusting your search or filters'
|
||||||
: 'Create your first agent type to get started'}
|
: 'Create your first agent type to get started'}
|
||||||
</p>
|
</p>
|
||||||
{!searchQuery && statusFilter === 'all' && (
|
{!searchQuery && statusFilter === 'all' && categoryFilter === 'all' && (
|
||||||
<Button onClick={onCreate} className="mt-4">
|
<Button onClick={onCreate} className="mt-4">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Agent Type
|
Create Agent Type
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { AgentTypeForm } from './AgentTypeForm';
|
export { AgentTypeForm } from './AgentTypeForm';
|
||||||
export { AgentTypeList } from './AgentTypeList';
|
export { AgentTypeList, type ViewMode } from './AgentTypeList';
|
||||||
export { AgentTypeDetail } from './AgentTypeDetail';
|
export { AgentTypeDetail } from './AgentTypeDetail';
|
||||||
|
|||||||
84
frontend/src/components/ui/dynamic-icon.tsx
Normal file
84
frontend/src/components/ui/dynamic-icon.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* DynamicIcon Component
|
||||||
|
*
|
||||||
|
* Renders Lucide icons dynamically by name string.
|
||||||
|
* Useful when icon names come from data (e.g., database).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import type { LucideProps } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of icon names to their components.
|
||||||
|
* Uses kebab-case names (e.g., 'clipboard-check') as keys.
|
||||||
|
*/
|
||||||
|
const iconMap: Record<string, React.ComponentType<LucideProps>> = {
|
||||||
|
// Development
|
||||||
|
'clipboard-check': LucideIcons.ClipboardCheck,
|
||||||
|
briefcase: LucideIcons.Briefcase,
|
||||||
|
'file-text': LucideIcons.FileText,
|
||||||
|
'git-branch': LucideIcons.GitBranch,
|
||||||
|
code: LucideIcons.Code,
|
||||||
|
server: LucideIcons.Server,
|
||||||
|
layout: LucideIcons.Layout,
|
||||||
|
smartphone: LucideIcons.Smartphone,
|
||||||
|
// Design
|
||||||
|
palette: LucideIcons.Palette,
|
||||||
|
search: LucideIcons.Search,
|
||||||
|
// Quality
|
||||||
|
shield: LucideIcons.Shield,
|
||||||
|
'shield-check': LucideIcons.ShieldCheck,
|
||||||
|
// Operations
|
||||||
|
settings: LucideIcons.Settings,
|
||||||
|
'settings-2': LucideIcons.Settings2,
|
||||||
|
// AI/ML
|
||||||
|
brain: LucideIcons.Brain,
|
||||||
|
microscope: LucideIcons.Microscope,
|
||||||
|
eye: LucideIcons.Eye,
|
||||||
|
'message-square': LucideIcons.MessageSquare,
|
||||||
|
// Data
|
||||||
|
'bar-chart': LucideIcons.BarChart,
|
||||||
|
database: LucideIcons.Database,
|
||||||
|
// Leadership
|
||||||
|
users: LucideIcons.Users,
|
||||||
|
target: LucideIcons.Target,
|
||||||
|
// Domain Expert
|
||||||
|
calculator: LucideIcons.Calculator,
|
||||||
|
'heart-pulse': LucideIcons.HeartPulse,
|
||||||
|
'flask-conical': LucideIcons.FlaskConical,
|
||||||
|
lightbulb: LucideIcons.Lightbulb,
|
||||||
|
'book-open': LucideIcons.BookOpen,
|
||||||
|
// Generic
|
||||||
|
bot: LucideIcons.Bot,
|
||||||
|
cpu: LucideIcons.Cpu,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DynamicIconProps extends Omit<LucideProps, 'name'> {
|
||||||
|
/** Icon name in kebab-case (e.g., 'clipboard-check', 'bot') */
|
||||||
|
name: string | null | undefined;
|
||||||
|
/** Fallback icon name if the specified icon is not found */
|
||||||
|
fallback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a Lucide icon dynamically by name.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <DynamicIcon name="clipboard-check" className="h-5 w-5" />
|
||||||
|
* <DynamicIcon name={agent.icon} fallback="bot" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function DynamicIcon({ name, fallback = 'bot', ...props }: DynamicIconProps) {
|
||||||
|
const iconName = name || fallback;
|
||||||
|
const IconComponent = iconMap[iconName] || iconMap[fallback] || LucideIcons.Bot;
|
||||||
|
|
||||||
|
return <IconComponent {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available icon names for validation or display
|
||||||
|
*/
|
||||||
|
export function getAvailableIconNames(): string[] {
|
||||||
|
return Object.keys(iconMap);
|
||||||
|
}
|
||||||
93
frontend/src/components/ui/toggle-group.tsx
Normal file
93
frontend/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const toggleGroupVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md border bg-transparent',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
outline: 'border border-input',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'outline',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleGroupItemVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent hover:bg-muted hover:text-muted-foreground',
|
||||||
|
outline: 'bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-3',
|
||||||
|
sm: 'h-9 px-2.5',
|
||||||
|
lg: 'h-11 px-5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleGroupItemVariants>>({
|
||||||
|
size: 'default',
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToggleGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleGroupVariants> &
|
||||||
|
VariantProps<typeof toggleGroupItemVariants>
|
||||||
|
>(({ className, variant, size, children, ...props }, ref) => (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleGroupVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
));
|
||||||
|
|
||||||
|
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ToggleGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleGroupItemVariants>
|
||||||
|
>(({ className, children, variant, size, ...props }, ref) => {
|
||||||
|
const context = React.useContext(ToggleGroupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
toggleGroupItemVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem };
|
||||||
@@ -44,10 +44,10 @@ const DEFAULT_PAGE_LIMIT = 20;
|
|||||||
export function useAgentTypes(params: AgentTypeListParams = {}) {
|
export function useAgentTypes(params: AgentTypeListParams = {}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { page = 1, limit = DEFAULT_PAGE_LIMIT, is_active = true, search } = params;
|
const { page = 1, limit = DEFAULT_PAGE_LIMIT, is_active = true, search, category } = params;
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: agentTypeKeys.list({ page, limit, is_active, search }),
|
queryKey: agentTypeKeys.list({ page, limit, is_active, search, category }),
|
||||||
queryFn: async (): Promise<AgentTypeListResponse> => {
|
queryFn: async (): Promise<AgentTypeListResponse> => {
|
||||||
const response = await apiClient.instance.get('/api/v1/agent-types', {
|
const response = await apiClient.instance.get('/api/v1/agent-types', {
|
||||||
params: {
|
params: {
|
||||||
@@ -55,6 +55,7 @@ export function useAgentTypes(params: AgentTypeListParams = {}) {
|
|||||||
limit,
|
limit,
|
||||||
is_active,
|
is_active,
|
||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
|
...(category ? { category } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -58,9 +65,8 @@ describe('AgentTypeDetail', () => {
|
|||||||
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders description card', () => {
|
it('renders description in hero header', () => {
|
||||||
render(<AgentTypeDetail {...defaultProps} />);
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Designs system architecture and makes technology decisions')
|
screen.getByText('Designs system architecture and makes technology decisions')
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -130,7 +136,7 @@ describe('AgentTypeDetail', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AgentTypeDetail {...defaultProps} />);
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
await user.click(screen.getByRole('button', { name: /back to agent types/i }));
|
||||||
expect(defaultProps.onBack).toHaveBeenCalledTimes(1);
|
expect(defaultProps.onBack).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,4 +217,146 @@ describe('AgentTypeDetail', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText('None configured')).toBeInTheDocument();
|
expect(screen.getByText('None configured')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Hero Header', () => {
|
||||||
|
it('renders hero header with agent name', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { level: 1, name: 'Software Architect' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dynamic icon in hero header', () => {
|
||||||
|
const { container } = render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(container.querySelector('svg.lucide-git-branch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies agent color to hero header gradient', () => {
|
||||||
|
const { container } = render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
const heroHeader = container.querySelector('[style*="linear-gradient"]');
|
||||||
|
expect(heroHeader).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders category badge in hero header', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Development')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows last updated date in hero header', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/Last updated:/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Jan 18, 2025/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Typical Tasks Card', () => {
|
||||||
|
it('renders "What This Agent Does Best" card', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('What This Agent Does Best')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays all typical tasks', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Design system architecture')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create ADRs')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render typical tasks card when empty', () => {
|
||||||
|
render(
|
||||||
|
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, typical_tasks: [] }} />
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('What This Agent Does Best')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Collaboration Hints Card', () => {
|
||||||
|
it('renders "Works Well With" card', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Works Well With')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays collaboration hints as badges', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('backend-engineer')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('frontend-engineer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render collaboration hints card when empty', () => {
|
||||||
|
render(
|
||||||
|
<AgentTypeDetail
|
||||||
|
{...defaultProps}
|
||||||
|
agentType={{ ...mockAgentType, collaboration_hints: [] }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Works Well With')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Category Badge', () => {
|
||||||
|
it('renders category badge with correct label', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Development')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render category badge when category is null', () => {
|
||||||
|
render(
|
||||||
|
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, category: null }} />
|
||||||
|
);
|
||||||
|
// Should not have a Development badge in the hero header area
|
||||||
|
// The word "Development" should not appear
|
||||||
|
expect(screen.queryByText('Development')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Details Card', () => {
|
||||||
|
it('renders details card with slug', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Slug')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('software-architect')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders details card with sort order', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Sort Order')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('40')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders details card with creation date', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Created')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Jan 10, 2025/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dynamic Icon', () => {
|
||||||
|
it('renders fallback icon when icon is null', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, icon: null }} />
|
||||||
|
);
|
||||||
|
// Should fall back to 'bot' icon
|
||||||
|
expect(container.querySelector('svg.lucide-bot')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correct icon based on agent type', () => {
|
||||||
|
const agentWithBrainIcon = { ...mockAgentType, icon: 'brain' };
|
||||||
|
const { container } = render(
|
||||||
|
<AgentTypeDetail {...defaultProps} agentType={agentWithBrainIcon} />
|
||||||
|
);
|
||||||
|
expect(container.querySelector('svg.lucide-brain')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Color Styling', () => {
|
||||||
|
it('applies custom color to instance count', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} />);
|
||||||
|
const instanceCount = screen.getByText('2');
|
||||||
|
expect(instanceCount).toHaveStyle({ color: 'rgb(59, 130, 246)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default color when color is null', () => {
|
||||||
|
render(<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, color: null }} />);
|
||||||
|
// Should still render without errors
|
||||||
|
expect(screen.getByText('Software Architect')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -48,6 +62,10 @@ describe('AgentTypeList', () => {
|
|||||||
onSearchChange: jest.fn(),
|
onSearchChange: jest.fn(),
|
||||||
statusFilter: 'all',
|
statusFilter: 'all',
|
||||||
onStatusFilterChange: jest.fn(),
|
onStatusFilterChange: jest.fn(),
|
||||||
|
categoryFilter: 'all',
|
||||||
|
onCategoryFilterChange: jest.fn(),
|
||||||
|
viewMode: 'grid' as const,
|
||||||
|
onViewModeChange: jest.fn(),
|
||||||
onSelect: jest.fn(),
|
onSelect: jest.fn(),
|
||||||
onCreate: jest.fn(),
|
onCreate: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -194,4 +212,158 @@ describe('AgentTypeList', () => {
|
|||||||
const { container } = render(<AgentTypeList {...defaultProps} className="custom-class" />);
|
const { container } = render(<AgentTypeList {...defaultProps} className="custom-class" />);
|
||||||
expect(container.firstChild).toHaveClass('custom-class');
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Category Filter', () => {
|
||||||
|
it('renders category filter dropdown', () => {
|
||||||
|
render(<AgentTypeList {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('combobox', { name: /filter by category/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "All Categories" as default option', () => {
|
||||||
|
render(<AgentTypeList {...defaultProps} categoryFilter="all" />);
|
||||||
|
expect(screen.getByText('All Categories')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays category badge on agent cards', () => {
|
||||||
|
render(<AgentTypeList {...defaultProps} />);
|
||||||
|
// Both agents have 'development' category
|
||||||
|
const developmentBadges = screen.getAllByText('Development');
|
||||||
|
expect(developmentBadges.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows filter hint in empty state when category filter is applied', () => {
|
||||||
|
render(<AgentTypeList {...defaultProps} agentTypes={[]} categoryFilter="design" />);
|
||||||
|
expect(screen.getByText('Try adjusting your search or filters')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('View Mode Toggle', () => {
|
||||||
|
it('renders view mode toggle buttons', () => {
|
||||||
|
render(<AgentTypeList {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('radio', { name: /grid view/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('radio', { name: /list view/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders grid view by default', () => {
|
||||||
|
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||||
|
// Grid view uses CSS grid
|
||||||
|
expect(container.querySelector('.grid')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders list view when viewMode is list', () => {
|
||||||
|
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||||
|
// List view uses space-y-3 for vertical stacking
|
||||||
|
expect(container.querySelector('.space-y-3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onViewModeChange when grid toggle is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onViewModeChange = jest.fn();
|
||||||
|
render(
|
||||||
|
<AgentTypeList {...defaultProps} viewMode="list" onViewModeChange={onViewModeChange} />
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('radio', { name: /grid view/i }));
|
||||||
|
expect(onViewModeChange).toHaveBeenCalledWith('grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onViewModeChange when list toggle is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onViewModeChange = jest.fn();
|
||||||
|
render(
|
||||||
|
<AgentTypeList {...defaultProps} viewMode="grid" onViewModeChange={onViewModeChange} />
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('radio', { name: /list view/i }));
|
||||||
|
expect(onViewModeChange).toHaveBeenCalledWith('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows list-specific loading skeletons when viewMode is list', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AgentTypeList {...defaultProps} agentTypes={[]} isLoading={true} viewMode="list" />
|
||||||
|
);
|
||||||
|
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('List View', () => {
|
||||||
|
it('shows agent info in list rows', () => {
|
||||||
|
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||||
|
expect(screen.getByText('Product Owner')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Software Architect')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows category badge in list view', () => {
|
||||||
|
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||||
|
const developmentBadges = screen.getAllByText('Development');
|
||||||
|
expect(developmentBadges.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows expertise count in list view', () => {
|
||||||
|
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||||
|
// Both agents have 3 expertise areas
|
||||||
|
const expertiseTexts = screen.getAllByText('3 expertise areas');
|
||||||
|
expect(expertiseTexts.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelect when list row is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = jest.fn();
|
||||||
|
render(<AgentTypeList {...defaultProps} viewMode="list" onSelect={onSelect} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Product Owner'));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('type-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports keyboard navigation on list rows', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = jest.fn();
|
||||||
|
render(<AgentTypeList {...defaultProps} viewMode="list" onSelect={onSelect} />);
|
||||||
|
|
||||||
|
const rows = screen.getAllByRole('button', { name: /view .* agent type/i });
|
||||||
|
rows[0].focus();
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('type-001');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dynamic Icons', () => {
|
||||||
|
it('renders agent icon in grid view', () => {
|
||||||
|
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||||
|
// Check for svg icons with lucide classes
|
||||||
|
const icons = container.querySelectorAll('svg.lucide-clipboard-check, svg.lucide-git-branch');
|
||||||
|
expect(icons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders agent icon in list view', () => {
|
||||||
|
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||||
|
const icons = container.querySelectorAll('svg.lucide-clipboard-check, svg.lucide-git-branch');
|
||||||
|
expect(icons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Color Accent', () => {
|
||||||
|
it('applies color to card border in grid view', () => {
|
||||||
|
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||||
|
const card = container.querySelector('[style*="border-top-color"]');
|
||||||
|
expect(card).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies color to row border in list view', () => {
|
||||||
|
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||||
|
const row = container.querySelector('[style*="border-left-color"]');
|
||||||
|
expect(row).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Category Badge Component', () => {
|
||||||
|
it('does not render category badge when category is null', () => {
|
||||||
|
const agentWithNoCategory: AgentTypeResponse = {
|
||||||
|
...mockAgentTypes[0],
|
||||||
|
category: null,
|
||||||
|
};
|
||||||
|
render(<AgentTypeList {...defaultProps} agentTypes={[agentWithNoCategory]} />);
|
||||||
|
expect(screen.queryByText('Development')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
158
frontend/tests/components/ui/DynamicIcon.test.tsx
Normal file
158
frontend/tests/components/ui/DynamicIcon.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DynamicIcon Component
|
||||||
|
* Verifies dynamic icon rendering by name string
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { DynamicIcon, getAvailableIconNames } from '@/components/ui/dynamic-icon';
|
||||||
|
|
||||||
|
describe('DynamicIcon', () => {
|
||||||
|
describe('Basic Rendering', () => {
|
||||||
|
it('renders an icon by name', () => {
|
||||||
|
render(<DynamicIcon name="bot" data-testid="icon" />);
|
||||||
|
const icon = screen.getByTestId('icon');
|
||||||
|
expect(icon).toBeInTheDocument();
|
||||||
|
expect(icon.tagName).toBe('svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders different icons by name', () => {
|
||||||
|
const { rerender } = render(<DynamicIcon name="code" data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-code');
|
||||||
|
|
||||||
|
rerender(<DynamicIcon name="brain" data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-brain');
|
||||||
|
|
||||||
|
rerender(<DynamicIcon name="shield" data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-shield');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders kebab-case icon names correctly', () => {
|
||||||
|
render(<DynamicIcon name="clipboard-check" data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-clipboard-check');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fallback Behavior', () => {
|
||||||
|
it('renders fallback icon when name is null', () => {
|
||||||
|
render(<DynamicIcon name={null} data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-bot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback icon when name is undefined', () => {
|
||||||
|
render(<DynamicIcon name={undefined} data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-bot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback icon when name is not found', () => {
|
||||||
|
render(<DynamicIcon name="nonexistent-icon" data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-bot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom fallback when specified', () => {
|
||||||
|
render(<DynamicIcon name={null} fallback="code" data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to bot when custom fallback is also invalid', () => {
|
||||||
|
render(<DynamicIcon name="invalid" fallback="also-invalid" data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass('lucide-bot');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Props Forwarding', () => {
|
||||||
|
it('forwards className to icon', () => {
|
||||||
|
render(<DynamicIcon name="bot" className="h-5 w-5 text-primary" data-testid="icon" />);
|
||||||
|
const icon = screen.getByTestId('icon');
|
||||||
|
expect(icon).toHaveClass('h-5');
|
||||||
|
expect(icon).toHaveClass('w-5');
|
||||||
|
expect(icon).toHaveClass('text-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards style to icon', () => {
|
||||||
|
render(<DynamicIcon name="bot" style={{ color: 'red' }} data-testid="icon" />);
|
||||||
|
const icon = screen.getByTestId('icon');
|
||||||
|
expect(icon).toHaveStyle({ color: 'rgb(255, 0, 0)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards aria-hidden to icon', () => {
|
||||||
|
render(<DynamicIcon name="bot" aria-hidden="true" data-testid="icon" />);
|
||||||
|
const icon = screen.getByTestId('icon');
|
||||||
|
expect(icon).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Available Icons', () => {
|
||||||
|
it('includes development icons', () => {
|
||||||
|
const icons = getAvailableIconNames();
|
||||||
|
expect(icons).toContain('clipboard-check');
|
||||||
|
expect(icons).toContain('briefcase');
|
||||||
|
expect(icons).toContain('code');
|
||||||
|
expect(icons).toContain('server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes design icons', () => {
|
||||||
|
const icons = getAvailableIconNames();
|
||||||
|
expect(icons).toContain('palette');
|
||||||
|
expect(icons).toContain('search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes quality icons', () => {
|
||||||
|
const icons = getAvailableIconNames();
|
||||||
|
expect(icons).toContain('shield');
|
||||||
|
expect(icons).toContain('shield-check');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes ai_ml icons', () => {
|
||||||
|
const icons = getAvailableIconNames();
|
||||||
|
expect(icons).toContain('brain');
|
||||||
|
expect(icons).toContain('microscope');
|
||||||
|
expect(icons).toContain('eye');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes data icons', () => {
|
||||||
|
const icons = getAvailableIconNames();
|
||||||
|
expect(icons).toContain('bar-chart');
|
||||||
|
expect(icons).toContain('database');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes domain expert icons', () => {
|
||||||
|
const icons = getAvailableIconNames();
|
||||||
|
expect(icons).toContain('calculator');
|
||||||
|
expect(icons).toContain('heart-pulse');
|
||||||
|
expect(icons).toContain('flask-conical');
|
||||||
|
expect(icons).toContain('lightbulb');
|
||||||
|
expect(icons).toContain('book-open');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes generic icons', () => {
|
||||||
|
const icons = getAvailableIconNames();
|
||||||
|
expect(icons).toContain('bot');
|
||||||
|
expect(icons).toContain('cpu');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Icon Categories Coverage', () => {
|
||||||
|
const iconTestCases = [
|
||||||
|
// Development
|
||||||
|
{ name: 'clipboard-check', expectedClass: 'lucide-clipboard-check' },
|
||||||
|
{ name: 'briefcase', expectedClass: 'lucide-briefcase' },
|
||||||
|
{ name: 'file-text', expectedClass: 'lucide-file-text' },
|
||||||
|
{ name: 'git-branch', expectedClass: 'lucide-git-branch' },
|
||||||
|
{ name: 'layout', expectedClass: 'lucide-panels-top-left' },
|
||||||
|
{ name: 'smartphone', expectedClass: 'lucide-smartphone' },
|
||||||
|
// Operations
|
||||||
|
{ name: 'settings', expectedClass: 'lucide-settings' },
|
||||||
|
{ name: 'settings-2', expectedClass: 'lucide-settings-2' },
|
||||||
|
// AI/ML
|
||||||
|
{ name: 'message-square', expectedClass: 'lucide-message-square' },
|
||||||
|
// Leadership
|
||||||
|
{ name: 'users', expectedClass: 'lucide-users' },
|
||||||
|
{ name: 'target', expectedClass: 'lucide-target' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(iconTestCases)('renders $name icon correctly', ({ name, expectedClass }) => {
|
||||||
|
render(<DynamicIcon name={name} data-testid="icon" />);
|
||||||
|
expect(screen.getByTestId('icon')).toHaveClass(expectedClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class CodeChunker(BaseChunker):
|
|||||||
for struct_type, pattern in patterns.items():
|
for struct_type, pattern in patterns.items():
|
||||||
for match in pattern.finditer(content):
|
for match in pattern.finditer(content):
|
||||||
# Convert character position to line number
|
# Convert character position to line number
|
||||||
line_num = content[:match.start()].count("\n")
|
line_num = content[: match.start()].count("\n")
|
||||||
boundaries.append((line_num, struct_type))
|
boundaries.append((line_num, struct_type))
|
||||||
|
|
||||||
if not boundaries:
|
if not boundaries:
|
||||||
|
|||||||
@@ -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,
|
{
|
||||||
"tokens": self.count_tokens(para_content),
|
"content": para_content,
|
||||||
"start_line": para_start,
|
"tokens": self.count_tokens(para_content),
|
||||||
"end_line": i - 1,
|
"start_line": para_start,
|
||||||
})
|
"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,
|
{
|
||||||
"tokens": self.count_tokens(para_content),
|
"content": para_content,
|
||||||
"start_line": para_start,
|
"tokens": self.count_tokens(para_content),
|
||||||
"end_line": i - 1,
|
"start_line": para_start,
|
||||||
})
|
"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,
|
{
|
||||||
"tokens": self.count_tokens(para_content),
|
"content": para_content,
|
||||||
"start_line": para_start,
|
"tokens": self.count_tokens(para_content),
|
||||||
"end_line": len(lines) - 1,
|
"start_line": para_start,
|
||||||
})
|
"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,
|
{
|
||||||
"tokens": self.count_tokens(para),
|
"content": para,
|
||||||
"start_line": line_num,
|
"tokens": self.count_tokens(para),
|
||||||
"end_line": line_num + para_lines - 1,
|
"start_line": line_num,
|
||||||
})
|
"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,
|
(
|
||||||
embedding,
|
chunk.content,
|
||||||
chunk.chunk_type,
|
embedding,
|
||||||
chunk_metadata,
|
chunk.chunk_type,
|
||||||
))
|
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,
|
{
|
||||||
"description": info["description"],
|
"name": name,
|
||||||
"inputSchema": info["schema"],
|
"description": info["description"],
|
||||||
})
|
"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