chore(lint): apply ruff --fix (4443 auto-fixes)

Auto-fixed categories:
- F401: unused imports removed
- I001: import blocks sorted
- W293: trailing whitespace stripped
- UP035: deprecated typing imports replaced
- SIM: simplify suggestions applied
- ARG: unused args prefixed with underscore
- T201: print statements removed
- F841: unused variables removed
- RUF059: unpacked variables prefixed

Remaining ~1500 unfixable errors (C901, B904, N806, E402) require manual work.

Backend smoke tests: 13/13 passed.
This commit is contained in:
2026-05-14 11:20:17 +03:00
parent a9a5eff518
commit c6189876b3
337 changed files with 4677 additions and 4515 deletions

View File

@@ -5,11 +5,12 @@
# @RELATION DEPENDS_on -> pydantic
# @RELATION DEPENDs_on -> pydantic
from typing import List, Optional
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
# #region LLMProviderType [TYPE Class]
# @BRIEF Enum for supported LLM providers.
class LLMProviderType(str, Enum):
@@ -21,11 +22,11 @@ class LLMProviderType(str, Enum):
# #region LLMProviderConfig [TYPE Class]
# @BRIEF Configuration for an LLM provider.
class LLMProviderConfig(BaseModel):
id: Optional[str] = None
id: str | None = None
provider_type: LLMProviderType
name: str
base_url: str
api_key: Optional[str] = None
api_key: str | None = None
default_model: str
is_active: bool = True
# #endregion LLMProviderConfig
@@ -44,20 +45,20 @@ class ValidationStatus(str, Enum):
class DetectedIssue(BaseModel):
severity: ValidationStatus
message: str
location: Optional[str] = None
location: str | None = None
# #endregion DetectedIssue
# #region ValidationResult [TYPE Class]
# @BRIEF Model for dashboard validation result.
class ValidationResult(BaseModel):
id: Optional[str] = None
id: str | None = None
dashboard_id: str
timestamp: datetime = Field(default_factory=datetime.utcnow)
status: ValidationStatus
screenshot_path: Optional[str] = None
issues: List[DetectedIssue]
screenshot_path: str | None = None
issues: list[DetectedIssue]
summary: str
raw_response: Optional[str] = None
raw_response: str | None = None
# #endregion ValidationResult
# #endregion LLMAnalysisModels

View File

@@ -8,32 +8,34 @@
# @RELATION USES -> TaskContext
# @INVARIANT: All LLM interactions must be executed as asynchronous tasks.
from typing import Dict, Any, Optional
import os
import json
import os
from datetime import datetime, timedelta
from ...core.plugin_base import PluginBase
from ...core.logger import belief_scope, logger
from typing import Any
from ...core.database import SessionLocal
from ...services.llm_provider import LLMProviderService
from ...core.logger import belief_scope, logger
from ...core.plugin_base import PluginBase
from ...core.superset_client import SupersetClient
from .service import ScreenshotService, LLMClient
from .models import LLMProviderType, ValidationStatus, ValidationResult, DetectedIssue
from ...models.llm import ValidationRecord, ValidationPolicy
from ...core.task_manager.context import TaskContext
from ...services.notifications.service import NotificationService
from ...models.llm import ValidationPolicy, ValidationRecord
from ...services.llm_prompt_templates import (
DEFAULT_LLM_PROMPTS,
is_multimodal_model,
normalize_llm_settings,
render_prompt,
)
from ...services.llm_provider import LLMProviderService
from ...services.notifications.service import NotificationService
from .models import DetectedIssue, LLMProviderType, ValidationResult, ValidationStatus
from .service import LLMClient, ScreenshotService
# #region _is_masked_or_invalid_api_key [TYPE Function]
# @BRIEF Guards against placeholder or malformed API keys in runtime.
# @PRE: value may be None.
# @POST: Returns True when value cannot be used for authenticated provider calls.
def _is_masked_or_invalid_api_key(value: Optional[str]) -> bool:
def _is_masked_or_invalid_api_key(value: str | None) -> bool:
key = (value or "").strip()
if not key:
return True
@@ -77,7 +79,7 @@ class DashboardValidationPlugin(PluginBase):
def version(self) -> str:
return "1.0.0"
def get_schema(self) -> Dict[str, Any]:
def get_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
@@ -95,19 +97,19 @@ class DashboardValidationPlugin(PluginBase):
# @PRE: params contains dashboard_id, environment_id, and provider_id.
# @POST: Returns a dictionary with validation results and persists them to the database.
# @SIDE_EFFECT: Captures a screenshot, calls LLM API, and writes to the database.
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
with belief_scope("execute", f"plugin_id={self.id}"):
validation_started_at = datetime.utcnow()
# Use TaskContext logger if available, otherwise fall back to app logger
log = context.logger if context else logger
# Create sub-loggers for different components
llm_log = log.with_source("llm") if context else log
screenshot_log = log.with_source("screenshot") if context else log
superset_log = log.with_source("superset_api") if context else log
log.info(f"Executing {self.name} with params: {params}")
dashboard_id_raw = params.get("dashboard_id")
dashboard_id = str(dashboard_id_raw) if dashboard_id_raw is not None else None
env_id = params.get("environment_id")
@@ -129,7 +131,7 @@ class DashboardValidationPlugin(PluginBase):
if not db_provider:
log.error(f"LLM Provider {provider_id} not found")
raise ValueError(f"LLM Provider {provider_id} not found")
llm_log.debug("Retrieved provider config:")
llm_log.debug(f" Provider ID: {db_provider.id}")
llm_log.debug(f" Provider Name: {db_provider.name}")
@@ -141,10 +143,10 @@ class DashboardValidationPlugin(PluginBase):
raise ValueError(
"Dashboard validation requires a multimodal model (image input support)."
)
api_key = llm_service.get_decrypted_api_key(provider_id)
llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")
# Check if API key was successfully decrypted
if _is_masked_or_invalid_api_key(api_key):
raise ValueError(
@@ -154,14 +156,14 @@ class DashboardValidationPlugin(PluginBase):
# 3. Capture Screenshot
screenshot_service = ScreenshotService(env)
storage_root = config_mgr.get_config().settings.storage.root_path
screenshots_dir = os.path.join(storage_root, "screenshots")
os.makedirs(screenshots_dir, exist_ok=True)
filename = f"{dashboard_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
screenshot_path = os.path.join(screenshots_dir, filename)
screenshot_started_at = datetime.utcnow()
screenshot_log.info(f"Capturing screenshot for dashboard {dashboard_id}")
await screenshot_service.capture_dashboard(dashboard_id, screenshot_path)
@@ -173,10 +175,10 @@ class DashboardValidationPlugin(PluginBase):
logs_fetch_started_at = datetime.utcnow()
try:
client = SupersetClient(env)
# Calculate time window (last 24 hours)
start_time = (datetime.now() - timedelta(hours=24)).isoformat()
# Construct filter for logs
# Note: We filter by dashboard_id matching the object
query_params = {
@@ -189,28 +191,28 @@ class DashboardValidationPlugin(PluginBase):
"page": 0,
"page_size": 100
}
superset_log.debug(f"Fetching logs for dashboard {dashboard_id}")
response = client.network.request(
method="GET",
endpoint="/log/",
params={"q": json.dumps(query_params)}
)
if isinstance(response, dict) and "result" in response:
for item in response["result"]:
action = item.get("action", "unknown")
dttm = item.get("dttm", "")
details = item.get("json", "")
logs.append(f"[{dttm}] {action}: {details}")
if not logs:
logs = ["No recent logs found for this dashboard."]
superset_log.debug("No recent logs found for this dashboard")
except Exception as e:
superset_log.warning(f"Failed to fetch logs from environment: {e}")
logs = [f"Error fetching remote logs: {str(e)}"]
logs = [f"Error fetching remote logs: {e!s}"]
logs_fetch_finished_at = datetime.utcnow()
# 5. Analyze with LLM
@@ -220,7 +222,7 @@ class DashboardValidationPlugin(PluginBase):
base_url=db_provider.base_url,
default_model=db_provider.default_model
)
llm_log.info(f"Analyzing dashboard {dashboard_id} with LLM")
llm_settings = normalize_llm_settings(config_mgr.get_config().settings.llm)
dashboard_prompt = llm_settings["prompts"].get(
@@ -234,7 +236,7 @@ class DashboardValidationPlugin(PluginBase):
prompt_template=dashboard_prompt,
)
llm_call_finished_at = datetime.utcnow()
# Log analysis summary to task logs for better visibility
llm_log.info(f"[ANALYSIS_SUMMARY] Status: {analysis['status']}")
llm_log.info(f"[ANALYSIS_SUMMARY] Summary: {analysis['summary']}")
@@ -300,7 +302,7 @@ class DashboardValidationPlugin(PluginBase):
policy = None
if policy_id:
policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == policy_id).first()
notification_service = NotificationService(db, config_mgr)
await notification_service.dispatch_report(
record=db_record,
@@ -312,7 +314,7 @@ class DashboardValidationPlugin(PluginBase):
# Final log to ensure all analysis is visible in task logs
log.info(f"Validation completed for dashboard {dashboard_id}. Status: {validation_result.status.value}")
return result_payload
finally:
@@ -340,7 +342,7 @@ class DocumentationPlugin(PluginBase):
def version(self) -> str:
return "1.0.0"
def get_schema(self) -> Dict[str, Any]:
def get_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
@@ -358,17 +360,17 @@ class DocumentationPlugin(PluginBase):
# @PRE: params contains dataset_id, environment_id, and provider_id.
# @POST: Returns generated documentation and updates the dataset in Superset.
# @SIDE_EFFECT: Calls LLM API and updates dataset metadata in Superset.
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
with belief_scope("execute", f"plugin_id={self.id}"):
# Use TaskContext logger if available, otherwise fall back to app logger
log = context.logger if context else logger
# Create sub-loggers for different components
llm_log = log.with_source("llm") if context else log
superset_log = log.with_source("superset_api") if context else log
log.info(f"Executing {self.name} with params: {params}")
dataset_id = params.get("dataset_id")
env_id = params.get("environment_id")
provider_id = params.get("provider_id")
@@ -389,17 +391,17 @@ class DocumentationPlugin(PluginBase):
if not db_provider:
log.error(f"LLM Provider {provider_id} not found")
raise ValueError(f"LLM Provider {provider_id} not found")
llm_log.debug("Retrieved provider config:")
llm_log.debug(f" Provider ID: {db_provider.id}")
llm_log.debug(f" Provider Name: {db_provider.name}")
llm_log.debug(f" Provider Type: {db_provider.provider_type}")
llm_log.debug(f" Base URL: {db_provider.base_url}")
llm_log.debug(f" Default Model: {db_provider.default_model}")
api_key = llm_service.get_decrypted_api_key(provider_id)
llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")
# Check if API key was successfully decrypted
if _is_masked_or_invalid_api_key(api_key):
raise ValueError(
@@ -410,10 +412,10 @@ class DocumentationPlugin(PluginBase):
# 3. Fetch Metadata (US2 / T024)
from ...core.superset_client import SupersetClient
client = SupersetClient(env)
superset_log.debug(f"Fetching dataset {dataset_id}")
dataset = client.get_dataset(int(dataset_id))
# Extract columns and existing descriptions
columns_data = []
for col in dataset.get("columns", []):
@@ -431,7 +433,7 @@ class DocumentationPlugin(PluginBase):
base_url=db_provider.base_url,
default_model=db_provider.default_model
)
llm_settings = normalize_llm_settings(config_mgr.get_config().settings.llm)
documentation_prompt = llm_settings["prompts"].get(
"documentation_prompt",
@@ -444,7 +446,7 @@ class DocumentationPlugin(PluginBase):
"columns_json": json.dumps(columns_data, ensure_ascii=False),
},
)
# Using a generic chat completion for text-only US2
llm_log.info(f"Generating documentation for dataset {dataset_id}")
doc_result = await llm_client.get_json_completion([{"role": "user", "content": prompt}])
@@ -454,7 +456,7 @@ class DocumentationPlugin(PluginBase):
"description": doc_result["dataset_description"],
"columns": []
}
# Map generated descriptions back to column IDs
for col_doc in doc_result["column_descriptions"]:
for col in dataset.get("columns", []):
@@ -466,9 +468,9 @@ class DocumentationPlugin(PluginBase):
superset_log.info(f"Updating dataset {dataset_id} with generated documentation")
client.update_dataset(int(dataset_id), update_payload)
log.info(f"Documentation completed for dataset {dataset_id}")
return doc_result
finally:

View File

@@ -3,20 +3,22 @@
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [SchedulerService]
from typing import Dict, Any
from ...dependencies import get_task_manager, get_scheduler_service
from typing import Any
from ...core.logger import belief_scope, logger
from ...dependencies import get_scheduler_service, get_task_manager
# #region schedule_dashboard_validation [TYPE Function]
# @BRIEF Schedules a recurring dashboard validation task.
# @SIDE_EFFECT: Adds a job to the scheduler service.
def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, params: Dict[str, Any]):
def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, params: dict[str, Any]):
with belief_scope("schedule_dashboard_validation", f"dashboard_id={dashboard_id}"):
scheduler = get_scheduler_service()
task_manager = get_task_manager()
job_id = f"llm_val_{dashboard_id}"
async def job_func():
await task_manager.create_task(
plugin_id="llm_dashboard_validation",
@@ -38,7 +40,7 @@ def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, param
# #region _parse_cron [TYPE Function]
# @BRIEF Basic cron parser placeholder.
def _parse_cron(cron: str) -> Dict[str, str]:
def _parse_cron(cron: str) -> dict[str, str]:
# Basic cron parser placeholder
parts = cron.split()
if len(parts) != 5:

View File

@@ -8,20 +8,24 @@
import asyncio
import base64
import json
import io
import json
import os
from typing import Any
from urllib.parse import urlsplit
from typing import List, Dict, Any
import httpx
from openai import AsyncOpenAI, RateLimitError
from openai import AuthenticationError as OpenAIAuthenticationError
from PIL import Image
from playwright.async_api import async_playwright
from openai import AsyncOpenAI, RateLimitError, AuthenticationError as OpenAIAuthenticationError
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
from .models import LLMProviderType
from ...core.logger import belief_scope, logger
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
from ...core.config_models import Environment
from ...core.logger import belief_scope, logger
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
from .models import LLMProviderType
# #region ScreenshotService [TYPE Class]
# @BRIEF Handles capturing screenshots of Superset dashboards.
@@ -54,7 +58,7 @@ class ScreenshotService:
# @PURPOSE: Enumerate page and child frames where login controls may be rendered.
# @PRE: page is a Playwright page-like object.
# @POST: Returns ordered roots starting with main page followed by frames.
def _iter_login_roots(self, page) -> List[Any]:
def _iter_login_roots(self, page) -> list[Any]:
roots = [page]
page_frames = getattr(page, "frames", [])
try:
@@ -70,8 +74,8 @@ class ScreenshotService:
# @PURPOSE: Collect hidden form fields required for direct login POST fallback.
# @PRE: Login page is loaded.
# @POST: Returns hidden input name/value mapping aggregated from page and child frames.
async def _extract_hidden_login_fields(self, page) -> Dict[str, str]:
hidden_fields: Dict[str, str] = {}
async def _extract_hidden_login_fields(self, page) -> dict[str, str]:
hidden_fields: dict[str, str] = {}
for root in self._iter_login_roots(page):
try:
locator = root.locator("input[type='hidden'][name]")
@@ -296,7 +300,7 @@ class ScreenshotService:
base_ui_url = self.env.url.rstrip("/")
if base_ui_url.endswith("/api/v1"):
base_ui_url = base_ui_url[:-len("/api/v1")]
# Create browser context with realistic headers
context = await browser.new_context(
viewport={'width': 1280, 'height': 720},
@@ -320,7 +324,7 @@ class ScreenshotService:
# 1. Navigate to login page and authenticate
login_url = f"{base_ui_url.rstrip('/')}/login/"
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
response = await self._goto_resilient(
page,
login_url,
@@ -330,10 +334,10 @@ class ScreenshotService:
)
if response:
logger.info(f"[DEBUG] Login page response status: {response.status}")
# Wait for login form to be ready
await page.wait_for_load_state("domcontentloaded")
# More exhaustive list of selectors for various Superset versions/themes
selectors = {
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
@@ -341,7 +345,7 @@ class ScreenshotService:
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
}
logger.info("[DEBUG] Attempting to find login form elements...")
try:
used_direct_form_login = False
# Find and fill username
@@ -362,21 +366,21 @@ class ScreenshotService:
if not used_direct_form_login:
raise RuntimeError("Could not find username input field on login page")
username_locator = None
if username_locator is not None:
logger.info("[DEBUG] Filling username field")
await username_locator.fill(self.env.username)
# Find and fill password
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
if username_locator is not None and not password_locator:
raise RuntimeError("Could not find password input field on login page")
if password_locator is not None:
logger.info("[DEBUG] Filling password field")
await password_locator.fill(self.env.password)
# Click submit
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
@@ -386,14 +390,14 @@ class ScreenshotService:
if submit_locator is not None:
logger.info("[DEBUG] Clicking submit button")
await submit_locator.click()
# Wait for navigation after login
if not used_direct_form_login:
try:
await page.wait_for_load_state("load", timeout=30000)
except Exception as load_wait_error:
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
# Check if login was successful
if not used_direct_form_login and "/login" in page.url:
# Check for error messages on page
@@ -402,31 +406,31 @@ class ScreenshotService:
debug_path = output_path.replace(".png", "_debug_failed_login.png")
await page.screenshot(path=debug_path)
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
# Check cookies after successful login
page_cookies = await context.cookies()
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
for c in page_cookies:
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
except Exception as e:
page_title = await page.title()
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {str(e)}")
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {e!s}")
debug_path = output_path.replace(".png", "_debug_failed_login.png")
await page.screenshot(path=debug_path)
raise RuntimeError(f"Login failed: {str(e)}. Debug screenshot saved to {debug_path}")
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
# 2. Navigate to dashboard
# @UX_STATE: [Navigating] -> Loading dashboard UI
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
dashboard_url = dashboard_url.replace("http://", "https://")
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
# Dashboard pages can keep polling/network activity open indefinitely.
response = await self._goto_resilient(
page,
@@ -435,7 +439,7 @@ class ScreenshotService:
fallback_wait_until="load",
timeout=60000,
)
if response:
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
@@ -445,12 +449,12 @@ class ScreenshotService:
raise RuntimeError(
f"Dashboard navigation redirected to login page after authentication. Debug screenshot saved to {debug_path}"
)
try:
# Wait for the dashboard grid to be present
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=30000)
logger.info("[DEBUG] Dashboard container loaded")
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
# We wait until loading indicators disappear or a timeout occurs
try:
@@ -498,7 +502,7 @@ class ScreenshotService:
except Exception as e:
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
# Final stabilization delay - increased for complex dashboards
logger.info("[DEBUG] Final stabilization delay...")
await asyncio.sleep(15)
@@ -512,42 +516,42 @@ class ScreenshotService:
async def switch_tabs(depth=0):
if depth > 3:
return # Limit recursion depth
tab_selectors = [
'.ant-tabs-nav-list .ant-tabs-tab',
'.dashboard-component-tabs .ant-tabs-tab',
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
]
found_tabs = []
for selector in tab_selectors:
found_tabs = await page.locator(selector).all()
if found_tabs:
break
if found_tabs:
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
for i, tab in enumerate(found_tabs):
try:
tab_text = (await tab.inner_text()).strip()
tab_id = f"{depth}_{i}_{tab_text}"
if tab_id in processed_tabs:
continue
if await tab.is_visible():
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
processed_tabs.add(tab_id)
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
if not is_active:
await tab.click()
await asyncio.sleep(2) # Wait for content to render
await switch_tabs(depth + 1)
except Exception as tab_e:
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
try:
first_tab = found_tabs[0]
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
@@ -572,7 +576,7 @@ class ScreenshotService:
);
}""")
logger.info(f"[DEBUG] Calculated full height: {full_height}")
# DIAGNOSTIC: Count chart elements before resize
chart_count_before = await page.evaluate("""() => {
return {
@@ -583,7 +587,7 @@ class ScreenshotService:
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
pre_resize_path = output_path.replace(".png", "_preresize.png")
try:
@@ -593,15 +597,15 @@ class ScreenshotService:
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
except Exception as pre_e:
logger.warning(f"[DIAGNOSTIC] Failed to capture pre-resize screenshot: {pre_e}")
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
# DIAGNOSTIC: Increased wait time and log timing
logger.info("[DIAGNOSTIC] Waiting 10 seconds after viewport resize for re-render...")
await asyncio.sleep(10)
logger.info("[DIAGNOSTIC] Wait completed")
# DIAGNOSTIC: Count chart elements after resize and wait
chart_count_after = await page.evaluate("""() => {
return {
@@ -612,7 +616,7 @@ class ScreenshotService:
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
# DIAGNOSTIC: Check if any charts have error states
chart_errors = await page.evaluate("""() => {
const errors = [];
@@ -633,31 +637,31 @@ class ScreenshotService:
# @UX_STATE: [Capturing] -> Executing CDP screenshot
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
cdp = await page.context.new_cdp_session(page)
screenshot_data = await cdp.send("Page.captureScreenshot", {
"format": "png",
"fromSurface": True,
"captureBeyondViewport": True
})
image_data = base64.b64decode(screenshot_data["data"])
with open(output_path, 'wb') as f:
f.write(image_data)
# DIAGNOSTIC: Verify screenshot file
import os
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
# DIAGNOSTIC: Get image dimensions
try:
with Image.open(output_path) as final_img:
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
except Exception as img_err:
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
logger.info(f"Full-page screenshot saved to {output_path} (via CDP)")
except Exception as e:
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
@@ -666,7 +670,7 @@ class ScreenshotService:
except Exception as e2:
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
await page.screenshot(path=output_path, timeout=5000)
await browser.close()
return True
# endregion ScreenshotService.capture_dashboard
@@ -686,7 +690,7 @@ class LLMClient:
self.api_key = normalized_key
self.base_url = base_url
self.default_model = default_model
# DEBUG: Log initialization parameters (without exposing full API key)
logger.info("[LLMClient.__init__] Initializing LLM client:")
logger.info(f"[LLMClient.__init__] Provider Type: {provider_type}")
@@ -749,14 +753,14 @@ class LLMClient:
return False
# Retry on rate limit errors and other exceptions
return isinstance(exception, (RateLimitError, Exception))
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=2, min=5, max=60),
retry=retry_if_exception(_should_retry),
reraise=True
)
async def get_json_completion(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
async def get_json_completion(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
with belief_scope("get_json_completion"):
response = None
try:
@@ -788,22 +792,22 @@ class LLMClient:
or "response_format" in str(e).lower()
or "400" in str(e)
):
logger.warning(f"[get_json_completion] JSON mode failed or not supported: {str(e)}. Falling back to plain text response.")
logger.warning(f"[get_json_completion] JSON mode failed or not supported: {e!s}. Falling back to plain text response.")
response = await self.client.chat.completions.create(
model=self.default_model,
messages=messages
)
else:
raise e
logger.debug(f"[get_json_completion] LLM Response: {response}")
except OpenAIAuthenticationError as e:
logger.error(f"[get_json_completion] Authentication error: {str(e)}")
logger.error(f"[get_json_completion] Authentication error: {e!s}")
# Do not retry on auth errors - re-raise to stop retry
raise
except RateLimitError as e:
logger.warning(f"[get_json_completion] Rate limit hit: {str(e)}")
logger.warning(f"[get_json_completion] Rate limit hit: {e!s}")
# Extract retry_delay from error metadata if available
retry_delay = 5.0 # Default fallback
try:
@@ -811,7 +815,7 @@ class LLMClient:
# The logs show 'metadata': {'raw': '...'} which suggests a proxy or specific client wrapper
# Let's try to find the 'retryDelay' in the error message or response
import re
# Try to find "retryDelay": "XXs" in the string representation of the error
error_str = str(e)
match = re.search(r'"retryDelay":\s*"(\d+)s"', error_str)
@@ -829,14 +833,14 @@ class LLMClient:
break
except Exception as parse_e:
logger.debug(f"[get_json_completion] Failed to parse retry delay: {parse_e}")
# Add a small safety margin (0.5s) as requested
wait_time = retry_delay + 0.5
logger.info(f"[get_json_completion] Waiting for {wait_time}s before retry...")
await asyncio.sleep(wait_time)
raise
except Exception as e:
logger.error(f"[get_json_completion] LLM call failed: {str(e)}")
logger.error(f"[get_json_completion] LLM call failed: {e!s}")
raise
if not response or not hasattr(response, 'choices') or not response.choices:
@@ -844,7 +848,7 @@ class LLMClient:
content = response.choices[0].message.content
logger.debug(f"[get_json_completion] Raw content to parse: {content}")
try:
return json.loads(content)
except json.JSONDecodeError:
@@ -864,7 +868,7 @@ class LLMClient:
# @PRE: Client is initialized with provider credentials and default_model.
# @POST: Returns lightweight JSON payload when runtime auth/model path is valid.
# @SIDE_EFFECT: Calls external LLM API.
async def test_runtime_connection(self) -> Dict[str, Any]:
async def test_runtime_connection(self) -> dict[str, Any]:
with belief_scope("test_runtime_connection"):
messages = [
{
@@ -880,7 +884,7 @@ class LLMClient:
# @PRE: Client is initialized with provider credentials.
# @POST: Returns a list of model ID strings.
# @SIDE_EFFECT: Calls external LLM API /v1/models endpoint.
async def fetch_models(self) -> List[str]:
async def fetch_models(self) -> list[str]:
with belief_scope("LLMClient.fetch_models"):
try:
response = await self.client.models.list()
@@ -906,9 +910,9 @@ class LLMClient:
async def analyze_dashboard(
self,
screenshot_path: str,
logs: List[str],
logs: list[str],
prompt_template: str = DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
) -> Dict[str, Any]:
) -> dict[str, Any]:
with belief_scope("analyze_dashboard"):
# Optimize image to reduce token count (US1 / T023)
# Gemini/Gemma models have limits on input tokens, and large images contribute significantly.
@@ -917,7 +921,7 @@ class LLMClient:
# Convert to RGB if necessary
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Resize if too large (max 1024px width while maintaining aspect ratio)
# We reduce width further to 1024px to stay within token limits for long dashboards
max_width = 1024
@@ -929,7 +933,7 @@ class LLMClient:
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
logger.info(f"[analyze_dashboard] Resized image from {img.width}x{img.height} to {new_width}x{new_height}")
# Compress and convert to base64
buffer = io.BytesIO()
# Lower quality to 60% to further reduce payload size
@@ -943,7 +947,7 @@ class LLMClient:
log_text = "\n".join(logs)
prompt = render_prompt(prompt_template, {"logs": log_text})
messages = [
{
"role": "user",
@@ -958,14 +962,14 @@ class LLMClient:
]
}
]
try:
return await self.get_json_completion(messages)
except Exception as e:
logger.error(f"[analyze_dashboard] Failed to get analysis: {str(e)}")
logger.error(f"[analyze_dashboard] Failed to get analysis: {e!s}")
return {
"status": "UNKNOWN",
"summary": f"Failed to get response from LLM: {str(e)}",
"summary": f"Failed to get response from LLM: {e!s}",
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
}
# endregion LLMClient.analyze_dashboard