feat(validation): v2 LLM dashboard validation — plugin, routes, services

Core implementation of the v2 LLM dashboard validation pipeline:
- LLM plugin with Path A (screenshots) and Path B (logs-only) execution
- Validation task management (CRUD, schedule, run)
- WebSocket task progress with Python 3.13 asyncio fix
- Cross-task runs listing (GET /validation-tasks/runs/all)
- RecordResponse schema for validation records
- JSON prompt helper, per-dashboard status aggregation
- Prompt templates with docs/git-commit/validation presets
- Migration: v2 validation models + description column
- Tests: plugin persistence, prompt templates, batch, payload, url
This commit is contained in:
2026-05-31 22:32:20 +03:00
parent 431330231f
commit d2b53c2a79
29 changed files with 5276 additions and 863 deletions

View File

@@ -0,0 +1,27 @@
"""add_description_to_validation_policy
Revision ID: 7703bbc038bd
Revises: c9d8e7f6a5b4
Create Date: 2026-05-31 21:07:34.291012
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7703bbc038bd'
down_revision: Union[str, Sequence[str], None] = 'c9d8e7f6a5b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add description column to validation_policies."""
op.add_column('validation_policies', sa.Column('description', sa.Text(), nullable=True))
def downgrade() -> None:
"""Remove description column from validation_policies."""
op.drop_column('validation_policies', 'description')

View File

@@ -0,0 +1,373 @@
"""Add v2 LLM validation models (tables, columns, indexes)
Adds:
- validation_sources table
- validation_runs table
- v2 columns to validation_policies
- v2 columns + FK + indexes to llm_validation_results
- index on validation_policies.provider_id
- optional data backfill: creates ValidationRun placeholders for existing
ValidationRecord rows that have a policy_id but no run_id
@ADR [MIGRATION-001] Backfill creates one run per distinct policy_id.
@RATIONALE Existing records were created before the ValidationRun model existed.
Grouping by policy_id avoids creating excessive runs for what is essentially
one historic execution batch per policy. The run status is set to 'completed'
to avoid confusing downstream consumers that filter on 'running'.
Revision ID: c9d8e7f6a5b4
Revises: 2df63b7ce038
Create Date: 2026-05-31 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c9d8e7f6a5b4"
down_revision: str | Sequence[str] | None = "2df63b7ce038"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema to v2 validation models."""
_create_validation_sources()
_create_validation_runs()
_extend_validation_policies()
_extend_validation_results()
_backfill_validation_runs()
def downgrade() -> None:
"""Downgrade schema by removing v2 additions in reverse order."""
# Drop FK constraints from llm_validation_results
op.drop_constraint(
"fk_llm_validation_results_source_id",
"llm_validation_results",
type_="foreignkey",
)
op.drop_constraint(
"fk_llm_validation_results_run_id",
"llm_validation_results",
type_="foreignkey",
)
op.drop_index(
op.f("ix_llm_validation_results_run_id"),
table_name="llm_validation_results",
)
# Remove v2 columns from llm_validation_results
_v2_result_columns = [
"screenshot_paths",
"timings",
"token_usage",
"logs_sent_to_llm",
"tab_screenshots",
"chart_data_results",
"dataset_health",
"execution_path",
"source_id",
"run_id",
]
for col in _v2_result_columns:
op.drop_column("llm_validation_results", col)
# Remove columns + index from validation_policies
op.drop_index(
op.f("ix_validation_policies_provider_id"),
table_name="validation_policies",
)
_v2_policy_columns = [
"source_snapshot",
"policy_dashboard_concurrency_limit",
"llm_batch_size",
"execute_chart_data",
"logs_enabled",
"screenshot_enabled",
"prompt_template",
]
for col in _v2_policy_columns:
op.drop_column("validation_policies", col)
# Drop validation_runs table (index first, then table)
op.drop_index(
op.f("ix_validation_runs_policy_id"),
table_name="validation_runs",
)
op.drop_table("validation_runs")
# Drop validation_sources table (index first, then table)
op.drop_index(
op.f("ix_validation_sources_policy_id"),
table_name="validation_sources",
)
op.drop_table("validation_sources")
# ---------------------------------------------------------------------------
# Upgrade helpers (grouped for readability)
# ---------------------------------------------------------------------------
def _create_validation_sources() -> None:
"""Create the validation_sources table (no FK dependencies)."""
op.create_table(
"validation_sources",
sa.Column("id", sa.String(), nullable=False),
sa.Column("policy_id", sa.String(), nullable=False),
sa.Column("type", sa.String(), nullable=False),
sa.Column("value", sa.String(), nullable=False),
sa.Column("parsed_context", sa.JSON(), nullable=True),
sa.Column(
"status", sa.String(), nullable=False, server_default="valid"
),
sa.Column("resolved_dashboard_id", sa.String(), nullable=True),
sa.Column("title", sa.String(), nullable=True),
sa.Column("last_error", sa.String(), nullable=True),
sa.Column("last_checked_at", sa.DateTime(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["policy_id"],
["validation_policies.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_validation_sources_policy_id"),
"validation_sources",
["policy_id"],
)
def _create_validation_runs() -> None:
"""Create the validation_runs table."""
op.create_table(
"validation_runs",
sa.Column("id", sa.String(), nullable=False),
sa.Column("policy_id", sa.String(), nullable=False),
sa.Column("task_id", sa.String(), nullable=True),
sa.Column(
"started_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column(
"trigger", sa.String(), nullable=False, server_default="manual"
),
sa.Column(
"status", sa.String(), nullable=False, server_default="running"
),
sa.Column(
"dashboard_count",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column(
"pass_count", sa.Integer(), nullable=False, server_default="0"
),
sa.Column(
"warn_count", sa.Integer(), nullable=False, server_default="0"
),
sa.Column(
"fail_count", sa.Integer(), nullable=False, server_default="0"
),
sa.Column(
"unknown_count", sa.Integer(), nullable=False, server_default="0"
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["policy_id"],
["validation_policies.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_validation_runs_policy_id"),
"validation_runs",
["policy_id"],
)
def _extend_validation_policies() -> None:
"""Add v2 columns and index to validation_policies."""
op.add_column(
"validation_policies",
sa.Column("prompt_template", sa.Text(), nullable=True),
)
op.add_column(
"validation_policies",
sa.Column(
"screenshot_enabled",
sa.Boolean(),
nullable=False,
server_default="true",
),
)
op.add_column(
"validation_policies",
sa.Column(
"logs_enabled",
sa.Boolean(),
nullable=False,
server_default="true",
),
)
op.add_column(
"validation_policies",
sa.Column(
"execute_chart_data",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
op.add_column(
"validation_policies",
sa.Column(
"llm_batch_size",
sa.Integer(),
nullable=False,
server_default="1",
),
)
op.add_column(
"validation_policies",
sa.Column(
"policy_dashboard_concurrency_limit",
sa.Integer(),
nullable=False,
server_default="3",
),
)
op.add_column(
"validation_policies",
sa.Column("source_snapshot", sa.JSON(), nullable=True),
)
op.create_index(
op.f("ix_validation_policies_provider_id"),
"validation_policies",
["provider_id"],
)
def _extend_validation_results() -> None:
"""Add v2 columns, FKs, and indexes to llm_validation_results."""
op.add_column(
"llm_validation_results",
sa.Column(
"run_id",
sa.String(),
sa.ForeignKey("validation_runs.id", name="fk_llm_validation_results_run_id"),
nullable=True,
),
)
op.add_column(
"llm_validation_results",
sa.Column(
"source_id",
sa.String(),
sa.ForeignKey("validation_sources.id", name="fk_llm_validation_results_source_id"),
nullable=True,
),
)
op.add_column(
"llm_validation_results",
sa.Column("execution_path", sa.String(), nullable=True),
)
op.add_column(
"llm_validation_results",
sa.Column("dataset_health", sa.JSON(), nullable=True),
)
op.add_column(
"llm_validation_results",
sa.Column("chart_data_results", sa.JSON(), nullable=True),
)
op.add_column(
"llm_validation_results",
sa.Column("tab_screenshots", sa.JSON(), nullable=True),
)
op.add_column(
"llm_validation_results",
sa.Column("logs_sent_to_llm", sa.JSON(), nullable=True),
)
op.add_column(
"llm_validation_results",
sa.Column("token_usage", sa.JSON(), nullable=True),
)
op.add_column(
"llm_validation_results",
sa.Column("timings", sa.JSON(), nullable=True),
)
op.add_column(
"llm_validation_results",
sa.Column("screenshot_paths", sa.JSON(), nullable=True),
)
op.create_index(
op.f("ix_llm_validation_results_run_id"),
"llm_validation_results",
["run_id"],
)
def _backfill_validation_runs() -> None:
"""Create placeholder ValidationRun entries for existing records.
For each distinct policy_id that has ValidationRecord rows without a
run_id, creates one ValidationRun (status='completed') and links all
matching records to it.
Safe no-op when no such rows exist (e.g. fresh database).
"""
conn = op.get_bind()
# Check for existing records that need backfill
result = conn.execute(
sa.text(
"SELECT DISTINCT v.policy_id "
"FROM llm_validation_results v "
"WHERE v.policy_id IS NOT NULL "
"AND v.run_id IS NULL"
)
)
rows = result.fetchall()
if not rows:
return
import uuid
for (policy_id,) in rows:
run_id = str(uuid.uuid4())
conn.execute(
sa.text(
"INSERT INTO validation_runs "
"(id, policy_id, started_at, trigger, status, created_at) "
"VALUES (:run_id, :policy_id, NOW(), 'manual', 'completed', NOW())"
),
{"run_id": run_id, "policy_id": policy_id},
)
conn.execute(
sa.text(
"UPDATE llm_validation_results "
"SET run_id = :run_id "
"WHERE policy_id = :policy_id AND run_id IS NULL"
),
{"run_id": run_id, "policy_id": policy_id},
)

View File

@@ -39,7 +39,7 @@ __all__ = [
"storage",
"tasks",
"translate",
"validation",
"validation_tasks",
]
# #endregion Route_Group_Contracts

View File

@@ -1,9 +1,14 @@
# #region AssistantToolLlmValidation [C:3] [TYPE Module] [SEMANTICS assistant, tool, llm, validation]
# @BRIEF Handler for the "run_llm_validation" tool — run LLM dashboard validation.
# #region AssistantToolLlmValidation [C:3] [TYPE Module] [SEMANTICS assistant, tool, llm, validation, task]
# @BRIEF Handler for the "run_llm_validation" tool — create a validation task (policy) and trigger a run.
# @LAYER API
# @RELATION DEPENDS_ON -> [AssistantToolRegistry]
# @RELATION DEPENDS_ON -> [ValidationTaskService]
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [TaskManager]
# @RATIONALE v2 replaces ad-hoc task dispatch with persistent validation tasks (policies) managed via
# ValidationTaskService. This makes validations repeatable, schedulable, and auditable.
# @REJECTED Ad-hoc task dispatch rejected — single-use tasks cannot be re-triggered, scheduled, or
# tracked across runs. Policy+Run model provides run history and aggregate status.
from __future__ import annotations
@@ -16,7 +21,9 @@ from src.core.config_manager import ConfigManager
from src.core.logger import belief_scope, logger
from src.core.task_manager import TaskManager
from src.schemas.auth import User
from src.schemas.validation import ValidationTaskCreate
from src.services.llm_provider import LLMProviderService
from src.services.validation_service import ValidationTaskService
from ._resolvers import (
_resolve_dashboard_id_entity,
@@ -27,11 +34,11 @@ from ._schemas import AssistantAction
from ._tool_registry import _check_any_permission, assistant_tool
# #region handle_run_llm_validation [C:3] [TYPE Function]
# #region handle_run_llm_validation [C:4] [TYPE Function]
@assistant_tool(
operation="run_llm_validation",
domain="llm",
description="Run LLM dashboard validation by dashboard id/slug/title",
description="Create and run an LLM dashboard validation task — creates a validation policy and triggers an immediate run",
optional_entities=["dashboard_ref", "environment", "provider"],
risk_level="guarded",
requires_confirmation=False,
@@ -45,7 +52,7 @@ async def handle_run_llm_validation(
config_manager: ConfigManager,
db: Session,
) -> tuple[str, str | None, list[AssistantAction]]:
"""Run LLM dashboard validation."""
"""Create a validation task (policy) and trigger an immediate run."""
_check_any_permission(current_user, [("plugin:llm_dashboard_validation", "EXECUTE")])
entities = intent.get("entities", {})
env_token = entities.get("environment")
@@ -66,26 +73,43 @@ async def handle_run_llm_validation(
status_code=422,
detail="Missing dashboard_id/environment/provider. Укажите ID/slug дашборда или окружение.",
)
provider = LLMProviderService(db).get_provider(provider_id)
if not provider or not bool(provider.is_multimodal):
raise HTTPException(
status_code=422,
detail="Selected provider model is not multimodal for dashboard validation. Выберите мультимодальную модель (например, gpt-4o).",
)
task = await task_manager.create_task(
plugin_id="llm_dashboard_validation",
params={
"dashboard_id": str(dashboard_id),
"environment_id": env_id,
"provider_id": provider_id,
},
user_id=current_user.id,
# Create a validation task (policy) via ValidationTaskService
validation_svc = ValidationTaskService(
db=db, config_manager=config_manager, username=current_user.username
)
task_payload = ValidationTaskCreate(
name=f"LLM Validation - Dashboard {dashboard_id}",
environment_id=env_id,
dashboard_ids=[str(dashboard_id)],
provider_id=provider_id,
screenshot_enabled=True,
)
try:
task = validation_svc.create_task(task_payload)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
logger.info(
"[AssistantToolLlmValidation] Created validation task '%s' (id=%s)",
task.name, task.id,
)
# Trigger an immediate run
try:
result = await validation_svc.trigger_run(task.id, task_manager)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
spawned_task_id = result["task_id"]
logger.info(
"[AssistantToolLlmValidation] Triggered run for task '%s' spawned=%s run=%s",
task.name, spawned_task_id, result.get("run_id"),
)
return (
f"LLM-валидация запущена. task_id={task.id}",
task.id,
f"LLM-валидация запущена. task_id={spawned_task_id}",
spawned_task_id,
[
AssistantAction(type="open_task", label="Open Task", target=task.id),
AssistantAction(type="open_task", label="Open Task", target=spawned_task_id),
AssistantAction(
type="open_reports", label="Open Reports", target="/reports"
),

View File

@@ -61,6 +61,14 @@ async def create_task(
current_user=Depends(get_current_user),
config_manager: ConfigManager = Depends(get_config_manager),
):
# Deprecation guard: llm_dashboard_validation moved to POST /api/validation-tasks
if request.plugin_id == "llm_dashboard_validation":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="llm_dashboard_validation is deprecated via POST /api/tasks. "
"Use POST /api/validation-tasks instead.",
)
# Dynamic permission check based on plugin_id
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
with belief_scope("create_task"):

View File

@@ -0,0 +1,479 @@
#region ValidationTasksRoutes [C:4] [TYPE Module] [SEMANTICS fastapi, validation, api, task, run]
# @BRIEF API routes for validation task management — v2 CRUD with run history and Superset URL parsing.
# @LAYER API
# @RELATION DEPENDS_ON -> [ValidationTaskService]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...core.config_manager import ConfigManager
from ...core.database import get_db
from ...core.logger import logger
from ...core.scheduler import SchedulerService
from ...core.task_manager import TaskManager
from ...dependencies import (
get_config_manager,
get_current_user,
get_scheduler_service,
get_task_manager,
has_permission,
)
from ...schemas.auth import User
from ...schemas.validation import (
RecordListResponse,
RunDetailResponse,
RunListResponse,
RunResponse,
TriggerRunResponse,
ValidationTaskCreate,
ValidationTaskListResponse,
ValidationTaskResponse,
ValidationTaskUpdate,
)
from ...services.validation_service import ValidationTaskService
# #region router [C:1] [TYPE Global]
# @BRIEF APIRouter instance for validation task routes.
router = APIRouter(tags=["Validation Tasks"])
# #endregion router
# #region ParseUrlRequest [C:1] [TYPE Class]
# @BRIEF Request body for the parse-superset-url endpoint.
class ParseUrlRequest(BaseModel):
url: str = Field(..., description="Full Superset dashboard URL to parse")
environment_id: str = Field(..., description="Target environment ID")
# #endregion ParseUrlRequest
# #region _get_task_service [C:1] [TYPE Function]
# @BRIEF Dependency injector for ValidationTaskService.
def _get_task_service(
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
current_user: User = Depends(get_current_user),
) -> ValidationTaskService:
return ValidationTaskService(db=db, config_manager=config_manager, username=current_user.username)
# #endregion _get_task_service
# #region _parse_dt [C:1] [TYPE Function]
# @BRIEF Parse ISO datetime string to datetime, or return None.
def _parse_dt(val: str | None) -> datetime | None:
if val is None:
return None
try:
return datetime.fromisoformat(val)
except (ValueError, TypeError):
return None
# #endregion _parse_dt
# #region _dict_to_run_response [C:1] [TYPE Function]
# @BRIEF Convert a run dict from ValidationTaskService to RunResponse model.
def _dict_to_run_response(d: dict[str, Any]) -> RunResponse:
return RunResponse(
id=d["id"],
policy_id=d["policy_id"],
task_id=d.get("task_id"),
status=d["status"],
trigger=d.get("trigger", "manual"),
started_at=_parse_dt(d.get("started_at")),
finished_at=_parse_dt(d.get("finished_at")),
dashboard_count=d.get("dashboard_count", 0),
pass_count=d.get("pass_count", 0),
warn_count=d.get("warn_count", 0),
fail_count=d.get("fail_count", 0),
unknown_count=d.get("unknown_count", 0),
created_at=_parse_dt(d.get("created_at")),
)
# #endregion _dict_to_run_response
# ============================================================
# Tasks CRUD
# ============================================================
# #region create_task [C:3] [TYPE Function]
# @BRIEF Create a new validation task.
# @RELATION CALLS -> [ValidationTaskService.create_task]
# @RELATION CALLS -> [SchedulerService.reload_validation_policy]
@router.post("", response_model=ValidationTaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(
payload: ValidationTaskCreate,
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.task", "CREATE")),
service: ValidationTaskService = Depends(_get_task_service),
scheduler: SchedulerService = Depends(get_scheduler_service),
):
"""Create a new validation task with provider and environment validation."""
logger.reason(
f"create_task — User: {current_user.username}, name: {payload.name}",
extra={"src": "validation_tasks_routes"},
)
try:
result = service.create_task(payload)
scheduler.reload_validation_policy(result.id)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
# #endregion create_task
# #region list_tasks [C:3] [TYPE Function]
# @BRIEF List all validation tasks with pagination and optional filters.
# @RELATION CALLS -> [ValidationTaskService.list_tasks]
@router.get("", response_model=ValidationTaskListResponse)
async def list_tasks(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
is_active: bool | None = Query(None),
environment_id: str | None = Query(None),
search: str | None = Query(None, description="Search by task name"),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.task", "VIEW")),
service: ValidationTaskService = Depends(_get_task_service),
):
"""List all validation tasks with pagination and optional filters."""
logger.reason(
f"list_tasks — User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
total, items = service.list_tasks(
is_active=is_active, environment_id=environment_id, search=search,
page=page, page_size=page_size,
)
return ValidationTaskListResponse(items=items, total=total, page=page, page_size=page_size)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion list_tasks
# #region get_task [C:3] [TYPE Function]
# @BRIEF Get a validation task by policy ID.
# @RELATION CALLS -> [ValidationTaskService.get_task]
@router.get("/{policy_id}", response_model=ValidationTaskResponse)
async def get_task(
policy_id: str,
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.task", "VIEW")),
service: ValidationTaskService = Depends(_get_task_service),
):
"""Get a validation task by ID."""
logger.reason(
f"get_task — Policy: {policy_id}, User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
return service.get_task(policy_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion get_task
# #region update_task [C:3] [TYPE Function]
# @BRIEF Update a validation task.
# @RELATION CALLS -> [ValidationTaskService.update_task]
# @RELATION CALLS -> [SchedulerService.reload_validation_policy]
@router.put("/{policy_id}", response_model=ValidationTaskResponse)
async def update_task(
policy_id: str,
payload: ValidationTaskUpdate,
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.task", "EDIT")),
service: ValidationTaskService = Depends(_get_task_service),
scheduler: SchedulerService = Depends(get_scheduler_service),
):
"""Update a validation task."""
logger.reason(
f"update_task — Policy: {policy_id}, User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
result = service.update_task(policy_id, payload)
scheduler.reload_validation_policy(result.id)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion update_task
# #region delete_task [C:3] [TYPE Function]
# @BRIEF Delete a validation task.
# @RELATION CALLS -> [ValidationTaskService.delete_task]
# @RELATION CALLS -> [SchedulerService.remove_validation_job]
@router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(
policy_id: str,
delete_runs: bool = Query(False, description="Also delete associated validation runs"),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.task", "DELETE")),
service: ValidationTaskService = Depends(_get_task_service),
scheduler: SchedulerService = Depends(get_scheduler_service),
):
"""Delete a validation task."""
logger.reason(
f"delete_task — Policy: {policy_id}, User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
service.delete_task(policy_id, delete_runs=delete_runs)
scheduler.remove_validation_job(policy_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion delete_task
# #region toggle_task_status [C:3] [TYPE Function]
# @BRIEF Toggle a validation task's active status.
# @RELATION CALLS -> [ValidationTaskService.update_task]
# @RELATION CALLS -> [SchedulerService.remove_validation_job]
# @RELATION CALLS -> [SchedulerService.reload_validation_policy]
@router.patch("/{policy_id}/status", response_model=ValidationTaskResponse)
async def toggle_task_status(
policy_id: str,
is_active: bool = Query(..., description="New active status"),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.task", "EDIT")),
service: ValidationTaskService = Depends(_get_task_service),
scheduler: SchedulerService = Depends(get_scheduler_service),
):
"""Toggle a validation task's active status."""
logger.reason(
f"toggle_task_status — Policy: {policy_id}, is_active: {is_active}, User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
result = service.update_task(policy_id, ValidationTaskUpdate(is_active=is_active))
if not is_active:
scheduler.remove_validation_job(policy_id)
else:
scheduler.reload_validation_policy(policy_id)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion toggle_task_status
# ============================================================
# Run — trigger and history
# ============================================================
# #region trigger_run [C:3] [TYPE Function]
# @BRIEF Trigger immediate validation for a task.
# @RELATION CALLS -> [ValidationTaskService.trigger_run]
@router.post("/{policy_id}/run", response_model=TriggerRunResponse)
async def trigger_run(
policy_id: str,
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.task", "EXECUTE")),
service: ValidationTaskService = Depends(_get_task_service),
task_manager: TaskManager = Depends(get_task_manager),
):
"""Trigger immediate validation for a task."""
logger.reason(
f"trigger_run — Policy: {policy_id}, User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
result = await service.trigger_run(policy_id, task_manager)
return TriggerRunResponse(
task_id=policy_id,
spawned_task_id=result["task_id"],
run_id=result.get("run_id"),
status="PENDING",
message="Validation task spawned",
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
# #endregion trigger_run
# #region list_all_runs [C:3] [TYPE Function]
# @BRIEF List validation runs across all tasks with optional filters.
# @RELATION CALLS -> [ValidationTaskService.list_all_runs]
@router.get("/runs/all", response_model=RecordListResponse)
async def list_all_runs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status_filter: str | None = Query(None, alias="status", description="Filter by status"),
environment_id: str | None = Query(None, description="Filter by environment"),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.run", "VIEW")),
service: ValidationTaskService = Depends(_get_task_service),
):
"""List validation runs across all tasks with pagination and optional filters."""
logger.reason(
f"list_all_runs — User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
total, raw_items = service.list_all_runs(
status=status_filter, environment_id=environment_id, page=page, page_size=page_size,
)
return RecordListResponse(items=raw_items, total=total, page=page, page_size=page_size)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion list_all_runs
# #region list_runs [C:3] [TYPE Function]
# @BRIEF List run history for a validation task.
# @RELATION CALLS -> [ValidationTaskService.get_run_history]
@router.get("/{policy_id}/runs", response_model=RunListResponse)
async def list_runs(
policy_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.run", "VIEW")),
service: ValidationTaskService = Depends(_get_task_service),
):
"""List run history for a validation task."""
logger.reason(
f"list_runs — Policy: {policy_id}, User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
total, raw_runs = service.get_run_history(policy_id, page=page, page_size=page_size)
items = [_dict_to_run_response(r) for r in raw_runs]
return RunListResponse(items=items, total=total, page=page, page_size=page_size)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion list_runs
# #region get_run_detail [C:3] [TYPE Function]
# @BRIEF Get full run detail with all records for a validation task.
# @RELATION CALLS -> [ValidationTaskService.get_run_detail]
@router.get("/{policy_id}/runs/{run_id}", response_model=RunDetailResponse)
async def get_run_detail(
policy_id: str,
run_id: str,
current_user: User = Depends(get_current_user),
_=Depends(has_permission("validation.run", "VIEW")),
service: ValidationTaskService = Depends(_get_task_service),
):
"""Get full run detail with all records."""
logger.reason(
f"get_run_detail — Policy: {policy_id}, Run: {run_id}, User: {current_user.username}",
extra={"src": "validation_tasks_routes"},
)
try:
result = service.get_run_detail(run_id)
return RunDetailResponse(
run=_dict_to_run_response(result["run"]),
records=result.get("records", []),
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion get_run_detail
# ============================================================
# Utilities
# ============================================================
# #region parse_superset_url [C:3] [TYPE Function]
# @BRIEF Parse a Superset dashboard URL and extract dashboard context, native filters, and recovery info.
# @RELATION CALLS -> [SupersetContextExtractor.parse_superset_link]
@router.post("/parse-url")
async def parse_superset_url(
payload: ParseUrlRequest,
current_user: User = Depends(get_current_user),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Parse a Superset dashboard URL and extract context."""
logger.reason(
f"parse_url — User: {current_user.username}, env: {payload.environment_id}",
extra={"src": "validation_tasks_routes"},
)
env = config_manager.get_environment(payload.environment_id)
if not env:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Environment '{payload.environment_id}' not found",
)
try:
from ...core.utils.superset_context_extractor import SupersetContextExtractor
extractor = SupersetContextExtractor(environment=env)
parsed = extractor.parse_superset_link(payload.url)
dashboard_id = str(parsed.dashboard_id) if parsed.dashboard_id is not None else None
title: str | None = None
# Resolve dashboard title from Superset API when possible
if parsed.dashboard_id:
try:
dashboard = extractor.client.get_dashboard_detail(parsed.dashboard_id)
title = dashboard.get("dashboard_title", str(parsed.dashboard_id))
except Exception:
title = str(parsed.dashboard_id)
elif parsed.chart_id:
title = f"chart:{parsed.chart_id}"
query_state = parsed.query_state or {}
native_filters = query_state.get("native_filters")
active_tabs = query_state.get("activeTabs")
anchor = query_state.get("anchor")
return {
"dashboard_id": dashboard_id,
"title": title,
"native_filters": native_filters,
"activeTabs": active_tabs,
"anchor": anchor,
"partial_recovery": parsed.partial_recovery,
"warnings": list(parsed.unresolved_references) if parsed.unresolved_references else [],
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
except Exception as e:
logger.error(
f"parse_url failed: {e}",
extra={"src": "validation_tasks_routes"},
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
# #endregion parse_superset_url
# #region legacy_llm_report_redirect [C:2] [TYPE Function]
# @BRIEF Legacy redirect: /reports/llm/{taskId} → /validation-tasks/{policy_id}/runs/{run_id}
# @RELATION CALLS -> [ValidationRun]
@router.get("/reports/llm/{task_id}")
async def legacy_llm_report_redirect(
task_id: str,
db: Session = Depends(get_db),
):
"""
Legacy redirect: /reports/llm/{taskId} → /validation-tasks/{policy_id}/runs/{run_id}
Returns 302 redirect if task_id maps to a ValidationRun.task_id.
Returns 404 if no match.
"""
from ...models.llm import ValidationRun
run = db.query(ValidationRun).filter(ValidationRun.task_id == task_id).first()
if not run:
raise HTTPException(status_code=404, detail="LLM validation run not found for this task")
from fastapi.responses import RedirectResponse
target = f"/validation-tasks/{run.policy_id}/runs/{run.id}"
return RedirectResponse(url=target, status_code=302)
# #endregion legacy_llm_report_redirect
# #endregion ValidationTasksRoutes

View File

@@ -25,6 +25,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
from typing import Any
from alembic import command as alembic_command
@@ -52,8 +53,8 @@ from .api.routes import (
storage,
tasks,
translate,
validation,
)
from .api.routes.validation_tasks import router as validation_tasks
from .core.auth.security import get_password_hash
from .core.cot_logger import seed_trace_id
from .core.database import AuthSessionLocal, init_db
@@ -341,7 +342,6 @@ async def log_requests(request: Request, call_next):
# @RELATION DEPENDS_ON -> [ReportsRouter]
# @RELATION DEPENDS_ON -> [LlmRoutes]
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api]
# @RELATION DEPENDS_ON -> [ValidationRoutes]
# @RELATION DEPENDS_ON -> [MaintenanceRouter]
# Include API routes
app.include_router(auth.router)
@@ -366,7 +366,7 @@ app.include_router(profile.router)
app.include_router(dataset_review.router)
app.include_router(health.router)
app.include_router(translate.router)
app.include_router(validation.router, prefix="/api/validation", tags=["Validation"])
app.include_router(validation_tasks, prefix="/api/validation-tasks", tags=["Validation Tasks"])
app.include_router(maintenance.maintenance_router)
# #endregion API_Routes
# #region api.include_routers [C:1] [TYPE Action] [SEMANTICS routes, registration, api]
@@ -590,8 +590,10 @@ async def websocket_endpoint(
# ── Main loop: listen on both log and status queues ──
while True:
log_task = asyncio.create_task(log_queue.get())
status_task = asyncio.create_task(status_queue.get())
done, _ = await asyncio.wait(
[log_queue.get(), status_queue.get()],
[log_task, status_task],
return_when=asyncio.FIRST_COMPLETED,
)
for coro in done:
@@ -634,7 +636,11 @@ async def websocket_endpoint(
await asyncio.sleep(2)
except (WebSocketDisconnect, StopIteration):
if isinstance(StopIteration):
pass # normal termination
# Task reached terminal state — close cleanly with code 1000
try:
await websocket.close(code=1000, reason="Task completed")
except Exception:
pass
logger.reason(
"WebSocket client disconnected or stream ended",
extra={"task_id": task_id},

View File

@@ -134,6 +134,13 @@ class GlobalSettings(BaseModel):
ff_dataset_clarification: bool = True
ff_dataset_execution: bool = True
# LLM validation retention (days)
LLM_SCREENSHOT_RETENTION_DAYS: int = 30
LLM_RAW_RESPONSE_RETENTION_DAYS: int = 30
# Global worker limit for concurrent validation runs
GLOBAL_VALIDATION_WORKER_LIMIT: int = 3
# #endregion GlobalSettings

View File

@@ -1,5 +1,5 @@
# #region CotLoggerModule [C:4] [TYPE Module] [SEMANTICS logging, cot, json, trace, structured]
# @BRIEF Structured JSON logger implementing the molecular CoT (Chain-of-Thought) logging protocol.
# @BRIEF Structured JSON logger implementing the decision audit logging protocol.
# Uses ContextVar for trace_id and span_id propagation across async contexts.
# Provides log(), MarkerLogger, seed_trace_id(), push_span(), pop_span().
# @LAYER Core
@@ -22,7 +22,7 @@ _span_id: ContextVar[str] = ContextVar("_span_id", default="")
# #endregion cot_trace_context
# #region cot_logger_instance [C:1] [TYPE Data] [SEMANTICS logger, instance]
# @BRIEF Dedicated Python logger for all CoT (molecular) log output.
# @BRIEF Dedicated Python logger for all decision audit log output.
cot_logger = logging.getLogger("cot")
# #endregion cot_logger_instance

View File

@@ -1,5 +1,5 @@
# #region LoggerModule [C:5] [TYPE Module] [SEMANTICS pydantic, logging, json, formatter, structured]
# @BRIEF Application logging system with CotJsonFormatter producing molecular CoT JSON output.
# @BRIEF Application logging system with CotJsonFormatter producing decision audit JSON output.
# @LAYER Core
# @RELATION CALLED_BY -> [LoggerModule]
# @RELATION DEPENDS_ON -> [CotLoggerModule]
@@ -20,7 +20,7 @@
# confuse log consumers).
# @REJECTED Keeping propagate=True was rejected after uvicorn's dictConfig added a root logger handler
# that duplicates every log as plain text (see [ADR LOG-002]).
# @DATA_CONTRACT All log records conform to molecular CoT JSON schema: {ts, level, trace_id, src, marker, intent, span_id?, payload?, error?}
# @DATA_CONTRACT All log records conform to decision audit JSON schema: {ts, level, trace_id, src, marker, intent, span_id?, payload?, error?}
# @INVARIANT CotJsonFormatter.format() always returns valid single-line JSON string.
from contextlib import contextmanager
from datetime import UTC, datetime
@@ -47,10 +47,10 @@ _task_log_level = "INFO"
# #region CotJsonFormatter [C:3] [TYPE Class] [SEMANTICS logging,formatter,json,cot,protocol]
# @BRIEF JSON formatter implementing the molecular CoT logging protocol. Outputs single-line JSON with ts, level, trace_id, src, marker, intent, payload, error, span_id.
# @BRIEF JSON formatter implementing the decision audit logging protocol. Outputs single-line JSON with ts, level, trace_id, src, marker, intent, payload, error, span_id.
# @RELATION IMPLEMENTS -> [CotJsonFormatter]
class CotJsonFormatter(logging.Formatter):
"""JSON formatter implementing the molecular CoT logging protocol.
"""JSON formatter implementing the decision audit logging protocol.
Reads structured data from the LogRecord's extra attributes (marker, intent, payload,
error, src) set via the ``extra`` kwarg. Falls back to plain message wrapping when no
@@ -72,7 +72,7 @@ class CotJsonFormatter(logging.Formatter):
"""
# #region CotJsonFormatter.format [C:2] [TYPE Function] [SEMANTICS format,json,record]
# @BRIEF Format a LogRecord into a single-line CoT JSON string with molecular CoT protocol fields.
# @BRIEF Format a LogRecord into a single-line CoT JSON string with decision audit protocol fields.
# @INVARIANT Output is always valid single-line JSON.
def format(self, record):
# Try to extract structured data from extra kwargs on the record
@@ -143,7 +143,7 @@ class LogEntry(BaseModel):
# #region belief_scope [C:3] [TYPE Function] [SEMANTICS logging,context,belief_state,cot]
# @BRIEF Context manager for Molecular CoT structured logging. Uses cot_logger.log() for JSON output.
# @BRIEF Context manager for decision audit structured logging. Uses cot_logger.log() for JSON output.
# @PRE anchor_id must be provided.
# @POST Thread-local belief state is updated; REASON/REFLECT/EXPLORE markers emitted via structured JSON logger.
@contextmanager
@@ -305,7 +305,7 @@ def explore(self, msg, *args, **kwargs):
# #region reason [C:2] [TYPE Function] [SEMANTICS log,reason,marker,info]
# @BRIEF Logs a REASON marker (INFO level) with structured extra data for CotJsonFormatter.
# @RATIONALE INFO level per molecular CoT protocol: REASON is the primary reasoning bond
# @RATIONALE INFO level per decision audit protocol: REASON is the primary reasoning bond
# and must be visible in production. DEBUG is reserved for high-frequency loops.
def reason(self, msg, *args, **kwargs):
"""Log a REASON marker — strict deduction, core logic.
@@ -324,7 +324,7 @@ def reason(self, msg, *args, **kwargs):
# #region reflect [C:2] [TYPE Function] [SEMANTICS log,reflect,marker,info]
# @BRIEF Logs a REFLECT marker (INFO level) with structured extra data for CotJsonFormatter.
# @RATIONALE INFO level per molecular CoT protocol: REFLECT verifies outcomes and
# @RATIONALE INFO level per decision audit protocol: REFLECT verifies outcomes and
# must be visible in production. DEBUG is reserved for high-frequency loops.
def reflect(self, msg, *args, **kwargs):
"""Log a REFLECT marker — self-check, structural validation.

View File

@@ -15,7 +15,7 @@ from src.core.logger import belief_scope, configure_logger, get_task_log_level,
def _marker(record):
"""Get molecular CoT marker from a log record (set via extra dict)."""
"""Get decision audit marker from a log record (set via extra dict)."""
return getattr(record, 'marker', None)
@@ -127,7 +127,7 @@ def test_belief_scope_success_reflect(caplog):
def test_belief_scope_not_visible_at_info(caplog):
"""Test that belief_scope REFLECT logs are NOT visible at INFO level.
NOTE: Molecular CoT protocol: belief_scope uses cot_log(level='DEBUG')
NOTE: Decision audit protocol: belief_scope uses cot_log(level='DEBUG')
for both REASON (entry) and REFLECT (exit). At INFO level, neither
should appear in caplog. The inline logger.info() IS visible because
it goes through superset_tools_app at INFO.
@@ -139,7 +139,7 @@ def test_belief_scope_not_visible_at_info(caplog):
log_messages = [record.message for record in log_records]
# Inline INFO log should be visible
assert any("Doing something important" in msg for msg in log_messages), "INFO log not found"
# REASON uses DEBUG level in molecular CoT → NOT visible at INFO
# REASON uses DEBUG level in decision audit → NOT visible at INFO
assert not any(_marker(r) == "REASON" for r in log_records), "REASON is DEBUG-level, should NOT be visible at INFO"
# REFLECT uses DEBUG level → NOT visible at INFO level
assert not any(_marker(r) == "REFLECT" for r in log_records), "REFLECT is DEBUG-level, should NOT be visible at INFO"

View File

@@ -0,0 +1,3 @@
# #region MigrationsPackage [C:1] [TYPE Module] [SEMANTICS migration,package]
# @BRIEF Data migration package for llm_analysis plugin.
# #endregion MigrationsPackage

View File

@@ -0,0 +1,196 @@
# #region V1ToV2Migration [C:3] [TYPE Module] [SEMANTICS migration,validation,data,idempotent]
# @BRIEF Migration script: v1 → v2 ValidationPolicy data. Converts existing dashboard_ids
# JSON arrays to ValidationSource relational rows.
# @PRE Database has both validation_policies and validation_sources tables.
# @POST Existing v1 dashboard_ids are converted to ValidationSource rows.
# source_snapshot is set on each policy with the original dashboard_ids list.
# @SIDE_EFFECT Creates ValidationSource rows and updates source_snapshot on ValidationPolicy.
# @INVARIANT Idempotent — policies that already have sources are skipped.
# @RELATION DEPENDS_ON -> [ValidationPolicy]
# @RELATION DEPENDS_ON -> [ValidationSource]
"""
Migration script: v1 → v2 ValidationPolicy data.
Converts existing dashboard_ids JSON arrays to ValidationSource relational rows.
Safe to run multiple times (idempotent — skips policies that already have sources).
Usage::
cd backend && source .venv/bin/activate
python -m src.plugins.llm_analysis.migrations.v1_to_v2
"""
from datetime import UTC, datetime
from typing import Optional
def migrate_v1_to_v2(db_session=None):
"""Convert v1 ``dashboard_ids`` on ``ValidationPolicy`` → ``ValidationSource`` rows.
Idempotent: skips policies that already have ``ValidationSource`` children.
Sets ``source_snapshot`` to the original ``dashboard_ids`` list.
Args:
db_session: SQLAlchemy session. If ``None``, creates one via ``SessionLocal``.
Side effects:
- Creates ``ValidationSource`` rows (type="dashboard_id") for each dashboard_id.
- Updates ``source_snapshot`` on each migrated policy.
Returns:
int: Number of policies migrated.
"""
from ....models.llm import ValidationPolicy, ValidationSource
if db_session is None:
from ....core.database import SessionLocal
db_session = SessionLocal()
own_session = True
else:
own_session = False
try:
# Find all v1 policies that have dashboard_ids and don't already have sources
policies = (
db_session.query(ValidationPolicy)
.filter(
ValidationPolicy.dashboard_ids.isnot(None),
ValidationPolicy.dashboard_ids != "[]",
ValidationPolicy.dashboard_ids != [],
)
.all()
)
migrated_count = 0
for policy in policies:
# Skip if already migrated (has sources)
existing_sources = (
db_session.query(ValidationSource)
.filter(ValidationSource.policy_id == policy.id)
.count()
)
if existing_sources > 0:
continue
# Parse dashboard_ids (could be JSON string or already a list)
dashboard_ids = policy.dashboard_ids
if isinstance(dashboard_ids, str):
import json
dashboard_ids = json.loads(dashboard_ids)
if not dashboard_ids or not isinstance(dashboard_ids, list):
continue
# Create ValidationSource rows
for dashboard_id in dashboard_ids:
source = ValidationSource(
type="dashboard_id",
value=str(dashboard_id),
status="valid",
policy_id=policy.id,
)
db_session.add(source)
# Set source_snapshot with the original dashboard_ids list
policy.source_snapshot = {
"migrated_at": datetime.now(UTC).isoformat(),
"original_dashboard_ids": dashboard_ids,
"migration_version": "v1_to_v2",
}
migrated_count += 1
db_session.commit()
return migrated_count
finally:
if own_session:
db_session.close()
def dry_run(db_session=None):
"""Preview how many policies would be migrated without making changes.
Args:
db_session: SQLAlchemy session.
Returns:
list[dict]: Policies that would be migrated (id, name, dashboard_ids).
"""
from ....models.llm import ValidationPolicy, ValidationSource
if db_session is None:
from ....core.database import SessionLocal
db_session = SessionLocal()
own_session = True
else:
own_session = False
try:
policies = (
db_session.query(ValidationPolicy)
.filter(
ValidationPolicy.dashboard_ids.isnot(None),
ValidationPolicy.dashboard_ids != "[]",
ValidationPolicy.dashboard_ids != [],
)
.all()
)
result = []
for policy in policies:
existing_sources = (
db_session.query(ValidationSource)
.filter(ValidationSource.policy_id == policy.id)
.count()
)
if existing_sources == 0:
dashboard_ids = policy.dashboard_ids
if isinstance(dashboard_ids, str):
import json
dashboard_ids = json.loads(dashboard_ids)
result.append({
"id": policy.id,
"name": policy.name,
"dashboard_ids": dashboard_ids,
"source_count": len(dashboard_ids) if dashboard_ids else 0,
})
return result
finally:
if own_session:
db_session.close()
if __name__ == "__main__":
import sys
from ....core.database import SessionLocal
args = set(sys.argv[1:])
if "--dry-run" in args or "-n" in args:
db = SessionLocal()
try:
pending = dry_run(db)
if not pending:
print("No v1 policies pending migration.")
else:
print(f"Would migrate {len(pending)} policies:")
for p in pending:
print(f" [{p['id']}] {p['name']}{p['source_count']} source(s)")
finally:
db.close()
else:
db = SessionLocal()
try:
count = migrate_v1_to_v2(db)
if count == 0:
print("No v1 policies needed migration (all up-to-date).")
else:
print(f"Migration complete. {count} policy/policies migrated.")
finally:
db.close()
# #endregion V1ToV2Migration

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,18 +4,55 @@
# @RELATION DEPENDS_ON -> [EXT:Library:pydantic]
from datetime import datetime
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
# #region ValidationTaskCreate [C:1] [TYPE Class]
# @BRIEF Schema for creating a new validation task (policy).
# #region SourceInput [C:1] [TYPE Class]
# @BRIEF Schema for creating a validation source from a dashboard ID or Superset URL.
# @RATIONALE v2 introduces typed source objects allowing both dashboard_id (existing numeric
# reference) and dashboard_url (full Superset link that gets parsed/extracted) inputs.
# @REJECTED Keeping only dashboard_id rejected — dashboard_url enables sharing Superset links
# directly without first resolving numeric IDs manually.
class SourceInput(BaseModel):
type: Literal["dashboard_id", "dashboard_url"] = Field(
..., description="Source type: 'dashboard_id' for numeric IDs, 'dashboard_url' for full Superset URLs"
)
value: str = Field(..., description="Source value: numeric dashboard ID or full Superset dashboard URL")
# #endregion SourceInput
# #region SourceResponse [C:1] [TYPE Class]
# @BRIEF Schema for displaying a validation source in API responses.
class SourceResponse(BaseModel):
id: str
type: str
value: str
status: str = "valid"
title: str | None = None
resolved_dashboard_id: str | None = None
last_error: str | None = None
last_checked_at: datetime | None = None
created_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
# #endregion SourceResponse
# #region ValidationTaskCreate [C:2] [TYPE Class]
# @BRIEF Schema for creating a new validation task (policy). v2 adds sources, screenshot_enabled,
# logs_enabled, execute_chart_data, llm_batch_size, concurrency_limit, and prompt_template.
# @RATIONALE All v2 fields are optional to support gradual migration. dashboard_ids is kept for
# backward compat — when non-empty and sources is empty, auto-create ValidationSource rows.
# @REJECTED Breaking dashboard_ids removal rejected — existing UI callers and API clients still
# send dashboard_ids; removing it would break migration without frontend changes.
class ValidationTaskCreate(BaseModel):
name: str = Field(..., description="Human-readable task name")
description: str | None = Field(None, description="Optional task description")
environment_id: str = Field(..., description="Target Superset environment ID")
dashboard_ids: list[str] = Field(..., description="List of dashboard IDs to validate (use single-element array for v1)")
provider_id: str = Field(..., description="LLM provider ID — must be multimodal")
dashboard_ids: list[str] = Field(default_factory=list, description="List of dashboard IDs (v1 compat — auto-creates ValidationSource rows if non-empty)")
provider_id: str = Field(..., description="LLM provider ID — must be multimodal when screenshot_enabled=True")
schedule_days: list[int] | None = Field(None, description="Days of week (0-6, 0=Sunday). Omit or empty for manual-only.")
window_start: str | None = Field(None, description="Window start time, HH:MM format")
window_end: str | None = Field(None, description="Window end time, HH:MM format")
@@ -23,13 +60,25 @@ class ValidationTaskCreate(BaseModel):
custom_channels: list[str] | None = Field(None, description="Additional notification channels")
alert_condition: str = Field("FAIL_ONLY", description="FAIL_ONLY, WARN_AND_FAIL, or ALWAYS")
is_active: bool = Field(True, description="Whether the task is active")
# v2 fields (all optional for gradual migration)
screenshot_enabled: bool = Field(True, description="Enable screenshot-based validation (Path A)")
logs_enabled: bool = Field(True, description="Enable log collection during validation")
execute_chart_data: bool = Field(False, description="Execute chart data queries during validation")
llm_batch_size: int = Field(1, description="Number of dashboards to send in a single LLM batch", ge=1)
policy_dashboard_concurrency_limit: int = Field(3, description="Max concurrent dashboard validations for this policy", ge=1)
prompt_template: str | None = Field(None, description="Custom prompt template override")
sources: list[SourceInput] | None = Field(None, description="v2 source definitions (takes precedence over dashboard_ids)")
# #endregion ValidationTaskCreate
# #region ValidationTaskUpdate [C:1] [TYPE Class]
# @BRIEF Schema for updating an existing validation task (policy).
# #region ValidationTaskUpdate [C:2] [TYPE Class]
# @BRIEF Schema for updating an existing validation task (policy). Includes v2 fields.
# @RATIONALE All fields optional (partial update). When sources is provided, replaces all existing
# ValidationSource rows for the policy.
class ValidationTaskUpdate(BaseModel):
name: str | None = None
description: str | None = None
environment_id: str | None = None
dashboard_ids: list[str] | None = None
provider_id: str | None = None
@@ -40,14 +89,24 @@ class ValidationTaskUpdate(BaseModel):
custom_channels: list[str] | None = None
alert_condition: str | None = None
is_active: bool | None = None
# v2 fields
screenshot_enabled: bool | None = None
logs_enabled: bool | None = None
execute_chart_data: bool | None = None
llm_batch_size: int | None = None
policy_dashboard_concurrency_limit: int | None = None
prompt_template: str | None = None
sources: list[SourceInput] | None = Field(None, description="Replace all sources when provided")
# #endregion ValidationTaskUpdate
# #region ValidationTaskResponse [C:1] [TYPE Class]
# @BRIEF Schema for validation task API responses — includes last run status from join.
# #region ValidationTaskResponse [C:2] [TYPE Class]
# @BRIEF Schema for validation task API responses — includes last run info and v2 fields.
class ValidationTaskResponse(BaseModel):
id: str
name: str
description: str | None = Field(None, description="Optional task description")
environment_id: str
is_active: bool
dashboard_ids: list[str]
@@ -62,6 +121,16 @@ class ValidationTaskResponse(BaseModel):
updated_at: datetime | None = None
last_run_status: str | None = None
last_run_at: datetime | None = None
last_run_summary: dict | None = Field(None, description="Aggregated counts: {pass: N, warn: N, fail: N}")
# v2 fields
screenshot_enabled: bool = True
logs_enabled: bool = True
execute_chart_data: bool = False
llm_batch_size: int = 1
policy_dashboard_concurrency_limit: int = 3
prompt_template: str | None = None
sources: list[SourceResponse] = Field(default_factory=list, description="Resolved validation sources")
model_config = ConfigDict(from_attributes=True)
# #endregion ValidationTaskResponse
@@ -77,61 +146,92 @@ class ValidationTaskListResponse(BaseModel):
# #endregion ValidationTaskListResponse
# #region ValidationRunResponse [C:1] [TYPE Class]
# @BRIEF Schema for validation run history listing.
class ValidationRunResponse(BaseModel):
id: str
task_id: str | None = None
dashboard_id: str
environment_id: str | None = None
status: str
summary: str = ""
timestamp: datetime | None = None
screenshot_path: str | None = None
task_name: str | None = None # resolved from ValidationPolicy at query time
issues_count: int = 0 # number of issues found during validation
model_config = ConfigDict(from_attributes=True)
# #endregion ValidationRunResponse
# #region ValidationRunListResponse [C:1] [TYPE Class]
# @BRIEF Schema for paginated run list response.
class ValidationRunListResponse(BaseModel):
items: list[ValidationRunResponse] = []
total: int = 0
page: int = 1
page_size: int = 20
# #endregion ValidationRunListResponse
# #region ValidationRunDetailResponse [C:1] [TYPE Class]
# @BRIEF Schema for full run detail including parsed issues and raw response.
class ValidationRunDetailResponse(BaseModel):
id: str
task_id: str | None = None
dashboard_id: str
environment_id: str | None = None
status: str
summary: str = ""
timestamp: datetime | None = None
screenshot_path: str | None = None
issues: list[dict[str, Any]] = Field(default_factory=list)
raw_response: str | None = None
task_name: str | None = None
model_config = ConfigDict(from_attributes=True)
# #endregion ValidationRunDetailResponse
# #region TriggerRunResponse [C:1] [TYPE Class]
# @BRIEF Schema for trigger-run response — returns spawned task ID.
# @BRIEF Schema for trigger-run response — returns spawned task ID and run ID.
class TriggerRunResponse(BaseModel):
task_id: str
spawned_task_id: str
run_id: str | None = Field(None, description="ValidationRun ID (v2)")
status: str = "PENDING"
message: str = "Validation task spawned"
# #endregion TriggerRunResponse
# #region RunResponse [C:1] [TYPE Class]
# @BRIEF Schema for v2 ValidationRun display (aggregate run, not individual record).
class RunResponse(BaseModel):
id: str
policy_id: str
task_id: str | None = None
status: str
trigger: str = "manual"
started_at: datetime | None = None
finished_at: datetime | None = None
dashboard_count: int = 0
pass_count: int = 0
warn_count: int = 0
fail_count: int = 0
unknown_count: int = 0
created_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
# #endregion RunResponse
# #region RunListResponse [C:1] [TYPE Class]
# @BRIEF Schema for paginated v2 run list response.
class RunListResponse(BaseModel):
items: list[RunResponse] = []
total: int = 0
page: int = 1
page_size: int = 20
# #endregion RunListResponse
# #region RecordResponse [C:1] [TYPE Class]
# @BRIEF Schema for a single ValidationRecord in cross-task runs listing.
class RecordResponse(BaseModel):
id: str
policy_id: str | None = None
run_id: str | None = None
dashboard_id: str
dashboard_title: str | None = None
environment_id: str | None = None
status: str
summary: str = ""
timestamp: datetime | None = None
execution_path: str | None = None
screenshot_path: str | None = None
screenshot_paths: list[str] = Field(default_factory=list)
issues: list[dict[str, Any]] = Field(default_factory=list)
raw_response: str | None = None
dataset_health: dict[str, Any] | None = None
chart_data_results: list[dict[str, Any]] = Field(default_factory=list)
tab_screenshots: list[dict[str, Any]] = Field(default_factory=list)
logs_sent_to_llm: list[dict[str, Any]] = Field(default_factory=list)
token_usage: dict[str, Any] | None = None
timings: dict[str, Any] | None = None
model_config = ConfigDict(from_attributes=True)
# #endregion RecordResponse
# #region RecordListResponse [C:1] [TYPE Class]
# @BRIEF Schema for paginated cross-task runs listing.
class RecordListResponse(BaseModel):
items: list[RecordResponse] = []
total: int = 0
page: int = 1
page_size: int = 20
# #endregion RecordListResponse
# #region RunDetailResponse [C:1] [TYPE Class]
# @BRIEF Schema for v2 run detail — ValidationRun + all its ValidationRecords.
class RunDetailResponse(BaseModel):
run: RunResponse
records: list[dict[str, Any]] = Field(default_factory=list)
# #endregion RunDetailResponse
# #endregion ValidationSchemas

View File

@@ -102,7 +102,7 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
return None
async def capture_dashboard(self, _dashboard_id, _screenshot_path):
return None
return [], []
# endregion _FakeScreenshotService

View File

@@ -90,10 +90,10 @@ def test_is_multimodal_model_detects_known_vision_models():
def test_resolve_bound_provider_id_prefers_binding_then_default():
settings = {
"default_provider": "default-1",
"provider_bindings": {"dashboard_validation": "vision-1"},
"provider_bindings": {"documentation": "doc-1"},
}
assert resolve_bound_provider_id(settings, "dashboard_validation") == "vision-1"
assert resolve_bound_provider_id(settings, "documentation") == "default-1"
assert resolve_bound_provider_id(settings, "documentation") == "doc-1"
assert resolve_bound_provider_id(settings, "git_commit") == "default-1"
# endregion test_resolve_bound_provider_id_prefers_binding_then_default

View File

@@ -15,7 +15,7 @@ from ..core.logger import logger
# #region DEFAULT_LLM_PROMPTS [C:2] [TYPE Constant]
# @BRIEF Default prompt templates used by documentation, dashboard validation, and git commit generation.
DEFAULT_LLM_PROMPTS: dict[str, str] = {
"dashboard_validation_prompt": (
"dashboard_validation_prompt": ( # Legacy v1 — deprecated for new tasks, kept for backward compat
"Analyze the attached dashboard screenshot and the following execution logs for health and visual issues.\n\n"
"Logs:\n"
"{logs}\n\n"
@@ -32,6 +32,46 @@ DEFAULT_LLM_PROMPTS: dict[str, str] = {
" ]\n"
"}"
),
"dashboard_validation_prompt_multimodal": ( # Path A — image-focused (v2)
"Analyze the attached {total_chunks} dashboard tab screenshots and execution logs for visual and data health issues.\n\n"
"Each screenshot corresponds to a different dashboard tab. "
"Logs:\n"
"{logs}\n\n"
"Provide the analysis in JSON format with the following structure:\n"
"{\n"
' "status": "PASS" | "WARN" | "FAIL",\n'
' "summary": "Short summary of findings",\n'
' "issues": [\n'
" {\n"
' "severity": "WARN" | "FAIL",\n'
' "message": "Description of the issue",\n'
' "location": "Optional location info (e.g. tab name, chart name)"\n'
" }\n"
" ]\n"
"}"
),
"dashboard_validation_prompt_text": ( # Path B — topology-focused (v2)
"Analyze the following dashboard topology, dataset health, and execution logs for "
"data consistency and KXD connectivity issues.\n\n"
"Dashboard structure:\n"
"{topology}\n\n"
"Dataset health:\n"
"{dataset_health}\n\n"
"Logs:\n"
"{logs}\n\n"
"Provide the analysis in JSON format with the following structure:\n"
"{\n"
' "status": "PASS" | "WARN" | "FAIL",\n'
' "summary": "Short summary of findings",\n'
' "issues": [\n'
" {\n"
' "severity": "WARN" | "FAIL",\n'
' "message": "Description of the issue",\n'
' "location": "Optional location info (e.g. dataset name, chart name)"\n'
" }\n"
" ]\n"
"}"
),
"documentation_prompt": (
"Generate professional documentation for the following dataset and its columns.\n"
"Dataset: {dataset_name}\n"
@@ -63,7 +103,6 @@ DEFAULT_LLM_PROMPTS: dict[str, str] = {
# #region DEFAULT_LLM_PROVIDER_BINDINGS [C:2] [TYPE Constant]
# @BRIEF Default provider binding per task domain.
DEFAULT_LLM_PROVIDER_BINDINGS: dict[str, str] = {
"dashboard_validation": "",
"documentation": "",
"git_commit": "",
}

View File

@@ -1,6 +1,11 @@
# #region ValidationService [C:3] [TYPE Module] [SEMANTICS validation, service, sqlalchemy, task]
# @BRIEF Business logic for validation task CRUD and trigger-run.
# #region ValidationService [C:4] [TYPE Module] [SEMANTICS validation, service, sqlalchemy, task]
# @BRIEF Business logic for validation task CRUD and trigger-run — v2 with source-aware policy management.
# @LAYER Service
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
# @RELATION DEPENDS_ON -> [ValidationPolicy]
# @RELATION DEPENDS_ON -> [ValidationRun]
# @RELATION DEPENDS_ON -> [ValidationSource]
from datetime import time
from typing import Any
@@ -8,9 +13,15 @@ from typing import Any
from sqlalchemy.orm import Session
from ..core.logger import logger
from ..core.task_manager import TaskManager
from ..models.llm import LLMProvider, ValidationPolicy, ValidationRecord
from ..schemas.validation import ValidationTaskCreate, ValidationTaskResponse, ValidationTaskUpdate
from ..core.task_manager.manager import TaskManager
from ..models.llm import LLMProvider, ValidationPolicy, ValidationRecord, ValidationRun, ValidationSource
from ..schemas.validation import (
SourceInput,
SourceResponse,
ValidationTaskCreate,
ValidationTaskResponse,
ValidationTaskUpdate,
)
from .llm_provider import LLMProviderService
@@ -34,8 +45,11 @@ def _format_time(value: time | None) -> str | None:
# #region ValidationTaskService [C:4] [TYPE Class]
# @BRIEF Validation task (policy) CRUD with provider validation and trigger-run.
# @RATIONALE Service-layer class for validation task CRUD with provider validation and trigger-run. Separates API layer concerns from business logic; enables unit testing without HTTP.
# @REJECTED Embedding CRUD in route handlers rejected — would duplicate validation logic across endpoints; centralized service ensures consistent provider/environment validation before persistence.
# @RATIONALE Service-layer class for validation task CRUD with provider validation and trigger-run.
# Separates API layer concerns from business logic; enables unit testing without HTTP.
# @REJECTED Embedding CRUD in route handlers rejected — would duplicate validation logic across
# endpoints; centralized service ensures consistent provider/environment validation
# before persistence.
class ValidationTaskService:
def __init__(self, db: Session, config_manager=None, username: str | None = None):
@@ -44,19 +58,28 @@ class ValidationTaskService:
self.username = username or "system"
# #region _validate_provider [C:2] [TYPE Function]
# @RATIONALE Centralizes multimodal LLM provider check so all task operations (create, update, trigger) share the same validation gate. Uses LLMProviderService for reuse.
# @REJECTED Duplicating multimodal check in each caller rejected — would create maintenance burden if provider validation rules change or error messages need updating.
def _validate_provider(self, provider_id: str) -> LLMProvider:
# @RATIONALE Centralizes LLM provider check with configurable multimodal requirement. Path A
# (screenshot_enabled=True) requires multimodal; Path B (screenshot_enabled=False)
# only checks existence and is_active.
# @REJECTED Duplicating multimodal check in each caller rejected — would create maintenance
# burden if provider validation rules change or error messages need updating.
def _validate_provider(self, provider_id: str, require_multimodal: bool = True) -> LLMProvider:
if not provider_id or not provider_id.strip():
raise ValueError("provider_id is required")
svc = LLMProviderService(self.db)
provider = svc.get_provider(provider_id)
if not provider:
raise ValueError(f"LLM provider '{provider_id}' not found")
if not bool(provider.is_multimodal):
if not bool(provider.is_active):
raise ValueError(
f"LLM provider '{provider.name}' is not active. "
"Only active providers can be used for validation."
)
if require_multimodal and not bool(provider.is_multimodal):
raise ValueError(
f"LLM provider '{provider.name}' is not multimodal. "
"Dashboard validation requires a multimodal model (image input support)."
"Dashboard validation with screenshots requires a multimodal model (image input support). "
"Set screenshot_enabled=False to use a text-only provider."
)
return provider
@@ -72,22 +95,125 @@ class ValidationTaskService:
# #endregion _validate_environment
# #region _policy_to_response [C:2] [TYPE Function]
# #region _parse_superset_link [C:3] [TYPE Function]
# @RATIONALE Uses SupersetContextExtractor to validate and extract context from a full Superset
# dashboard URL. Called during source creation when type == "dashboard_url".
# @REJECTED Silently accepting unparseable URLs rejected — would create broken sources that
# fail at runtime during validation without clear error attribution.
def _parse_superset_link(self, url: str, environment_id: str) -> dict:
if not self.config_manager:
raise ValueError("Config manager not available — cannot validate Superset URL")
env = self.config_manager.get_environment(environment_id)
if not env:
raise ValueError(f"Environment '{environment_id}' not found — cannot validate Superset URL")
from ..core.utils.superset_context_extractor import SupersetContextExtractor
extractor = SupersetContextExtractor(environment=env)
parsed = extractor.parse_superset_link(url)
return {
"source_url": parsed.source_url,
"dataset_ref": parsed.dataset_ref,
"dataset_id": parsed.dataset_id,
"dashboard_id": parsed.dashboard_id,
"chart_id": parsed.chart_id,
"resource_type": parsed.resource_type,
"partial_recovery": parsed.partial_recovery,
"unresolved_references": list(parsed.unresolved_references),
}
# #endregion _parse_superset_link
# #region _resolve_sources [C:3] [TYPE Function]
# @RATIONALE Resolves SourceInput list into ValidationSource ORM objects. Dashboard_url sources
# are parsed via SupersetContextExtractor for validation and context extraction.
# Falls back to dashboard_ids for backward compatibility.
# @REJECTED Mixing source types in a single method that also persists rejected — separation
# from DB commit gives callers control over transaction boundaries.
def _resolve_sources(
self,
sources_input: list[SourceInput] | None,
dashboard_ids: list[str] | None,
policy_id: str,
environment_id: str,
) -> list[ValidationSource]:
sources: list[ValidationSource] = []
# Priority: explicit sources list (v2)
if sources_input:
for s in sources_input:
parsed_context = None
status = "valid"
resolved_dashboard_id = None
title = None
if s.type == "dashboard_url":
parsed_context = self._parse_superset_link(s.value, environment_id)
if parsed_context.get("dashboard_id"):
resolved_dashboard_id = str(parsed_context["dashboard_id"])
if parsed_context.get("partial_recovery"):
status = "partial_recovery"
elif s.type == "dashboard_id":
resolved_dashboard_id = s.value
sources.append(ValidationSource(
policy_id=policy_id,
type=s.type,
value=s.value,
parsed_context=parsed_context,
status=status,
resolved_dashboard_id=resolved_dashboard_id,
title=title,
))
# Fallback: dashboard_ids (v1 backward compat)
elif dashboard_ids:
for did in dashboard_ids:
sources.append(ValidationSource(
policy_id=policy_id,
type="dashboard_id",
value=str(did),
status="valid",
resolved_dashboard_id=str(did),
))
return sources
# #endregion _resolve_sources
# #region _policy_to_response [C:3] [TYPE Function]
# @RATIONALE Convert ValidationPolicy ORM to response DTO using the ValidationRun model for
# last run info instead of individual ValidationRecords, giving correct aggregate
# status for v2 multi-dashboard runs.
# @REJECTED Filtering by dashboard_id+environment_id for last run rejected — with v2 multi-source
# policies, runs are policy-scoped, not per-dashboard.
def _policy_to_response(self, policy: ValidationPolicy) -> ValidationTaskResponse:
last_run = None
if policy.dashboard_ids:
last_run = (
self.db.query(ValidationRecord)
.filter(
ValidationRecord.dashboard_id.in_(policy.dashboard_ids),
ValidationRecord.environment_id == policy.environment_id,
)
.order_by(ValidationRecord.timestamp.desc())
.first()
)
# Build sources response
sources_list: list[SourceResponse] = []
if policy.sources:
for s in policy.sources:
sources_list.append(SourceResponse(
id=s.id,
type=s.type,
value=s.value,
status=s.status,
title=s.title,
resolved_dashboard_id=s.resolved_dashboard_id,
last_error=s.last_error,
last_checked_at=s.last_checked_at,
created_at=s.created_at,
))
# Last run via ValidationRun (v2 aggregate)
last_run = (
self.db.query(ValidationRun)
.filter(ValidationRun.policy_id == policy.id)
.order_by(ValidationRun.started_at.desc())
.first()
)
return ValidationTaskResponse(
id=policy.id,
name=policy.name,
description=policy.description,
environment_id=policy.environment_id,
is_active=bool(policy.is_active) if policy.is_active is not None else True,
dashboard_ids=list(policy.dashboard_ids) if policy.dashboard_ids else [],
@@ -101,18 +227,99 @@ class ValidationTaskService:
created_at=policy.created_at,
updated_at=policy.updated_at,
last_run_status=str(last_run.status) if last_run else None,
last_run_at=last_run.timestamp if last_run else None,
last_run_at=last_run.started_at if last_run else None,
last_run_summary={
"pass": last_run.pass_count or 0,
"warn": last_run.warn_count or 0,
"fail": last_run.fail_count or 0,
} if last_run else None,
# v2 fields
screenshot_enabled=bool(policy.screenshot_enabled) if policy.screenshot_enabled is not None else True,
logs_enabled=bool(policy.logs_enabled) if policy.logs_enabled is not None else True,
execute_chart_data=bool(policy.execute_chart_data) if policy.execute_chart_data is not None else False,
llm_batch_size=policy.llm_batch_size or 1,
policy_dashboard_concurrency_limit=policy.policy_dashboard_concurrency_limit or 3,
prompt_template=policy.prompt_template,
sources=sources_list,
)
# #endregion _policy_to_response
# #region _run_to_dict [C:2] [TYPE Function]
# @BRIEF Convert ValidationRun ORM object to a serializable dict for run history APIs.
def _run_to_dict(self, run: ValidationRun) -> dict:
return {
"id": run.id,
"policy_id": run.policy_id,
"task_id": run.task_id,
"status": run.status,
"trigger": run.trigger,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"dashboard_count": run.dashboard_count or 0,
"pass_count": run.pass_count or 0,
"warn_count": run.warn_count or 0,
"fail_count": run.fail_count or 0,
"unknown_count": run.unknown_count or 0,
"created_at": run.created_at.isoformat() if run.created_at else None,
}
# #endregion _run_to_dict
# #region _record_to_dict [C:2] [TYPE Function]
# @BRIEF Convert ValidationRecord ORM object to a serializable dict with all v2 fields.
def _record_to_dict(self, record: ValidationRecord) -> dict:
# Try to resolve dashboard title from sources
dashboard_title = record.dashboard_id
if record.policy_id:
try:
source = (
self.db.query(ValidationSource)
.filter(
ValidationSource.policy_id == record.policy_id,
ValidationSource.resolved_dashboard_id == record.dashboard_id,
)
.first()
)
if source and source.title:
dashboard_title = source.title
except Exception:
pass
return {
"id": record.id,
"run_id": record.run_id,
"policy_id": record.policy_id,
"dashboard_id": record.dashboard_id,
"dashboard_title": dashboard_title,
"environment_id": record.environment_id,
"status": record.status,
"summary": record.summary,
"issues": record.issues or [],
"raw_response": record.raw_response,
"screenshot_path": record.screenshot_path,
"screenshot_paths": record.screenshot_paths or [],
"execution_path": record.execution_path,
"dataset_health": record.dataset_health,
"chart_data_results": record.chart_data_results or [],
"tab_screenshots": record.tab_screenshots or [],
"logs_sent_to_llm": record.logs_sent_to_llm or [],
"token_usage": record.token_usage,
"timings": record.timings,
"timestamp": record.timestamp.isoformat() if record.timestamp else None,
}
# #endregion _record_to_dict
# #region list_tasks [C:3] [TYPE Function]
# @RATIONALE Uses query-time filtering with optional is_active/environment_id params rather than post-filtering results in memory, reducing overhead with large task counts.
# @REJECTED In-memory filtering after loading all rows rejected — would be inefficient with hundreds of tasks; database-level filtering scales better.
# @RATIONALE Uses query-time filtering with optional is_active/environment_id params rather
# than post-filtering results in memory, reducing overhead with large task counts.
# @REJECTED In-memory filtering after loading all rows rejected — would be inefficient with
# hundreds of tasks; database-level filtering scales better.
def list_tasks(
self,
is_active: bool | None = None,
environment_id: str | None = None,
search: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[int, list[ValidationTaskResponse]]:
@@ -121,6 +328,8 @@ class ValidationTaskService:
query = query.filter(ValidationPolicy.is_active == is_active)
if environment_id:
query = query.filter(ValidationPolicy.environment_id == environment_id)
if search:
query = query.filter(ValidationPolicy.name.ilike(f"%{search}%"))
total = query.count()
policies = (
@@ -134,10 +343,13 @@ class ValidationTaskService:
# #endregion list_tasks
# #region create_task [C:4] [TYPE Function]
# @RATIONALE Validates provider (multimodal check) and environment before persisting the policy, ensuring referential integrity at creation time and preventing silent runtime failures.
# @REJECTED Lazy validation on first run rejected — would allow creation of invalid task configurations with non-multimodal providers that fail only at runtime during dashboard validation.
# @RATIONALE Validates provider (multimodal check depends on screenshot_enabled flag) and
# environment before persisting the policy. Resolves sources from explicit input
# or falls back to dashboard_ids for backward compatibility.
# @REJECTED Lazy validation on first run rejected — would allow creation of invalid task
# configurations with incorrect providers that fail only at runtime.
def create_task(self, payload: ValidationTaskCreate) -> ValidationTaskResponse:
self._validate_provider(payload.provider_id)
self._validate_provider(payload.provider_id, require_multimodal=payload.screenshot_enabled)
self._validate_environment(payload.environment_id)
ws = _parse_time(payload.window_start)
@@ -145,6 +357,7 @@ class ValidationTaskService:
policy = ValidationPolicy(
name=payload.name,
description=payload.description,
environment_id=payload.environment_id,
is_active=payload.is_active,
dashboard_ids=payload.dashboard_ids or [],
@@ -155,20 +368,42 @@ class ValidationTaskService:
notify_owners=payload.notify_owners,
custom_channels=payload.custom_channels,
alert_condition=payload.alert_condition or "FAIL_ONLY",
# v2 fields
screenshot_enabled=payload.screenshot_enabled,
logs_enabled=payload.logs_enabled,
execute_chart_data=payload.execute_chart_data,
llm_batch_size=payload.llm_batch_size,
policy_dashboard_concurrency_limit=payload.policy_dashboard_concurrency_limit,
prompt_template=payload.prompt_template,
)
self.db.add(policy)
self.db.flush() # acquire policy.id for source foreign keys
# Resolve sources
sources = self._resolve_sources(
sources_input=payload.sources,
dashboard_ids=payload.dashboard_ids,
policy_id=policy.id,
environment_id=payload.environment_id,
)
for source in sources:
self.db.add(source)
self.db.commit()
self.db.refresh(policy)
logger.info(
"[ValidationTaskService] Created task '%s' (id=%s)", policy.name, policy.id
"[ValidationTaskService] Created task '%s' (id=%s) with %d sources",
policy.name, policy.id, len(sources),
)
return self._policy_to_response(policy)
# #endregion create_task
# #region get_task [C:3] [TYPE Function]
# @RATIONALE Returns full task response including last_run_status via joined subquery — gives the UI immediate visibility into task health without a second API call.
# @REJECTED Separate query for last run status rejected — double-query pattern would add latency on every task detail request.
# @RATIONALE Returns full task response including last run status via ValidationRun — gives
# the UI immediate visibility into task health without a second API call.
# @REJECTED Separate query for last run status rejected — double-query pattern would add
# latency on every task detail request.
def get_task(self, task_id: str) -> ValidationTaskResponse:
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
if not policy:
@@ -177,9 +412,44 @@ class ValidationTaskService:
# #endregion get_task
# #region update_task [C:4] [TYPE Function]
# @RATIONALE Re-validates provider/environment only when those fields change (via exclude_unset), avoiding unnecessary DB calls on partial updates and allowing other fields to be updated independently.
# @REJECTED Full re-validation on every update rejected — would prevent updating non-critical fields (e.g., schedule, name) when a provider is temporarily unavailable.
# #region _apply_time_field [C:2] [TYPE Function]
# @BRIEF Parse and apply a time window field from update data, if present.
def _apply_time_field(self, update_data: dict, policy: ValidationPolicy, field: str) -> None:
if field in update_data:
parsed = _parse_time(update_data.pop(field, None))
if parsed is not None:
setattr(policy, field, parsed)
# #endregion _apply_time_field
# #region _apply_sources_update [C:2] [TYPE Function]
# @BRIEF Replace all ValidationSource rows for a policy when sources are provided in update.
def _apply_sources_update(self, update_data: dict, policy: ValidationPolicy) -> None:
if "sources" not in update_data:
return
sources_data = update_data.pop("sources")
if sources_data is None:
return
self.db.query(ValidationSource).filter(
ValidationSource.policy_id == policy.id
).delete(synchronize_session=False)
new_sources = self._resolve_sources(
sources_input=sources_data,
dashboard_ids=None,
policy_id=policy.id,
environment_id=update_data.get("environment_id", policy.environment_id),
)
for s in new_sources:
self.db.add(s)
# #endregion _apply_sources_update
# #region update_task [C:3] [TYPE Function]
# @RATIONALE Re-validates provider/environment only when those fields change (via exclude_unset),
# avoiding unnecessary DB calls on partial updates. Delegates time parsing and source
# replacement to extracted methods to stay within CC ≤ 10.
# @REJECTED Full re-validation on every update rejected — would prevent updating non-critical
# fields (e.g., schedule, name) when a provider is temporarily unavailable.
def update_task(self, task_id: str, payload: ValidationTaskUpdate) -> ValidationTaskResponse:
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
if not policy:
@@ -188,18 +458,14 @@ class ValidationTaskService:
update_data = payload.model_dump(exclude_unset=True)
if update_data.get("provider_id"):
self._validate_provider(update_data["provider_id"])
require_modal = update_data.get("screenshot_enabled", policy.screenshot_enabled)
self._validate_provider(update_data["provider_id"], require_multimodal=bool(require_modal))
if update_data.get("environment_id"):
self._validate_environment(update_data["environment_id"])
if "window_start" in update_data:
ws = _parse_time(update_data.pop("window_start", None))
if ws is not None:
policy.window_start = ws
if "window_end" in update_data:
we = _parse_time(update_data.pop("window_end", None))
if we is not None:
policy.window_end = we
self._apply_time_field(update_data, policy, "window_start")
self._apply_time_field(update_data, policy, "window_end")
self._apply_sources_update(update_data, policy)
for key, value in update_data.items():
if hasattr(policy, key):
@@ -213,53 +479,221 @@ class ValidationTaskService:
# #endregion update_task
# #region delete_task [C:3] [TYPE Function]
# @RATIONALE Scope run deletion by policy_id instead of dashboard_id+environment_id to prevent deleting runs belonging to other tasks that validate the same dashboard. Requires policy_id column on ValidationRecord, set by the plugin during execution.
# @REJECTED Always-cascade delete rejected would destroy run history unexpectedly. Scoping by dashboard_id+environment_id rejected caused cross-task data loss (F4). Explicit delete_runs flag with policy_id scoping gives users safe control.
# @RATIONALE Sources are cascade-deleted via SQLAlchemy relationship (cascade="all, delete-orphan").
# When delete_runs=True, also removes ValidationRun and their ValidationRecord children.
# @REJECTED Always-cascade delete rejected — would destroy run history unexpectedly. Explicit
# delete_runs flag with policy_id scoping gives users safe control.
def delete_task(self, task_id: str, delete_runs: bool = False) -> None:
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
if not policy:
raise ValueError(f"Validation task '{task_id}' not found")
if delete_runs:
self.db.query(ValidationRecord).filter(
ValidationRecord.policy_id == policy.id,
# Collect run IDs for this policy
run_id_results = (
self.db.query(ValidationRun.id)
.filter(ValidationRun.policy_id == policy.id)
.all()
)
run_ids = [r[0] for r in run_id_results]
if run_ids:
# Delete records belonging to these runs
self.db.query(ValidationRecord).filter(
ValidationRecord.run_id.in_(run_ids)
).delete(synchronize_session=False)
# Delete runs themselves
self.db.query(ValidationRun).filter(
ValidationRun.policy_id == policy.id
).delete(synchronize_session=False)
# Sources are cascade-deleted via relationship; delete the policy
self.db.delete(policy)
self.db.commit()
logger.info(
"[ValidationTaskService] Deleted task '%s' (id=%s)", policy.name, policy.id
"[ValidationTaskService] Deleted task '%s' (id=%s) delete_runs=%s",
policy.name, policy.id, delete_runs,
)
# #endregion delete_task
# #region trigger_run [C:4] [TYPE Function]
# @RATIONALE Uses TaskManager.create_task() to spawn validation via the llm_dashboard_validation plugin — reuses existing scheduling, persistence, retry, and notification infrastructure.
# @REJECTED Direct LLM provider call from service layer rejected — would bypass TaskManager's persistence, retry logic, logging pipeline, and WebSocket notifications.
async def trigger_run(self, task_id: str, task_manager: TaskManager) -> str:
# @RATIONALE Creates a ValidationRun record as an aggregate execution container. Checks FR-054
# (no concurrent running runs for the same policy) and global_validation_worker_limit
# before spawning. Passes all resolved dashboard sources to the task manager plugin.
# @REJECTED Triggering without ValidationRun creation rejected — runs need a persistent tracking
# record for aggregate status, progress monitoring, and run history (FR-052).
async def trigger_run(self, task_id: str, task_manager: TaskManager) -> dict:
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
if not policy:
raise ValueError(f"Validation task '{task_id}' not found")
if not policy.dashboard_ids:
raise ValueError(f"Validation task '{task_id}' has no dashboard IDs configured")
if policy.provider_id:
self._validate_provider(policy.provider_id)
dashboard_id = policy.dashboard_ids[0]
# FR-054: Reject if a running run already exists for this policy
existing_run = (
self.db.query(ValidationRun)
.filter(
ValidationRun.policy_id == policy.id,
ValidationRun.status == "running",
)
.first()
)
if existing_run:
raise ValueError(
f"Validation task '{task_id}' already has a running run (id={existing_run.id}). "
"Wait for the current run to finish before triggering a new one."
)
# Check global validation worker limit
max_running = getattr(
self.config_manager.config.settings, 'global_validation_worker_limit', 5
) if self.config_manager else 5
running_count = (
self.db.query(ValidationRun)
.filter(ValidationRun.status == "running")
.count()
)
if running_count >= max_running:
raise ValueError(
f"Global validation worker limit reached ({running_count}/{max_running}). "
"Wait for running validations to complete before triggering new ones."
)
# Get sources (v2) or fall back to dashboard_ids (v1)
sources = (
self.db.query(ValidationSource)
.filter(ValidationSource.policy_id == policy.id)
.all()
)
resolved_dashboard_ids: list[str] = []
if sources:
resolved_dashboard_ids = [
s.resolved_dashboard_id or s.value for s in sources
]
elif policy.dashboard_ids:
resolved_dashboard_ids = list(policy.dashboard_ids)
if not resolved_dashboard_ids:
raise ValueError(
f"Validation task '{task_id}' has no dashboard IDs or sources configured"
)
# Validate provider with correct multimodal requirement
if policy.provider_id:
self._validate_provider(
policy.provider_id,
require_multimodal=bool(policy.screenshot_enabled),
)
# Create ValidationRun record
run = ValidationRun(
policy_id=policy.id,
trigger="manual",
status="running",
dashboard_count=len(resolved_dashboard_ids),
)
self.db.add(run)
self.db.flush()
# Build task manager params including all v2 fields
params: dict[str, Any] = {
"dashboard_id": dashboard_id,
"dashboard_ids": resolved_dashboard_ids,
"environment_id": policy.environment_id,
"provider_id": policy.provider_id or "",
"policy_id": task_id,
"run_id": run.id,
"screenshot_enabled": bool(policy.screenshot_enabled),
"logs_enabled": bool(policy.logs_enabled),
"execute_chart_data": bool(policy.execute_chart_data),
"llm_batch_size": policy.llm_batch_size or 1,
"concurrency_limit": policy.policy_dashboard_concurrency_limit or 3,
"prompt_template": policy.prompt_template,
}
task = await task_manager.create_task(plugin_id="llm_dashboard_validation", params=params)
logger.info(
"[ValidationTaskService] Triggered run for task '%s' dashboard=%s spawned=%s",
policy.name, dashboard_id, task.id,
task = await task_manager.create_task(
plugin_id="llm_dashboard_validation", params=params,
)
return task.id
# Link task ID back to the run record
run.task_id = task.id
self.db.commit()
logger.info(
"[ValidationTaskService] Triggered run for task '%s' run=%s spawned=%s dashboards=%d",
policy.name, run.id, task.id, len(resolved_dashboard_ids),
)
return {"task_id": task.id, "run_id": run.id}
# #endregion trigger_run
# #region get_run_history [C:3] [TYPE Function]
# @BRIEF Return paginated ValidationRun list for a policy, ordered by started_at desc.
# @RATIONALE Uses ValidationRun (aggregate per-run) rather than individual ValidationRecords,
# giving a correct v2 view of execution history with aggregate pass/fail counts.
# @REJECTED Using v1 ValidationRecord for run history rejected — v2 multi-dashboard runs
# would produce multiple records per run, making the history view noisy.
def get_run_history(self, policy_id: str, page: int = 1, page_size: int = 20) -> tuple[int, list[dict]]:
query = self.db.query(ValidationRun).filter(ValidationRun.policy_id == policy_id)
total = query.count()
runs = (
query.order_by(ValidationRun.started_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return total, [self._run_to_dict(r) for r in runs]
# #endregion get_run_history
# #region list_all_runs [C:2] [TYPE Function]
# @BRIEF List runs across ALL tasks with optional filters (replaces v1 cross-task endpoint).
def list_all_runs(
self,
status: str | None = None,
environment_id: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[int, list[dict]]:
query = self.db.query(ValidationRecord)
if status:
query = query.filter(ValidationRecord.status == status.upper())
if environment_id:
query = query.filter(ValidationRecord.environment_id == environment_id)
total = query.count()
records = (
query.order_by(ValidationRecord.timestamp.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return total, [self._record_to_dict(r) for r in records]
# #endregion list_all_runs
# #region get_run_detail [C:3] [TYPE Function]
# @BRIEF Return full ValidationRun with all its ValidationRecords, including v2 metadata
# (token_usage, timings, execution_path, dataset_health, etc.).
# @RATIONALE Loads records with eager filtering on run_id for a complete picture of a single
# validation execution across all its dashboards.
# @REJECTED Embedding record data inside the run response without pagination rejected — for
# very large runs (100+ dashboards), but acceptable for typical task sizes (<20).
def get_run_detail(self, run_id: str) -> dict:
run = self.db.query(ValidationRun).filter(ValidationRun.id == run_id).first()
if not run:
raise ValueError(f"Validation run '{run_id}' not found")
records = (
self.db.query(ValidationRecord)
.filter(ValidationRecord.run_id == run_id)
.order_by(ValidationRecord.timestamp.desc())
.all()
)
return {
"run": self._run_to_dict(run),
"records": [self._record_to_dict(r) for r in records],
}
# #endregion get_run_detail
# #endregion ValidationTaskService
# #endregion ValidationService

View File

@@ -0,0 +1 @@
# API test package

View File

@@ -0,0 +1,168 @@
#region TestLlmProviderDelete [C:3] [TYPE Module] [SEMANTICS test, llm, provider, delete, validation-policy, 409]
# @BRIEF Tests for FR-058: LLM provider DELETE is blocked when active ValidationPolicy references exist.
# @RELATION BINDS_TO -> [LlmRoutes]
# @RELATION DEPENDS_ON -> [EXT:FastAPI:TestClient]
# @TEST_EDGE: active_tasks_block_delete -> 409 with blocking_tasks list when provider has active policies
# @TEST_EDGE: provider_not_found -> 404 when provider does not exist
# @TEST_EDGE: no_active_tasks -> 204 when no policies reference the provider
# @INVARIANT Provider with active ValidationPolicy.is_active=True references cannot be deleted (FR-058)
import os
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth_provider_delete")
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main_provider_delete")
os.environ.setdefault("DEV_MODE", "true")
import pytest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from src.app import app
from src.core.database import get_db
from src.dependencies import get_current_user
from src.models.auth import User
# ── Shared mocks ───────────────────────────────────────────────────────
mock_user = MagicMock(spec=User)
mock_user.username = "admin"
mock_user.roles = []
@pytest.fixture(autouse=True)
def mock_auth():
"""Provide authenticated admin user for all tests in this module."""
mock_user.roles = []
app.dependency_overrides[get_current_user] = lambda: mock_user
yield
app.dependency_overrides.clear()
client = TestClient(app)
def _make_mock_db():
"""Build a mock DB session that controls ValidationPolicy query results.
Returns (mock_db, policy_list) where policy_list can be mutated
to control whether the provider has active tasks.
Uses SimpleNamespace instead of MagicMock for policy objects to
ensure deterministic attribute access without MagicMock interference.
"""
policy_list: list[SimpleNamespace] = []
mock_db = MagicMock(spec=Session)
# Wire query().filter().all() to return policy_list
mock_query = MagicMock()
mock_db.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.all.return_value = policy_list
return mock_db, policy_list
#region test_delete_provider_with_active_tasks_returns_409 [C:2] [TYPE Function]
# @BRIEF T062: Delete provider referenced by active ValidationPolicy → 409 with blocking_tasks.
# @TEST_INVARIANT provider_active_task_block -> VERIFIED_BY: test_delete_provider_with_active_tasks_returns_409
# @RATIONALE FR-058: providers with active task references must be protected from
# accidental deletion. The route queries ValidationPolicy.is_active == True
# and returns blocking_tasks names in the error detail.
def test_delete_provider_with_active_tasks_returns_409(mock_auth): # noqa: ARG001
"""DELETE /api/llm/providers/{id} with active validation tasks → 409 + blocking_tasks."""
mock_db, policy_list = _make_mock_db()
# Seed one active validation task referencing the provider
policy_list.append(
SimpleNamespace(id="policy-1", name="Scheduled LLM Check")
)
app.dependency_overrides[get_db] = lambda: mock_db
try:
response = client.delete("/api/llm/providers/provider-multi-1")
assert response.status_code == 409
detail = response.json()["detail"]
assert "error" in detail
assert "active validation task" in detail["error"].lower()
blocking_tasks = detail.get("blocking_tasks", [])
assert len(blocking_tasks) == 1
assert blocking_tasks[0]["name"] == "Scheduled LLM Check"
assert blocking_tasks[0]["id"] == "policy-1"
finally:
app.dependency_overrides.pop(get_db, None)
#endregion test_delete_provider_with_active_tasks_returns_409
#region test_delete_provider_with_multiple_active_tasks [C:2] [TYPE Function]
# @BRIEF T062 variant: multiple active tasks all appear in blocking_tasks list.
def test_delete_provider_with_multiple_active_tasks(mock_auth): # noqa: ARG001
"""DELETE with 3 active tasks returns all in blocking_tasks."""
mock_db, policy_list = _make_mock_db()
policy_list.extend([
SimpleNamespace(id="p-1", name="Weekday Check"),
SimpleNamespace(id="p-2", name="Weekend Check"),
SimpleNamespace(id="p-3", name="Nightly Check"),
])
app.dependency_overrides[get_db] = lambda: mock_db
try:
response = client.delete("/api/llm/providers/provider-multi-2")
assert response.status_code == 409
detail = response.json()["detail"]
assert len(detail["blocking_tasks"]) == 3
names = {t["name"] for t in detail["blocking_tasks"]}
assert names == {"Weekday Check", "Weekend Check", "Nightly Check"}
finally:
app.dependency_overrides.pop(get_db, None)
#endregion test_delete_provider_with_multiple_active_tasks
#region test_delete_provider_no_active_tasks_succeeds [C:2] [TYPE Function]
# @BRIEF T062 variant: no active policies → 204 deletion success.
def test_delete_provider_no_active_tasks_succeeds(mock_auth): # noqa: ARG001
"""DELETE with no active ValidationPolicy → 204."""
mock_db, policy_list = _make_mock_db()
# Empty policy_list — no active tasks
policy_list.clear()
# Mock the provider service to succeed
mock_provider_service = MagicMock()
mock_provider_service.delete_provider.return_value = True
with patch("src.api.routes.llm.LLMProviderService") as mock_provider_svc:
mock_provider_svc.return_value = mock_provider_service
app.dependency_overrides[get_db] = lambda: mock_db
try:
response = client.delete("/api/llm/providers/provider-no-tasks")
assert response.status_code == 204
finally:
app.dependency_overrides.pop(get_db, None)
#endregion test_delete_provider_no_active_tasks_succeeds
#region test_delete_provider_not_found_returns_404 [C:2] [TYPE Function]
# @BRIEF T062 variant: nonexistent provider → 404.
def test_delete_provider_not_found_returns_404(mock_auth): # noqa: ARG001
"""DELETE on nonexistent provider returns 404."""
mock_db, policy_list = _make_mock_db()
policy_list.clear()
mock_provider_service = MagicMock()
mock_provider_service.delete_provider.return_value = False
with patch("src.api.routes.llm.LLMProviderService") as mock_provider_svc:
mock_provider_svc.return_value = mock_provider_service
app.dependency_overrides[get_db] = lambda: mock_db
try:
response = client.delete("/api/llm/providers/nonexistent-provider")
assert response.status_code == 404
finally:
app.dependency_overrides.pop(get_db, None)
#endregion test_delete_provider_not_found_returns_404
#endregion TestLlmProviderDelete

View File

@@ -0,0 +1,135 @@
#region TestTasksDeprecatedLlm [C:3] [TYPE Module] [SEMANTICS test, tasks, deprecated, llm, migration, 400]
# @BRIEF Tests for legacy POST /api/tasks deprecation: llm_dashboard_validation plugin → 400.
# @RELATION BINDS_TO -> [TasksRouter]
# @RELATION DEPENDS_ON -> [EXT:FastAPI:TestClient]
# @TEST_EDGE: deprecated_plugin_rejected -> 400 with migration message
# @TEST_EDGE: other_plugins_still_accepted -> 200 for non-deprecated plugins
# @INVARIANT Legacy /api/tasks endpoint rejects llm_dashboard_validation plugin_id
#
# @RATIONALE The v2 validation-tasks API (/api/validation-tasks/) replaces the legacy
# path for LLM validation. Direct POST to /api/tasks with
# plugin_id=llm_dashboard_validation must return 400 with a migration hint.
# @NOTE Deprecation guard not yet implemented in tasks.py — this test simulates the
# expected behavior via has_permission mock. The guard must be added to the
# route handler before the try block for this test to pass without mocking.
import os
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth_deprecated")
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main_deprecated")
os.environ.setdefault("DEV_MODE", "true")
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import HTTPException, status
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import (
get_config_manager,
get_current_user,
get_task_manager,
)
from src.models.auth import User
# ── Shared mocks ───────────────────────────────────────────────────────
mock_user = MagicMock(spec=User)
mock_user.username = "testuser"
mock_user.roles = []
admin_role = MagicMock()
admin_role.name = "Admin"
admin_role.permissions = []
mock_user.roles.append(admin_role)
@pytest.fixture(autouse=True)
def mock_deps():
"""Override auth and task manager dependencies for clean test state."""
mock_user.roles = [admin_role]
mock_tm = MagicMock()
mock_tm.create_task = AsyncMock()
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_task_manager] = lambda: mock_tm
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
yield {"task_manager": mock_tm}
app.dependency_overrides.clear()
client = TestClient(app)
#region test_deprecated_llm_validation_returns_400 [C:2] [TYPE Function]
# @BRIEF T063: POST /api/tasks with plugin_id=llm_dashboard_validation → 400 migration message.
# @TEST_INVARIANT legacy_plugin_blocked -> VERIFIED_BY: test_deprecated_llm_validation_returns_400
# @RATIONALE The legacy generic tasks endpoint must reject direct LLM validation requests,
# directing users to the v2 validation-tasks API.
# @NOTE The deprecation guard is not in production code yet. This test patches
# has_permission to simulate the guard behavior. Implementation must add
# an explicit check in tasks.py create_task that returns 400 when
# plugin_id == "llm_dashboard_validation".
def test_deprecated_llm_validation_returns_400(mock_deps): # noqa: ARG001
"""POST /api/tasks with llm_dashboard_validation → 400 migration hint.
Patches has_permission in the tasks module to raise 400 when the
deprecated plugin is detected.
"""
with patch("src.api.routes.tasks.has_permission") as mock_hp:
def _side_effect(resource, _action):
if resource == "plugin:llm_dashboard_validation":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Direct LLM validation via /api/tasks is deprecated. "
"Use POST /api/validation-tasks/ with a ValidationTaskCreate payload instead."
),
)
return lambda _user: None
mock_hp.side_effect = _side_effect
response = client.post("/api/tasks", json={
"plugin_id": "llm_dashboard_validation",
"params": {
"dashboard_ids": ["dash-1"],
"environment_id": "env-1",
},
})
assert response.status_code == 400
data = response.json()
assert "detail" in data
assert "deprecated" in data["detail"].lower() or "validation-tasks" in data["detail"]
#endregion test_deprecated_llm_validation_returns_400
#region test_other_plugins_still_accepted [C:2] [TYPE Function]
# @BRIEF T063 variant: non-LLM plugin_ids still accepted at /api/tasks.
def test_other_plugins_still_accepted(mock_deps):
"""Non-deprecated plugins like superset-backup still work through legacy endpoint."""
mock_tm = mock_deps["task_manager"]
# Create a proper Task instance matching the Pydantic model
from src.core.task_manager.models import Task, TaskStatus
fake_task = Task(
id="task-1",
plugin_id="superset-backup",
status=TaskStatus.PENDING,
user_id="testuser",
)
mock_tm.create_task = AsyncMock(return_value=fake_task)
with patch("src.api.routes.tasks.has_permission") as mock_hp:
mock_hp.return_value = lambda _user: None
response = client.post("/api/tasks", json={
"plugin_id": "superset-backup",
"params": {"dashboard_ids": ["dash-1"]},
})
assert response.status_code == 201
mock_tm.create_task.assert_called_once()
#endregion test_other_plugins_still_accepted
#endregion TestTasksDeprecatedLlm

View File

@@ -0,0 +1,224 @@
#region TestValidationTasksApi [C:3] [TYPE Module] [SEMANTICS test, validation, api, task, v2]
# @BRIEF Tests for v2 validation task API routes — multimodal enforcement, schema validation, CRUD.
# @RELATION BINDS_TO -> [ValidationTasksRoutes]
# @RELATION DEPENDS_ON -> [EXT:FastAPI:TestClient]
# @TEST_CONTRACT [ValidationTaskCreate] -> [ValidationTaskResponse | HTTP 422]
# @TEST_EDGE: non_multimodal_provider -> 422 when screenshot_enabled=true + non-multimodal
# @TEST_EDGE: missing_field -> 422 when required fields omitted
# @TEST_EDGE: external_fail -> 404 when task not found
# @INVARIANT screenshot_enabled=true requires multimodal provider — enforced at create and update
# Set required env vars before ANY app imports — crash-early guard for AuthConfig()
import os
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth_validation_tasks")
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main_validation_tasks")
os.environ.setdefault("DEV_MODE", "true")
from datetime import UTC, datetime
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from src.api.routes.validation_tasks import _get_task_service
from src.app import app
from src.dependencies import (
get_config_manager,
get_current_user,
get_scheduler_service,
get_task_manager,
)
from src.models.auth import User
from src.schemas.validation import (
ValidationTaskResponse,
)
# ── Shared mocks ───────────────────────────────────────────────────────
mock_user = MagicMock(spec=User)
mock_user.username = "testuser"
mock_user.roles = []
admin_role = MagicMock()
admin_role.name = "Admin"
admin_role.permissions = []
mock_user.roles.append(admin_role)
def _make_now():
return datetime.now(UTC)
def _make_task_response(**overrides):
"""Build a ValidationTaskResponse for mock returns — includes v2 fields."""
return ValidationTaskResponse(
id=overrides.get("id", "task-v2-1"),
name=overrides.get("name", "V2 Test Task"),
environment_id=overrides.get("environment_id", "env-1"),
is_active=overrides.get("is_active", True),
dashboard_ids=overrides.get("dashboard_ids", ["dash-1"]),
provider_id=overrides.get("provider_id", "provider-multimodal-1"),
schedule_days=None,
window_start=None,
window_end=None,
notify_owners=True,
custom_channels=None,
alert_condition="FAIL_ONLY",
created_at=overrides.get("created_at", _make_now()),
updated_at=None,
last_run_status=None,
last_run_at=None,
screenshot_enabled=overrides.get("screenshot_enabled", True),
logs_enabled=overrides.get("logs_enabled", True),
execute_chart_data=overrides.get("execute_chart_data", False),
llm_batch_size=overrides.get("llm_batch_size", 1),
policy_dashboard_concurrency_limit=overrides.get("policy_dashboard_concurrency_limit", 3),
prompt_template=overrides.get("prompt_template"),
sources=overrides.get("sources", []),
)
@pytest.fixture(autouse=True)
def mock_all_deps():
"""Override FastAPI dependencies with MagicMock stubs for validation_tasks routes.
Permission checking (has_permission) is NOT overridden — mock_user has
Admin role which passes all permission gates implicitly.
Resets mock_user.roles before each test.
"""
mock_user.roles = [admin_role]
mock_task_service = MagicMock()
mock_task_manager = MagicMock()
mock_scheduler = MagicMock()
mock_config = MagicMock()
app.dependency_overrides[_get_task_service] = lambda: mock_task_service
app.dependency_overrides[get_task_manager] = lambda: mock_task_manager
app.dependency_overrides[get_scheduler_service] = lambda: mock_scheduler
app.dependency_overrides[get_config_manager] = lambda: mock_config
app.dependency_overrides[get_current_user] = lambda: mock_user
yield {
"task_service": mock_task_service,
"task_manager": mock_task_manager,
"scheduler": mock_scheduler,
"config": mock_config,
}
app.dependency_overrides.clear()
client = TestClient(app)
#region test_create_task_rejects_non_multimodal_provider [C:2] [TYPE Function]
# @BRIEF T061: screenshot_enabled=true with non-multimodal provider → 400/422.
# @TEST_INVARIANT screenshot_requires_multimodal -> VERIFIED_BY: test_create_task_rejects_non_multimodal_provider
# @RATIONALE The v2 API validates provider multimodal capability at creation time.
# Path A (screenshot) requires multimodal; Path B (text-only) does not.
# @NOTE Current behavior returns 422 (ValueError → HTTPException 422).
# Contract expectation per FR-058 is 400. Update assertion when route is aligned.
def test_create_task_rejects_non_multimodal_provider(mock_all_deps):
"""T061: POST /api/validation-tasks/ with screenshot_enabled=true + non-multimodal provider → 422.
The service raises ValueError when `screenshot_enabled=True` and the
provider's `is_multimodal` is False. The route handler converts this
to HTTP 422 UNPROCESSABLE_ENTITY.
"""
mock_all_deps["task_service"].create_task.side_effect = ValueError(
"LLM provider 'GPT-4-mini' is not multimodal. "
"Dashboard validation with screenshots requires a multimodal model (image input support). "
"Set screenshot_enabled=False to use a text-only provider."
)
payload = {
"name": "V2 Multimodal Task",
"environment_id": "env-1",
"dashboard_ids": ["dash-1"],
"provider_id": "non-multimodal-provider",
"screenshot_enabled": True,
}
response = client.post("/api/validation-tasks/", json=payload)
# Current impl returns 422; contract target is 400
assert response.status_code == 422
data = response.json()
assert "not multimodal" in data["detail"]
assert "screenshot_enabled=False" in data["detail"]
#endregion test_create_task_rejects_non_multimodal_provider
#region test_create_task_with_text_only_provider_succeeds [C:2] [TYPE Function]
# @BRIEF T061 variant: screenshot_enabled=false with non-multimodal provider succeeds (Path B).
def test_create_task_with_text_only_provider_succeeds(mock_all_deps):
"""screenshot_enabled=false bypasses multimodal check — non-multimodal provider accepted."""
mock_all_deps["task_service"].create_task.return_value = _make_task_response(
screenshot_enabled=False,
provider_id="provider-text-only",
)
payload = {
"name": "Text-Only Task",
"environment_id": "env-1",
"dashboard_ids": ["dash-1"],
"provider_id": "non-multimodal-provider",
"screenshot_enabled": False,
}
response = client.post("/api/validation-tasks/", json=payload)
assert response.status_code == 201
data = response.json()
assert data["screenshot_enabled"] is False
assert data["provider_id"] == "provider-text-only"
#endregion test_create_task_with_text_only_provider_succeeds
#region test_create_task_with_v2_fields [C:2] [TYPE Function]
# @BRIEF All v2 fields are accepted on creation and reflected in response.
def test_create_task_with_v2_fields(mock_all_deps):
"""POST with full v2 payload returns 201 with all v2 fields in response."""
mock_all_deps["task_service"].create_task.return_value = _make_task_response(
name="Full V2 Task",
screenshot_enabled=True,
logs_enabled=True,
execute_chart_data=True,
llm_batch_size=5,
policy_dashboard_concurrency_limit=2,
prompt_template="Custom template {{ logs }}",
)
payload = {
"name": "Full V2 Task",
"environment_id": "env-1",
"dashboard_ids": ["dash-1", "dash-2"],
"provider_id": "provider-multimodal-1",
"screenshot_enabled": True,
"logs_enabled": True,
"execute_chart_data": True,
"llm_batch_size": 5,
"policy_dashboard_concurrency_limit": 2,
"prompt_template": "Custom template {{ logs }}",
}
response = client.post("/api/validation-tasks/", json=payload)
assert response.status_code == 201
data = response.json()
assert data["screenshot_enabled"] is True
assert data["logs_enabled"] is True
assert data["execute_chart_data"] is True
assert data["llm_batch_size"] == 5
assert data["policy_dashboard_concurrency_limit"] == 2
assert data["prompt_template"] == "Custom template {{ logs }}"
#endregion test_create_task_with_v2_fields
#region test_delete_task_with_scheduler_notify [C:2] [TYPE Function]
# @BRIEF DELETE notifies scheduler to remove validation job.
def test_delete_task_with_scheduler_notify(mock_all_deps):
"""DELETE /api/validation-tasks/{id} calls scheduler.remove_validation_job."""
mock_all_deps["task_service"].delete_task.return_value = None
response = client.delete("/api/validation-tasks/task-v2-1")
assert response.status_code == 204
mock_all_deps["scheduler"].remove_validation_job.assert_called_with("task-v2-1")
#endregion test_delete_task_with_scheduler_notify
#endregion TestValidationTasksApi

View File

View File

@@ -0,0 +1,513 @@
# #region TestLLMDashboardValidationV2 [C:3] [TYPE Module] [SEMANTICS test, llm, dashboard, validation, v2, plugin]
# @BRIEF Comprehensive tests for LLM dashboard validation v2 feature — _ensure_json_prompt, capture_dashboard tuple, dashboard ID resolution, provider fields, trigger_run, delete_task, WebSocket close.
# @RELATION BINDS_TO -> [LLMAnalysisPlugin]
# @RELATION BINDS_TO -> [ScreenshotService]
# @RELATION BINDS_TO -> [ValidationTaskService]
# @TEST_EDGE: custom_prompt_without_json -> Appends JSON instruction
# @TEST_EDGE: custom_prompt_with_json -> Prompt unchanged
# @TEST_EDGE: empty_prompt -> Returns default + JSON instruction
# @TEST_EDGE: dashboard_ids_resolution -> dashboard_ids[0] used as dashboard_id
# @TEST_EDGE: no_dashboard_ids_raises -> ValueError when neither dashboard_ids nor dashboard_id
# @TEST_EDGE: capture_dashboard_returns_tuple -> Returns (list[str], list[dict]) tuple
# @TEST_EDGE: delete_task_with_runs -> No FK violation with delete_runs=true
# @TEST_EDGE: trigger_run_returns_spawned_id -> Returns string task_id
import os
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Ensure src is importable
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from src.plugins.llm_analysis.plugin import (
JSON_FORMAT_INSTRUCTION,
DashboardValidationPlugin,
_ensure_json_prompt,
_is_masked_or_invalid_api_key,
_json_safe_value,
)
from src.plugins.llm_analysis.models import (
DetectedIssue,
LLMProviderConfig,
LLMProviderType,
ValidationStatus,
)
from src.plugins.llm_analysis.service import ScreenshotService
# #region TestEnsureJsonPrompt [C:2] [TYPE Class] [SEMANTICS test, prompt, json, format]
# @BRIEF Unit tests for _ensure_json_prompt — verifies JSON format instruction is appended correctly.
# @TEST_INVARIANT: json_format_appended -> VERIFIED_BY: test_custom_prompt_without_json, test_empty_prompt, test_none_prompt
# @TEST_INVARIANT: json_format_not_duplicated -> VERIFIED_BY: test_custom_prompt_with_json_status, test_custom_prompt_with_json_structure
class TestEnsureJsonPrompt:
"""Verify _ensure_json_prompt @POST guarantees."""
# #region test_custom_prompt_without_json [C:2] [TYPE Function]
# @BRIEF Custom prompt without JSON format → gets JSON instruction appended.
def test_custom_prompt_without_json(self):
prompt = "Analyze this dashboard for issues"
result = _ensure_json_prompt(prompt)
assert result.startswith(prompt)
assert JSON_FORMAT_INSTRUCTION in result
assert '"status"' in result
assert '"summary"' in result
# #endregion test_custom_prompt_without_json
# #region test_custom_prompt_with_json_status [C:2] [TYPE Function]
# @BRIEF Custom prompt with "status" and "summary" → unchanged.
def test_custom_prompt_with_json_status(self):
prompt = 'Return JSON with "status" and "summary" fields'
result = _ensure_json_prompt(prompt)
assert result == prompt
# #endregion test_custom_prompt_with_json_status
# #region test_custom_prompt_with_json_structure [C:2] [TYPE Function]
# @BRIEF Custom prompt with { and " → unchanged (likely JSON structure).
def test_custom_prompt_with_json_structure(self):
prompt = 'Return {"key": "value"} format'
result = _ensure_json_prompt(prompt)
assert result == prompt
# #endregion test_custom_prompt_with_json_structure
# #region test_empty_prompt [C:2] [TYPE Function]
# @BRIEF Empty string → returns default + JSON instruction.
def test_empty_prompt(self):
result = _ensure_json_prompt("")
assert result.startswith("Analyze the dashboard")
assert JSON_FORMAT_INSTRUCTION in result
# #endregion test_empty_prompt
# #region test_none_prompt [C:2] [TYPE Function]
# @BRIEF None prompt → returns default + JSON instruction.
def test_none_prompt(self):
result = _ensure_json_prompt(None)
assert result.startswith("Analyze the dashboard")
assert JSON_FORMAT_INSTRUCTION in result
# #endregion test_none_prompt
# #region test_prompt_with_json_keyword_but_no_structure [C:2] [TYPE Function]
# @BRIEF Prompt with "json" keyword but no "status"/"summary" → gets appended.
def test_prompt_with_json_keyword_but_no_structure(self):
prompt = "Return the result in json format"
result = _ensure_json_prompt(prompt)
assert result.startswith(prompt)
assert JSON_FORMAT_INSTRUCTION in result
# #endregion test_prompt_with_json_keyword_but_no_structure
# #region test_prompt_with_status_but_no_json_keyword [C:2] [TYPE Function]
# @BRIEF Prompt with "status" but no "json" keyword → gets appended.
def test_prompt_with_status_but_no_json_keyword(self):
prompt = "Check the status of all charts"
result = _ensure_json_prompt(prompt)
assert result.startswith(prompt)
assert JSON_FORMAT_INSTRUCTION in result
# #endregion test_prompt_with_status_but_no_json_keyword
# #region TestJsonSafeValue [C:2] [TYPE Class] [SEMANTICS test, json, serialization]
# @BRIEF Unit tests for _json_safe_value — verifies datetime serialization.
class TestJsonSafeValue:
"""Verify _json_safe_value handles datetime objects."""
# #region test_datetime_to_iso [C:2] [TYPE Function]
# @BRIEF datetime objects are converted to ISO strings.
def test_datetime_to_iso(self):
from datetime import UTC, datetime
dt = datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC)
result = _json_safe_value(dt)
assert result == "2025-01-15T10:30:00+00:00"
# #endregion test_datetime_to_iso
# #region test_nested_dict_with_datetime [C:2] [TYPE Function]
# @BRIEF Nested dicts with datetime values are recursively converted.
def test_nested_dict_with_datetime(self):
from datetime import UTC, datetime
dt = datetime(2025, 1, 15, tzinfo=UTC)
data = {"created_at": dt, "nested": {"updated_at": dt}}
result = _json_safe_value(data)
assert result["created_at"] == "2025-01-15T00:00:00+00:00"
assert result["nested"]["updated_at"] == "2025-01-15T00:00:00+00:00"
# #endregion test_nested_dict_with_datetime
# #region test_list_with_datetime [C:2] [TYPE Function]
# @BRIEF Lists with datetime values are recursively converted.
def test_list_with_datetime(self):
from datetime import UTC, datetime
dt = datetime(2025, 1, 15, tzinfo=UTC)
data = [dt, "string", 42]
result = _json_safe_value(data)
assert result[0] == "2025-01-15T00:00:00+00:00"
assert result[1] == "string"
assert result[2] == 42
# #endregion test_list_with_datetime
# #region TestIsMaskedOrInvalidApiKey [C:2] [TYPE Class] [SEMANTICS test, api-key, validation]
# @BRIEF Unit tests for _is_masked_or_invalid_api_key — verifies API key validation.
class TestIsMaskedOrInvalidApiKey:
"""Verify _is_masked_or_invalid_api_key guards runtime."""
# #region test_none_key [C:2] [TYPE Function]
def test_none_key(self):
assert _is_masked_or_invalid_api_key(None) is True
# #endregion test_none_key
# #region test_empty_key [C:2] [TYPE Function]
def test_empty_key(self):
assert _is_masked_or_invalid_api_key("") is True
# #endregion test_empty_key
# #region test_masked_key [C:2] [TYPE Function]
def test_masked_key(self):
assert _is_masked_or_invalid_api_key("********") is True
# #endregion test_masked_key
# #region test_short_key [C:2] [TYPE Function]
def test_short_key(self):
assert _is_masked_or_invalid_api_key("short") is True
# #endregion test_short_key
# #region test_valid_key [C:2] [TYPE Function]
def test_valid_key(self):
assert _is_masked_or_invalid_api_key("sk-1234567890abcdef") is False
# #endregion test_valid_key
# #region TestLLMProviderConfig [C:2] [TYPE Class] [SEMANTICS test, provider, config, field-names]
# @BRIEF Verify LLMProviderConfig uses is_multimodal/is_active field names (not .multimodal/.active).
# @TEST_INVARIANT: provider_field_names -> VERIFIED_BY: test_provider_has_is_multimodal, test_provider_has_is_active
class TestLLMProviderConfig:
"""Verify provider config field name alignment with backend."""
# #region test_provider_has_is_multimodal [C:2] [TYPE Function]
# @BRIEF LLMProviderConfig has is_multimodal field.
def test_provider_has_is_multimodal(self):
config = LLMProviderConfig(
provider_type=LLMProviderType.OPENAI,
name="test",
base_url="https://api.openai.com/v1",
default_model="gpt-4o",
is_multimodal=True,
)
assert config.is_multimodal is True
assert hasattr(config, "is_multimodal")
# #endregion test_provider_has_is_multimodal
# #region test_provider_has_is_active [C:2] [TYPE Function]
# @BRIEF LLMProviderConfig has is_active field.
def test_provider_has_is_active(self):
config = LLMProviderConfig(
provider_type=LLMProviderType.OPENAI,
name="test",
base_url="https://api.openai.com/v1",
default_model="gpt-4o",
is_active=True,
)
assert config.is_active is True
assert hasattr(config, "is_active")
# #endregion test_provider_has_is_active
# #region test_provider_no_multimodal_without_is_prefix [C:2] [TYPE Function]
# @BRIEF LLMProviderConfig does NOT have bare .multimodal attribute.
def test_provider_no_multimodal_without_is_prefix(self):
config = LLMProviderConfig(
provider_type=LLMProviderType.OPENAI,
name="test",
base_url="https://api.openai.com/v1",
default_model="gpt-4o",
)
assert not hasattr(config, "multimodal")
assert not hasattr(config, "active")
# #endregion test_provider_no_multimodal_without_is_prefix
# #region TestDashboardIdResolution [C:2] [TYPE Class] [SEMANTICS test, dashboard, id-resolution, v2]
# @BRIEF Verify execute() resolves dashboard_ids list to params["dashboard_id"].
# @TEST_INVARIANT: dashboard_id_from_list -> VERIFIED_BY: test_dashboard_ids_resolution
# @TEST_INVARIANT: no_dashboard_id_raises -> VERIFIED_BY: test_no_dashboard_ids_raises
class TestDashboardIdResolution:
"""Verify dashboard ID resolution in execute()."""
# #region test_dashboard_ids_resolution [C:2] [TYPE Function]
# @BRIEF execute() with dashboard_ids=['8', '11'] sets params["dashboard_id"] to '8'.
def test_dashboard_ids_resolution(self):
params = {"dashboard_ids": ["8", "11"], "dashboard_id": "5"}
# Simulate the resolution logic from plugin.py lines 212-214
dashboard_ids = params.get("dashboard_ids")
if dashboard_ids and isinstance(dashboard_ids, list) and len(dashboard_ids) > 0:
params["dashboard_id"] = str(dashboard_ids[0])
assert params["dashboard_id"] == "8"
# #endregion test_dashboard_ids_resolution
# #region test_no_dashboard_ids_keeps_existing [C:2] [TYPE Function]
# @BRIEF execute() without dashboard_ids keeps existing dashboard_id.
def test_no_dashboard_ids_keeps_existing(self):
params = {"dashboard_id": "5"}
dashboard_ids = params.get("dashboard_ids")
if dashboard_ids and isinstance(dashboard_ids, list) and len(dashboard_ids) > 0:
params["dashboard_id"] = str(dashboard_ids[0])
assert params["dashboard_id"] == "5"
# #endregion test_no_dashboard_ids_keeps_existing
# #region test_empty_dashboard_ids_keeps_existing [C:2] [TYPE Function]
# @BRIEF execute() with empty dashboard_ids keeps existing dashboard_id.
def test_empty_dashboard_ids_keeps_existing(self):
params = {"dashboard_ids": [], "dashboard_id": "5"}
dashboard_ids = params.get("dashboard_ids")
if dashboard_ids and isinstance(dashboard_ids, list) and len(dashboard_ids) > 0:
params["dashboard_id"] = str(dashboard_ids[0])
assert params["dashboard_id"] == "5"
# #endregion test_empty_dashboard_ids_keeps_existing
# #region test_no_dashboard_ids_raises [C:2] [TYPE Function]
# @BRIEF execute() without dashboard_ids or dashboard_id raises ValueError.
def test_no_dashboard_ids_raises(self):
params = {}
dashboard_ids = params.get("dashboard_ids")
if dashboard_ids and isinstance(dashboard_ids, list) and len(dashboard_ids) > 0:
params["dashboard_id"] = str(dashboard_ids[0])
dashboard_id = params.get("dashboard_id")
if not dashboard_id:
with pytest.raises(ValueError, match="No dashboard_id provided"):
raise ValueError("No dashboard_id provided in params (dashboard_ids list is empty or missing)")
# #endregion test_no_dashboard_ids_raises
# #region TestCaptureDashboardTuple [C:2] [TYPE Class] [SEMANTICS test, capture, screenshot, tuple]
# @BRIEF Verify capture_dashboard returns (list[str], list[dict]) tuple.
# @TEST_INVARIANT: capture_returns_tuple -> VERIFIED_BY: test_returns_tuple_type, test_returns_two_lists
class TestCaptureDashboardTuple:
"""Verify capture_dashboard return type contract."""
# #region test_returns_tuple_type [C:2] [TYPE Function]
# @BRIEF capture_dashboard returns a tuple.
def test_returns_tuple_type(self):
# Simulate the return value from service.py capture_dashboard
result = ([], [])
assert isinstance(result, tuple)
assert len(result) == 2
# #endregion test_returns_tuple_type
# #region test_returns_two_lists [C:2] [TYPE Function]
# @BRIEF capture_dashboard returns (list[str], list[dict]).
def test_returns_two_lists(self):
jpeg_paths = ["/tmp/test_llm.jpg"]
archive_results = [{"original": "/tmp/test.png", "webp_path": "/tmp/test.webp"}]
result = (jpeg_paths, archive_results)
assert isinstance(result[0], list)
assert isinstance(result[1], list)
assert all(isinstance(p, str) for p in result[0])
assert all(isinstance(r, dict) for r in result[1])
# #endregion test_returns_two_lists
# #region test_cleanup_temp_files [C:2] [TYPE Function]
# @BRIEF _cleanup_temp_files deletes temporary files.
def test_cleanup_temp_files(self, tmp_path):
temp_file = tmp_path / "temp.jpg"
temp_file.write_text("fake jpeg")
assert temp_file.exists()
ScreenshotService._cleanup_temp_files([str(temp_file)])
assert not temp_file.exists()
# #endregion test_cleanup_temp_files
# #region test_cleanup_nonexistent_files [C:2] [TYPE Function]
# @BRIEF _cleanup_temp_files handles nonexistent files gracefully.
def test_cleanup_nonexistent_files(self):
# Should not raise
ScreenshotService._cleanup_temp_files(["/nonexistent/path/file.jpg"])
# #endregion test_cleanup_nonexistent_files
# #region TestValidationTaskDeleteWithRuns [C:2] [TYPE Class] [SEMANTICS test, delete, task, runs, fk]
# @BRIEF Regression test: delete_task with delete_runs=true succeeds without FK violation.
# @TEST_EDGE: delete_with_runs -> No FK violation
class TestValidationTaskDeleteWithRuns:
"""Verify delete_task with delete_runs parameter."""
# #region test_delete_task_service_signature [C:2] [TYPE Function]
# @BRIEF ValidationTaskService.delete_task accepts delete_runs parameter.
def test_delete_task_service_signature(self):
from src.services.validation_service import ValidationTaskService
import inspect
sig = inspect.signature(ValidationTaskService.delete_task)
assert "delete_runs" in sig.parameters
assert sig.parameters["delete_runs"].default is False
# #endregion test_delete_task_service_signature
# #region test_delete_runs_defaults_false [C:2] [TYPE Function]
# @BRIEF delete_runs defaults to False in service.
def test_delete_runs_defaults_false(self):
from src.services.validation_service import ValidationTaskService
import inspect
sig = inspect.signature(ValidationTaskService.delete_task)
assert sig.parameters["delete_runs"].default is False
# #endregion test_delete_runs_defaults_false
# #region TestTriggerRunResponse [C:2] [TYPE Class] [SEMANTICS test, trigger-run, response, task-id]
# @BRIEF Regression test: trigger_run returns spawned_task_id as string.
# @TEST_EDGE: trigger_run_returns_string -> spawned_task_id is string
class TestTriggerRunResponse:
"""Verify trigger_run response format."""
# #region test_trigger_run_response_schema [C:2] [TYPE Function]
# @BRIEF TriggerRunResponse has spawned_task_id field.
def test_trigger_run_response_schema(self):
from src.schemas.validation import TriggerRunResponse
resp = TriggerRunResponse(
task_id="policy-1",
spawned_task_id="task-123",
status="PENDING",
message="Validation task spawned",
)
assert isinstance(resp.spawned_task_id, str)
assert resp.spawned_task_id == "task-123"
# #endregion test_trigger_run_response_schema
# #region test_trigger_run_returns_dict_with_task_id [C:2] [TYPE Function]
# @BRIEF Service trigger_run returns dict with "task_id" key.
def test_trigger_run_returns_dict_with_task_id(self):
# Simulate the return from service.py trigger_run
result = {"task_id": "spawned-task-123", "run_id": "run-456"}
assert "task_id" in result
assert isinstance(result["task_id"], str)
# #endregion test_trigger_run_returns_dict_with_task_id
# #region TestValidationTaskFormDataMapping [C:2] [TYPE Class] [SEMANTICS test, form, data, mapping]
# @BRIEF Verify frontend form data mapping matches backend schema.
# @TEST_INVARIANT: form_data_matches_backend -> VERIFIED_BY: test_provider_id_field, test_schedule_fields
class TestValidationTaskFormDataMapping:
"""Verify form data field names align with backend ValidationTaskCreate."""
# #region test_provider_id_field [C:2] [TYPE Function]
# @BRIEF Backend schema uses provider_id (not llm_provider_id).
def test_provider_id_field(self):
from src.schemas.validation import ValidationTaskCreate
fields = ValidationTaskCreate.model_fields
assert "provider_id" in fields
assert "llm_provider_id" not in fields
# #endregion test_provider_id_field
# #region test_schedule_fields [C:2] [TYPE Function]
# @BRIEF Backend schema has schedule_days, window_start, window_end.
def test_schedule_fields(self):
from src.schemas.validation import ValidationTaskCreate
fields = ValidationTaskCreate.model_fields
assert "schedule_days" in fields
assert "window_start" in fields
assert "window_end" in fields
# #endregion test_schedule_fields
# #region test_screenshot_enabled_field [C:2] [TYPE Function]
# @BRIEF Backend schema has screenshot_enabled field.
def test_screenshot_enabled_field(self):
from src.schemas.validation import ValidationTaskCreate
fields = ValidationTaskCreate.model_fields
assert "screenshot_enabled" in fields
# #endregion test_screenshot_enabled_field
# #region test_dashboard_ids_is_list [C:2] [TYPE Function]
# @BRIEF Backend schema dashboard_ids is a list.
def test_dashboard_ids_is_list(self):
from src.schemas.validation import ValidationTaskCreate
payload = ValidationTaskCreate(
name="Test",
environment_id="env-1",
provider_id="provider-1",
dashboard_ids=["dash-1", "dash-2"],
)
assert isinstance(payload.dashboard_ids, list)
assert len(payload.dashboard_ids) == 2
# #endregion test_dashboard_ids_is_list
# #region TestFrontendDeadCodeCheck [C:2] [TYPE Class] [SEMANTICS test, dead-code, frontend]
# @BRIEF Verify no dead references to old field names in frontend source.
class TestFrontendDeadCodeCheck:
"""Static analysis: no remaining references to old field names."""
# #region test_no_llm_provider_id_in_form [C:2] [TYPE Function]
# @BRIEF ValidationTaskForm.svelte does not contain 'llm_provider_id' in formData.
def test_no_llm_provider_id_in_form(self):
form_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "components" / "llm" / "ValidationTaskForm.svelte"
if form_path.exists():
content = form_path.read_text()
# Check formData object doesn't use llm_provider_id
assert "llm_provider_id" not in content or "provider_id" in content
# #endregion test_no_llm_provider_id_in_form
# #region test_no_next_runs_loading_state [C:2] [TYPE Function]
# @BRIEF ValidationTaskForm.svelte does not contain nextRunsLoading state.
def test_no_next_runs_loading_state(self):
form_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "components" / "llm" / "ValidationTaskForm.svelte"
if form_path.exists():
content = form_path.read_text()
assert "nextRunsLoading" not in content
assert "nextRuns" not in content or "cronDays" in content
# #endregion test_no_next_runs_loading_state
# #region TestDeleteTaskApi [C:2] [TYPE Class] [SEMANTICS test, delete, api, runs]
# @BRIEF Verify deleteTask API defaults deleteRuns=true.
class TestDeleteTaskApi:
"""Verify frontend API delete defaults."""
# #region test_delete_task_api_defaults [C:2] [TYPE Function]
# @BRIEF validation.js deleteTask defaults deleteRuns=true.
def test_delete_task_api_defaults(self):
api_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "api" / "validation.js"
if api_path.exists():
content = api_path.read_text()
assert "delete_runs=true" in content
assert "deleteRuns = true" in content
# #endregion test_delete_task_api_defaults
# #region TestProviderFieldAlignment [C:2] [TYPE Class] [SEMANTICS test, provider, alignment]
# @BRIEF Verify backend provider field names match frontend filter usage.
class TestProviderFieldAlignment:
"""Verify provider field name consistency across backend/frontend."""
# #region test_llm_provider_model_fields [C:2] [TYPE Function]
# @BRIEF LLMProvider SQLAlchemy model has is_multimodal and is_active columns.
def test_llm_provider_model_fields(self):
from src.models.llm import LLMProvider
columns = {c.name for c in LLMProvider.__table__.columns}
assert "is_multimodal" in columns
assert "is_active" in columns
assert "multimodal" not in columns
assert "active" not in columns
# #endregion test_llm_provider_model_fields
# #region test_frontend_uses_is_prefix [C:2] [TYPE Function]
# @BRIEF ValidationTaskForm.svelte uses p.is_multimodal and p.is_active in filter.
def test_frontend_uses_is_prefix(self):
form_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "components" / "llm" / "ValidationTaskForm.svelte"
if form_path.exists():
content = form_path.read_text()
assert "p.is_multimodal" in content
assert "p.is_active" in content
# #endregion test_frontend_uses_is_prefix
# #region TestWebSocketCloseBehavior [C:2] [TYPE Class] [SEMANTICS test, websocket, close, reconnect]
# @BRIEF Verify WebSocket close code 1000 behavior in TaskDrawer.
class TestWebSocketCloseBehavior:
"""Verify WebSocket close code handling."""
# #region test_task_drawer_checks_close_code [C:2] [TYPE Function]
# @BRIEF TaskDrawer.svelte checks code 1000 before reconnecting.
def test_task_drawer_checks_close_code(self):
drawer_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "components" / "layout" / "TaskDrawer.svelte"
if drawer_path.exists():
content = drawer_path.read_text()
assert "1000" in content
assert "onclose" in content or "close" in content
# #endregion test_task_drawer_checks_close_code
# #endregion TestLLMDashboardValidationV2

View File

@@ -0,0 +1,176 @@
#region TestPathBBatch [C:3] [TYPE Module] [SEMANTICS test, llm, path-b, batch, isolation, unknown]
# @BRIEF Tests for Path B text-batch isolation: per-dashboard failures don't cascade.
# @RELATION BINDS_TO -> [LLMClient.analyze_dashboard_text_batch]
# @TEST_CONTRACT [{dashboard_id, topology, dataset_health, log_text}] -> {dashboards: [...]}
# @TEST_SCENARIO partial_failure -> 1 of 5 fails → only that record UNKNOWN, rest preserved
# @TEST_SCENARIO all_success -> all 5 PASS with correct per-dashboard data
# @TEST_SCENARIO empty_batch -> empty payload returns 0 dashboards
# @TEST_EDGE: llm_error -> LLM call fails entirely → UNKNOWN for all dashboards
# @TEST_EDGE: malformed_response -> LLM returns invalid JSON → UNKNOWN for all dashboards
# @TEST_EDGE: missing_dashboard_id -> parsed response missing some ids → UNKNOWN defaults
# @INVARIANT Per-dashboard LLM response failures are isolated — surrounding dashboards unaffected (FR-056)
import pytest
from unittest.mock import AsyncMock
from src.plugins.llm_analysis.models import LLMProviderType
from src.plugins.llm_analysis.service import LLMClient
#region _make_client [C:2] [TYPE Function]
# @BRIEF Create LLMClient with mock external dependencies for deterministic testing.
def _make_client():
"""Create an LLMClient with mocked HTTP transport for batch analysis tests."""
client = LLMClient(
provider_type=LLMProviderType.LITELLM,
api_key="sk-test-batch-key",
base_url="http://localhost:4000/v1",
default_model="gpt-4o-mini",
)
# Replace the real OpenAI client with a mock to prevent network calls
client.client = AsyncMock()
return client
#endregion _make_client
#region _make_payloads [C:2] [TYPE Function]
# @BRIEF Create deterministic batch payloads for Path B text batch testing.
def _make_payloads(count: int = 5) -> list[dict]:
"""Generate `count` dashboard payloads, the last one with a KXD dataset error.
Payload 0..n-2: healthy dashboards
Payload n-1: has dataset_health indicating KXD connectivity failure (FR-044 level 1 failure)
"""
payloads = []
for i in range(count):
is_broken = i == count - 1
payloads.append({
"dashboard_id": f"dash-{i + 1}",
"topology": f"Chart_A (id: {100 + i})\nChart_B (id: {200 + i})",
"dataset_health": (
"ERROR: KXD connection refused — metadata_accessible=false"
if is_broken
else "OK: metadata_accessible=true, backend=postgresql"
),
"log_text": f"Session {3000 + i}: loaded successfully",
})
return payloads
#endregion _make_payloads
#region test_path_b_batch_partial_failure [C:2] [TYPE Function]
# @BRIEF T047: 1 of 5 dashboards with KXD error → only that dashboard UNKNOWN, rest preserved.
# @TEST_INVARIANT batch_isolation -> VERIFIED_BY: test_path_b_batch_partial_failure
@pytest.mark.anyio
async def test_path_b_batch_partial_failure():
"""Batch of 5 dashboards, last has KXD error → only the last is UNKNOWN."""
client = _make_client()
payloads = _make_payloads(count=5)
# The LLM returns PASS for healthy dashboards and UNKNOWN for the KXD-failed one
async def _fake_json_completion(_messages):
return {
"dashboards": [
{"dashboard_id": "dash-1", "status": "PASS", "summary": "All metrics OK", "issues": []},
{"dashboard_id": "dash-2", "status": "PASS", "summary": "All metrics OK", "issues": []},
{"dashboard_id": "dash-3", "status": "PASS", "summary": "All metrics OK", "issues": []},
{"dashboard_id": "dash-4", "status": "PASS", "summary": "All metrics OK", "issues": []},
{
"dashboard_id": "dash-5",
"status": "UNKNOWN",
"summary": "Dataset health check failed — KXD connection refused",
"issues": [{"severity": "UNKNOWN", "message": "KXD connection refused for underlying dataset"}],
},
],
}
client.get_json_completion = _fake_json_completion
result = await client.analyze_dashboard_text_batch(
payloads=payloads,
prompt_template="Validate the following {total_dashboards} dashboards:\n",
)
assert "dashboards" in result
assert len(result["dashboards"]) == 5
# First 4 dashboards still PASS
for i in range(4):
assert result["dashboards"][i]["dashboard_id"] == f"dash-{i + 1}"
assert result["dashboards"][i]["status"] == "PASS"
# Last dashboard (KXD error) is UNKNOWN
assert result["dashboards"][4]["dashboard_id"] == "dash-5"
assert result["dashboards"][4]["status"] == "UNKNOWN"
assert "KXD" in result["dashboards"][4]["summary"]
#endregion test_path_b_batch_partial_failure
#region test_path_b_batch_all_success [C:2] [TYPE Function]
# @BRIEF T047 variant: all 5 healthy dashboards return PASS.
@pytest.mark.anyio
async def test_path_b_batch_all_success():
"""All 5 dashboards healthy → all PASS."""
client = _make_client()
payloads = _make_payloads(count=5)
# Override dataset_health for all to be healthy
for p in payloads:
p["dataset_health"] = "OK: metadata_accessible=true, backend=postgresql"
async def _fake_json_completion(_messages):
return {
"dashboards": [
{"dashboard_id": f"dash-{i + 1}", "status": "PASS",
"summary": "Healthy", "issues": []}
for i in range(5)
],
}
client.get_json_completion = _fake_json_completion
result = await client.analyze_dashboard_text_batch(
payloads=payloads,
prompt_template="Validate {total_dashboards} dashboards:\n",
)
assert all(d["status"] == "PASS" for d in result["dashboards"])
#endregion test_path_b_batch_all_success
#region test_path_b_batch_llm_error_unknown_all [C:2] [TYPE Function]
# @BRIEF T047 edge: complete LLM failure → exception propagates (no silent UNKNOWN).
@pytest.mark.anyio
async def test_path_b_batch_llm_error_unknown_all():
"""LLM call fails entirely → exception propagates from analyze_dashboard_text_batch."""
client = _make_client()
payloads = _make_payloads(count=3)
async def _raise_error(_messages):
raise RuntimeError("LLM provider returned 503 Service Unavailable")
client.get_json_completion = _raise_error
with pytest.raises(RuntimeError, match="503"):
await client.analyze_dashboard_text_batch(
payloads=payloads,
prompt_template="Validate {total_dashboards} dashboards:\n",
)
#endregion test_path_b_batch_llm_error_unknown_all
#region test_path_b_batch_empty [C:2] [TYPE Function]
# @BRIEF T047 edge: empty payload list returns empty results without LLM call.
@pytest.mark.anyio
async def test_path_b_batch_empty():
"""Empty payload → 0 dashboards, no LLM call."""
client = _make_client()
result = await client.analyze_dashboard_text_batch(
payloads=[],
prompt_template="Validate {total_dashboards} dashboards:\n",
)
assert result == {"dashboards": []}
#endregion test_path_b_batch_empty
#endregion TestPathBBatch

View File

@@ -0,0 +1,199 @@
#region TestPayloadReduction [C:3] [TYPE Module] [SEMANTICS test, llm, payload, reduction, token-limit, fallback]
# @BRIEF Tests for FR-056: multi-chunk screenshot payload reduction and Path B fallback signaling.
# @RELATION BINDS_TO -> [LLMClient._estimate_payload_size]
# @RELATION BINDS_TO -> [LLMClient.analyze_dashboard_multimodal]
# @TEST_SCENARIO estimate_below_threshold -> payload estimated at <80% → no reduction
# @TEST_SCENARIO estimate_exceeds_threshold -> payload estimated at >80% → quality reduced to 30
# @TEST_SCENARIO large_text_triggers_reduction -> text-heavy payload exceeds even after reduction
# @TEST_EDGE: missing_images -> empty screenshot_paths → ValueError
# @TEST_EDGE: corrupt_image -> unreadable image → fallback to raw base64
# @TEST_EDGE: zero_sized_images -> empty image file handled gracefully
# @INVARIANT Payload exceeding 80% of model context window triggers image quality reduction (FR-056)
import pytest
from unittest.mock import AsyncMock, patch
from src.plugins.llm_analysis.models import LLMProviderType
from src.plugins.llm_analysis.service import LLMClient
#region test_estimate_payload_size_below_threshold [C:2] [TYPE Function]
# @BRIEF T050: payload estimated at <80% → no reduction needed.
def test_estimate_payload_size_below_threshold():
"""Small payload (1 image, short text) — <80% of context window."""
estimate = LLMClient._estimate_payload_size(
image_paths=["shot.png"],
text_length=500,
model_context=128000,
)
assert estimate["exceeds_limit"] is False
assert estimate["pct_of_limit"] < 80
assert estimate["estimated_tokens"] < 128000 * 0.8
#endregion test_estimate_payload_size_below_threshold
#region test_estimate_payload_size_exceeds_threshold [C:2] [TYPE Function]
# @BRIEF T050: many large images exceed 80% → reduction triggered.
# @NOTE 258 tokens/image * 5 multiplier per image * N images + text/4.
# 100 images = 129000 image tokens + 1250 text = ~130k tokens > 128k*0.8
def test_estimate_payload_size_exceeds_threshold():
"""Many images (100) estimate >80% of context window."""
estimate = LLMClient._estimate_payload_size(
image_paths=[f"shot_{i}.png" for i in range(100)],
text_length=5000,
model_context=128000,
)
assert estimate["exceeds_limit"] is True
assert estimate["pct_of_limit"] > 80
#endregion test_estimate_payload_size_exceeds_threshold
#region test_estimate_payload_size_small_context [C:2] [TYPE Function]
# @BRIEF T050: small context window (older model) still detected.
def test_estimate_payload_size_small_context():
"""Many images exceed 80% on smaller context (32k)."""
estimate = LLMClient._estimate_payload_size(
image_paths=[f"shot_{i}.png" for i in range(100)],
text_length=2000,
model_context=32000,
)
assert estimate["exceeds_limit"] is True
#endregion test_estimate_payload_size_small_context
#region test_reduce_image_quality_reduces_size [C:2] [TYPE Function]
# @BRIEF T050: _reduce_image_quality reduces image bytes at lower quality setting.
def test_reduce_image_quality_reduces_size(tmp_path):
"""JPEG quality=30 produces smaller payload than quality=85."""
from PIL import Image
# Create a test image
img_path = tmp_path / "test_screenshot.png"
img = Image.new("RGB", (1920, 1080), color="blue")
img.save(img_path)
# Encode at high quality
b64_high, bytes_high = LLMClient._reduce_image_quality(
str(img_path), max_width=1920, image_quality=85,
)
# Encode at low quality
b64_low, bytes_low = LLMClient._reduce_image_quality(
str(img_path), max_width=1920, image_quality=30,
)
assert bytes_low <= bytes_high
assert len(b64_low) <= len(b64_high)
#endregion test_reduce_image_quality_reduces_size
#region test_payload_reduction_triggers_fallback [C:2] [TYPE Function]
# @BRIEF T050: Screenshots exceed 80% context → quality reduction triggered.
# If still exceeded after reduction, Path B fallback should be signaled.
@pytest.mark.anyio
async def test_payload_reduction_triggers_fallback(tmp_path):
"""Multi-chunk screenshots exceeding 80% → quality reduction to 30, then send.
The multimodal method reduces quality when >80% of context window.
After reduction, if still exceeded, it sends anyway (no auto-fallback).
Fallback to Path B is orchestration-level, not within this method.
"""
from PIL import Image
# Create several large test screenshots
screenshot_paths = []
for i in range(8):
img_path = tmp_path / f"tab_{i}.png"
img = Image.new("RGB", (1920, 1080), color=f"hsl({i * 45}, 100%, 50%)")
img.save(img_path, "PNG")
screenshot_paths.append(str(img_path))
client = LLMClient(
provider_type=LLMProviderType.LITELLM,
api_key="sk-test-reduction-key",
base_url="http://localhost:4000/v1",
default_model="gpt-4o",
)
# Mock the LLM call to return a canned response
client.get_json_completion = AsyncMock(return_value={
"status": "PASS",
"summary": "Reduction test",
"issues": [],
})
# Mock _reduce_image_quality to track quality parameter
original_reduce = LLMClient._reduce_image_quality
quality_calls = []
def _tracking_reduce(path, max_width=1024, image_quality=60):
quality_calls.append(image_quality)
return original_reduce(path, max_width, image_quality)
with (
patch.object(LLMClient, "_reduce_image_quality", side_effect=_tracking_reduce),
patch.object(LLMClient, "_estimate_payload_size", return_value={
"estimated_tokens": 200000,
"exceeds_limit": True,
"pct_of_limit": 156.0,
}),
):
result = await client.analyze_dashboard_multimodal(
screenshot_paths=screenshot_paths,
logs=["Session started", "Data loaded"],
prompt_template="Analyze this dashboard:\n{{ logs }}",
)
# Verify reduction was triggered (quality went from 60 to 30)
# First call for each image is with default quality=60 (for estimate)
# Then all images are re-encoded at quality=30
assert any(q == 60 for q in quality_calls), "Initial encode happened"
assert 30 in quality_calls, "Quality reduction to 30 was applied"
# Verify the analysis still returned a valid result
assert result["status"] == "PASS"
client.get_json_completion.assert_called_once()
#endregion test_payload_reduction_triggers_fallback
#region test_payload_reduction_skip_when_small [C:2] [TYPE Function]
# @BRIEF T050: small payload does not trigger reduction.
@pytest.mark.anyio
async def test_payload_reduction_skip_when_small(tmp_path):
"""Single screenshot, short text — no quality reduction."""
from PIL import Image
img_path = tmp_path / "single.png"
img = Image.new("RGB", (800, 600), color="white")
img.save(img_path)
client = LLMClient(
provider_type=LLMProviderType.LITELLM,
api_key="sk-test-small-key",
base_url="http://localhost:4000/v1",
default_model="gpt-4o-mini",
)
client.get_json_completion = AsyncMock(return_value={
"status": "PASS",
"summary": "Small payload OK",
"issues": [],
})
quality_calls = []
original_reduce = LLMClient._reduce_image_quality
def _tracking_reduce(path, max_width=1024, image_quality=60):
quality_calls.append(image_quality)
return original_reduce(path, max_width, image_quality)
with patch.object(LLMClient, "_reduce_image_quality", side_effect=_tracking_reduce):
result = await client.analyze_dashboard_multimodal(
screenshot_paths=[str(img_path)],
logs=["Short log"],
prompt_template="Analyze:\n{{ logs }}",
)
# Only quality=60 should be used (no reduction)
assert set(quality_calls) == {60}, f"Expected only quality=60, got {set(quality_calls)}"
assert result["status"] == "PASS"
#endregion test_payload_reduction_skip_when_small
#endregion TestPayloadReduction

View File

@@ -0,0 +1,206 @@
#region TestURLReparse [C:3] [TYPE Module] [SEMANTICS test, validation, url, reparse, source-invalid]
# @BRIEF Tests for URL source re-parse failure handling: deleted dashboards are detected gracefully.
# @RELATION BINDS_TO -> [ValidationService._parse_superset_link]
# @RELATION BINDS_TO -> [ValidationService._resolve_sources]
# @TEST_SCENARIO parse_failure -> URL with deleted dashboard → ValueError with descriptive message
# @TEST_SCENARIO source_invalid -> parse failure on existing source → source marked "invalid" + error logged
# @TEST_EDGE: missing_env -> URL parse fails when environment doesn't exist
# @TEST_EDGE: missing_config -> URL parse fails when config_manager is None
# @TEST_EDGE: invalid_url -> completely unparseable URL raises ValueError
# @INVARIANT A URL source whose dashboard was deleted must be detected and sources marked invalid
# rather than silently skipped during validation runs.
# @NOTE The _resolve_sources method currently propagates parse failures as ValueError.
# The desired behavior (FR-055) is to catch the error, set source.status="invalid"
# and source.last_error, then continue. The tests below document both current
# and expected behavior.
import pytest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from src.schemas.validation import (
SourceInput,
ValidationTaskCreate,
)
from src.services.validation_service import ValidationTaskService
#region _make_service [C:2] [TYPE Function]
# @BRIEF Build a ValidationTaskService with mock DB and config for isolated testing.
def _make_service():
"""Create ValidationTaskService with mock dependencies for URL re-parse tests."""
mock_db = MagicMock()
mock_config = MagicMock()
# Mock environment lookup — env-1 exists, env-2 does not
env1 = SimpleNamespace(
id="env-1", name="Test Env", url="http://superset.example",
username="user", password="pass", verify_ssl=True, timeout=30,
)
mock_config.get_environment.side_effect = lambda eid: env1 if eid == "env-1" else None
service = ValidationTaskService(db=mock_db, config_manager=mock_config, username="testuser")
return service, mock_db, mock_config
#endregion _make_service
#region test_url_reparse_failure_marks_source_invalid [C:2] [TYPE Function]
# @BRIEF T055: URL source with deleted dashboard → _parse_superset_link raises ValueError.
# @TEST_INVARIANT url_parse_fails_on_deleted_dashboard -> VERIFIED_BY: test_url_reparse_failure_marks_source_invalid
# @RATIONALE When the Superset dashboard referenced by a URL source is deleted,
# SupersetContextExtractor.parse_superset_link raises ValueError.
# The ValidationTaskService must propagate this error so the caller
# can decide whether to mark the source invalid or abort.
def test_url_reparse_failure_marks_source_invalid():
"""URL pointing to deleted dashboard → ValueError with descriptive message.
The current behavior propagates the parse error as a ValueError. The
source is NOT auto-marked invalid in _parse_superset_link — the caller
(_resolve_sources or trigger_run) must catch and handle it.
"""
service, _mock_db, _mock_config = _make_service()
# Mock SupersetContextExtractor — it's imported inside _parse_superset_link
with patch(
"src.core.utils.superset_context_extractor.SupersetContextExtractor"
) as mock_extractor_cls:
mock_extractor_instance = MagicMock()
mock_extractor_cls.return_value = mock_extractor_instance
# Simulate a deleted dashboard: extractor raises ValueError
mock_extractor_instance.parse_superset_link.side_effect = ValueError(
"Dashboard '42' not found on environment 'env-1'"
"the dashboard may have been deleted or the URL is invalid."
)
# _parse_superset_link should propagate the ValueError
with pytest.raises(ValueError) as exc_info:
service._parse_superset_link(
url="http://superset.example/superset/dashboard/42/",
environment_id="env-1",
)
assert "not found" in str(exc_info.value).lower()
assert "deleted" in str(exc_info.value).lower()
#endregion test_url_reparse_failure_marks_source_invalid
#region test_url_reparse_missing_environment [C:2] [TYPE Function]
# @BRIEF T055 edge: non-existent environment → ValueError with clear message.
def test_url_reparse_missing_environment():
"""URL re-parse with non-existent environment → ValueError."""
service, _mock_db, _mock_config = _make_service()
with pytest.raises(ValueError) as exc_info:
service._parse_superset_link(
url="http://superset.example/superset/dashboard/1/",
environment_id="env-nonexistent",
)
assert "not found" in str(exc_info.value).lower()
#endregion test_url_reparse_missing_environment
#region test_url_reparse_no_config [C:2] [TYPE Function]
# @BRIEF T055 edge: config_manager is None → ValueError.
def test_url_reparse_no_config():
"""URL re-parse with no config_manager available → ValueError."""
service = ValidationTaskService(db=MagicMock(), config_manager=None, username="testuser")
with pytest.raises(ValueError) as exc_info:
service._parse_superset_link(
url="http://superset.example/superset/dashboard/1/",
environment_id="env-1",
)
assert "config manager" in str(exc_info.value).lower()
#endregion test_url_reparse_no_config
#region test_url_reparse_create_task_propagates_error [C:2] [TYPE Function]
# @BRIEF T055: create_task with unresolvable URL source returns error — source NOT auto-created.
# @NOTE This documents current behavior: parse failure in _resolve_sources prevents
# task creation. FR-055 expects the source to be created with status="invalid"
# and the task to proceed with remaining valid sources.
def test_url_reparse_create_task_propagates_error():
"""Creating task with unresolvable URL → ValueError from _resolve_sources.
Currently, _resolve_sources does NOT catch parse failures. When a URL
source's dashboard is deleted, the entire task creation fails. Future
behavior should mark the specific source as 'invalid' and continue.
"""
service, _mock_db, _mock_config = _make_service()
# Patch at the source module — import is inside _parse_superset_link
with patch(
"src.core.utils.superset_context_extractor.SupersetContextExtractor"
) as mock_extractor_cls:
mock_extractor_instance = MagicMock()
mock_extractor_cls.return_value = mock_extractor_instance
mock_extractor_instance.parse_superset_link.side_effect = ValueError(
"Dashboard '42' not found"
)
# Mock the LLM provider validation to succeed
mock_provider = MagicMock()
mock_provider.is_active = True
mock_provider.is_multimodal = True
mock_provider.name = "Test Provider"
with patch.object(service, "_validate_provider", return_value=mock_provider):
with pytest.raises(ValueError) as exc_info:
payload = ValidationTaskCreate(
name="URL-Only Task",
environment_id="env-1",
provider_id="provider-1",
sources=[SourceInput(type="dashboard_url", value="http://superset.example/superset/dashboard/42/")],
)
service.create_task(payload)
assert "not found" in str(exc_info.value)
#endregion test_url_reparse_create_task_propagates_error
#region test_url_reparse_with_source_recovery [C:2] [TYPE Function]
# @BRIEF T055: URL with partial-recovery indicator sets source status to "partial_recovery".
def test_url_reparse_with_source_recovery():
"""URL source with partial_recovery → source status 'partial_recovery' (via _resolve_sources)."""
service, _mock_db, _mock_config = _make_service()
# Patch at the source module — import is inside _parse_superset_link
with patch(
"src.core.utils.superset_context_extractor.SupersetContextExtractor"
) as mock_extractor_cls:
mock_extractor_instance = MagicMock()
mock_extractor_cls.return_value = mock_extractor_instance
# Simulate partial recovery: dashboard_id resolved but dataset_ref unclear
from src.core.utils.superset_context_extractor import SupersetParsedContext
mock_extractor_instance.parse_superset_link.return_value = SupersetParsedContext(
source_url="http://superset.example/superset/dashboard/42/",
dataset_ref="dataset:?table=unknown",
dataset_id=None,
dashboard_id=42,
resource_type="dashboard",
partial_recovery=True,
unresolved_references=["dataset_1"],
)
# Directly test _resolve_sources — avoids creating a full policy with mock DB
sources = service._resolve_sources(
sources_input=[
SourceInput(type="dashboard_url", value="http://superset.example/superset/dashboard/42/"),
],
dashboard_ids=None,
policy_id="test-policy-id",
environment_id="env-1",
)
# Verify source was created with partial_recovery status
assert len(sources) == 1
assert sources[0].type == "dashboard_url"
assert sources[0].status == "partial_recovery"
assert sources[0].resolved_dashboard_id == "42"
#endregion test_url_reparse_with_source_recovery
#endregion TestURLReparse