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
200 lines
8.0 KiB
Python
200 lines
8.0 KiB
Python
#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
|