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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user