This commit is contained in:
2026-05-20 23:54:53 +03:00
parent a43886106e
commit 68740cd9ed
167 changed files with 10620 additions and 29041 deletions

View File

@@ -9,9 +9,9 @@
# @INVARIANT: All LLM interactions must be executed as asynchronous tasks.
# @DATA_CONTRACT: AnalysisRequest -> AnalysisResult
from datetime import datetime, timedelta
import json
import os
from datetime import datetime, timedelta
from typing import Any
from ...core.database import SessionLocal

View File

@@ -1,11 +1,12 @@
# #region LLMAnalysisService [C:5] [TYPE Module] [SEMANTICS llm, screenshot, playwright, openai, tenacity]
# @BRIEF Services for LLM interaction and dashboard screenshots.
# @LAYER: Plugin
# @RELATION DEPENDS_ON -> playwright
# @RELATION DEPENDS_ON -> openai
# @LAYER Plugin
# @RELATION DEPENDS_ON -> tenacity
# @INVARIANT: Screenshots must be 1920px width and capture full page height.
# @DATA_CONTRACT: DashboardSpec -> Screenshot + Analysis
# @RELATION DEPENDS_ON -> tenacity
# @RELATION DEPENDS_ON -> tenacity
# @INVARIANT Screenshots must be 1920px width and capture full page height.
# @DATA_CONTRACT DashboardSpec -> Screenshot + Analysis
# @RATIONALE Extracted all hardcoded timeouts into named module-level constants (PLAYWRIGHT_NAVIGATION_TIMEOUT_MS, PLAYWRIGHT_WAIT_TIMEOUT_MS, PLAYWRIGHT_SHORT_TIMEOUT_MS, HTTP_REQUEST_TIMEOUT_MS, SCREENSHOT_SERVICE_TIMEOUT_MS, LLM_HTTP_TIMEOUT_S) and DEFAULT_USER_AGENT. Zero remaining numeric timeout literals.
import asyncio
import base64
@@ -16,8 +17,7 @@ from typing import Any
from urllib.parse import urlsplit
import httpx
from openai import AsyncOpenAI, RateLimitError
from openai import AuthenticationError as OpenAIAuthenticationError
from openai import AsyncOpenAI, AuthenticationError as OpenAIAuthenticationError, RateLimitError
from PIL import Image
from playwright.async_api import async_playwright
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
@@ -27,21 +27,29 @@ from ...core.logger import belief_scope, logger
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
from .models import LLMProviderType
# Timeout constants (milliseconds unless noted)
PLAYWRIGHT_NAVIGATION_TIMEOUT_MS = 30000
PLAYWRIGHT_WAIT_TIMEOUT_MS = 10000
PLAYWRIGHT_SHORT_TIMEOUT_MS = 5000
HTTP_REQUEST_TIMEOUT_MS = 60000
SCREENSHOT_SERVICE_TIMEOUT_MS = 120000
LLM_HTTP_TIMEOUT_S = 120 # seconds (httpx client timeout)
DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
# #region ScreenshotService [TYPE Class]
# @BRIEF Handles capturing screenshots of Superset dashboards.
class ScreenshotService:
# region ScreenshotService.__init__ [TYPE Function]
# @PURPOSE: Initializes the ScreenshotService with environment configuration.
# @PRE: env is a valid Environment object.
# @PURPOSE Initializes the ScreenshotService with environment configuration.
# @PRE env is a valid Environment object.
def __init__(self, env: Environment):
self.env = env
# endregion ScreenshotService.__init__
# region ScreenshotService._find_first_visible_locator [TYPE Function]
# @PURPOSE: Resolve the first visible locator from multiple Playwright locator strategies.
# @PRE: candidates is a non-empty list of locator-like objects.
# @POST: Returns a locator ready for interaction or None when nothing matches.
# @PURPOSE Resolve the first visible locator from multiple Playwright locator strategies.
# @PRE candidates is a non-empty list of locator-like objects.
# @POST Returns a locator ready for interaction or None when nothing matches.
async def _find_first_visible_locator(self, candidates) -> Any:
for locator in candidates:
try:
@@ -56,9 +64,9 @@ class ScreenshotService:
# endregion ScreenshotService._find_first_visible_locator
# region ScreenshotService._iter_login_roots [TYPE Function]
# @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.
# @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]:
roots = [page]
page_frames = getattr(page, "frames", [])
@@ -72,9 +80,9 @@ class ScreenshotService:
# endregion ScreenshotService._iter_login_roots
# region ScreenshotService._extract_hidden_login_fields [TYPE Function]
# @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.
# @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] = {}
for root in self._iter_login_roots(page):
@@ -93,18 +101,18 @@ class ScreenshotService:
# endregion ScreenshotService._extract_hidden_login_fields
# region ScreenshotService._extract_csrf_token [TYPE Function]
# @PURPOSE: Resolve CSRF token value from main page or embedded login frame.
# @PRE: Login page is loaded.
# @POST: Returns first non-empty csrf token or empty string.
# @PURPOSE Resolve CSRF token value from main page or embedded login frame.
# @PRE Login page is loaded.
# @POST Returns first non-empty csrf token or empty string.
async def _extract_csrf_token(self, page) -> str:
hidden_fields = await self._extract_hidden_login_fields(page)
return str(hidden_fields.get("csrf_token") or "").strip()
# endregion ScreenshotService._extract_csrf_token
# region ScreenshotService._response_looks_like_login_page [TYPE Function]
# @PURPOSE: Detect when fallback login POST returned the login form again instead of an authenticated page.
# @PRE: response_text is normalized HTML or text from login POST response.
# @POST: Returns True when login-page markers dominate the response body.
# @PURPOSE Detect when fallback login POST returned the login form again instead of an authenticated page.
# @PRE response_text is normalized HTML or text from login POST response.
# @POST Returns True when login-page markers dominate the response body.
def _response_looks_like_login_page(self, response_text: str) -> bool:
normalized = str(response_text or "").strip().lower()
if not normalized:
@@ -121,9 +129,9 @@ class ScreenshotService:
# endregion ScreenshotService._response_looks_like_login_page
# region ScreenshotService._redirect_looks_authenticated [TYPE Function]
# @PURPOSE: Treat non-login redirects after form POST as successful authentication without waiting for redirect target.
# @PRE: redirect_location may be empty or relative.
# @POST: Returns True when redirect target does not point back to login flow.
# @PURPOSE Treat non-login redirects after form POST as successful authentication without waiting for redirect target.
# @PRE redirect_location may be empty or relative.
# @POST Returns True when redirect target does not point back to login flow.
def _redirect_looks_authenticated(self, redirect_location: str) -> bool:
normalized = str(redirect_location or "").strip().lower()
if not normalized:
@@ -132,9 +140,9 @@ class ScreenshotService:
# endregion ScreenshotService._redirect_looks_authenticated
# region ScreenshotService._submit_login_via_form_post [TYPE Function]
# @PURPOSE: Fallback login path that submits credentials directly with csrf token.
# @PRE: login_url is same-origin and csrf token can be read from DOM.
# @POST: Browser context receives authenticated cookies when login succeeds.
# @PURPOSE Fallback login path that submits credentials directly with csrf token.
# @PRE login_url is same-origin and csrf token can be read from DOM.
# @POST Browser context receives authenticated cookies when login succeeds.
async def _submit_login_via_form_post(self, page, login_url: str) -> bool:
hidden_fields = await self._extract_hidden_login_fields(page)
csrf_token = str(hidden_fields.get("csrf_token") or "").strip()
@@ -164,7 +172,7 @@ class ScreenshotService:
"Origin": origin,
"Referer": login_url,
},
timeout=10000,
timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS,
fail_on_status_code=False,
max_redirects=0,
)
@@ -194,9 +202,9 @@ class ScreenshotService:
# endregion ScreenshotService._submit_login_via_form_post
# region ScreenshotService._find_login_field_locator [TYPE Function]
# @PURPOSE: Resolve login form input using semantic label text plus generic visible-input fallbacks.
# @PRE: field_name is `username` or `password`.
# @POST: Returns a locator for the corresponding input or None.
# @PURPOSE Resolve login form input using semantic label text plus generic visible-input fallbacks.
# @PRE field_name is `username` or `password`.
# @POST Returns a locator for the corresponding input or None.
async def _find_login_field_locator(self, page, field_name: str) -> Any:
normalized = str(field_name or "").strip().lower()
for root in self._iter_login_roots(page):
@@ -234,9 +242,9 @@ class ScreenshotService:
# endregion ScreenshotService._find_login_field_locator
# region ScreenshotService._find_submit_locator [TYPE Function]
# @PURPOSE: Resolve login submit button from main page or embedded auth frame.
# @PRE: page is ready for login interaction.
# @POST: Returns visible submit locator or None.
# @PURPOSE Resolve login submit button from main page or embedded auth frame.
# @PRE page is ready for login interaction.
# @POST Returns visible submit locator or None.
async def _find_submit_locator(self, page) -> Any:
selectors = [
lambda root: root.get_by_role("button", name="Sign in", exact=False),
@@ -254,16 +262,16 @@ class ScreenshotService:
# endregion ScreenshotService._find_submit_locator
# region ScreenshotService._goto_resilient [TYPE Function]
# @PURPOSE: Navigate without relying on networkidle for pages with long-polling or persistent requests.
# @PRE: page is a valid Playwright page and url is non-empty.
# @POST: Returns last navigation response or raises when both primary and fallback waits fail.
# @PURPOSE Navigate without relying on networkidle for pages with long-polling or persistent requests.
# @PRE page is a valid Playwright page and url is non-empty.
# @POST Returns last navigation response or raises when both primary and fallback waits fail.
async def _goto_resilient(
self,
page,
url: str,
primary_wait_until: str = "domcontentloaded",
fallback_wait_until: str = "load",
timeout: int = 60000,
timeout: int = HTTP_REQUEST_TIMEOUT_MS,
):
try:
return await page.goto(url, wait_until=primary_wait_until, timeout=timeout)
@@ -275,14 +283,14 @@ class ScreenshotService:
# endregion ScreenshotService._goto_resilient
# region ScreenshotService.capture_dashboard [TYPE Function]
# @PURPOSE: Captures a full-page screenshot of a dashboard using Playwright and CDP.
# @PRE: dashboard_id is a valid string, output_path is a writable path.
# @POST: Returns True if screenshot is saved successfully.
# @SIDE_EFFECT: Launches a browser, performs UI login, switches tabs, and writes a PNG file.
# @UX_STATE: [Navigating] -> Loading dashboard UI
# @UX_STATE: [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
# @UX_STATE: [CalculatingHeight] -> Determining dashboard dimensions
# @UX_STATE: [Capturing] -> Executing CDP screenshot
# @PURPOSE Captures a full-page screenshot of a dashboard using Playwright and CDP.
# @PRE dashboard_id is a valid string, output_path is a writable path.
# @POST Returns True if screenshot is saved successfully.
# @SIDE_EFFECT Launches a browser, performs UI login, switches tabs, and writes a PNG file.
# @UX_STATE [Navigating] -> Loading dashboard UI
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
# @UX_STATE [Capturing] -> Executing CDP screenshot
async def capture_dashboard(self, dashboard_id: str, output_path: str) -> bool:
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
@@ -331,7 +339,7 @@ class ScreenshotService:
login_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=60000,
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if response:
logger.info(f"[DEBUG] Login page response status: {response.status}")
@@ -395,7 +403,7 @@ class ScreenshotService:
# Wait for navigation after login
if not used_direct_form_login:
try:
await page.wait_for_load_state("load", timeout=30000)
await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
except Exception as load_wait_error:
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
@@ -424,7 +432,7 @@ class ScreenshotService:
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
# 2. Navigate to dashboard
# @UX_STATE: [Navigating] -> Loading dashboard UI
# @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://"):
@@ -438,7 +446,7 @@ class ScreenshotService:
dashboard_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=60000,
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if response:
@@ -453,14 +461,14 @@ class ScreenshotService:
try:
# Wait for the dashboard grid to be present
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=30000)
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
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:
# Wait for loading indicators to disappear
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=60000)
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Loading indicators hidden")
except Exception:
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
@@ -468,7 +476,7 @@ class ScreenshotService:
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
# We look for common chart containers that should have content
try:
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=60000)
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Chart content detected")
except Exception:
logger.warning("[DEBUG] Timeout waiting for chart content")
@@ -486,7 +494,7 @@ class ScreenshotService:
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
return hasCanvas || hasSvg || hasContent;
});
}""", timeout=60000)
}""", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] All charts have rendered content")
# Scroll to bottom and back to top to trigger lazy loading of all charts
@@ -511,7 +519,7 @@ class ScreenshotService:
# Logic to handle tabs and full-page capture
try:
# 1. Handle Tabs (Recursive switching)
# @UX_STATE: [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
processed_tabs = set()
async def switch_tabs(depth=0):
@@ -564,7 +572,7 @@ class ScreenshotService:
await switch_tabs()
# 2. Calculate full height for screenshot
# @UX_STATE: [CalculatingHeight] -> Determining dashboard dimensions
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
full_height = await page.evaluate("""() => {
const body = document.body;
const html = document.documentElement;
@@ -592,7 +600,7 @@ class ScreenshotService:
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
pre_resize_path = output_path.replace(".png", "_preresize.png")
try:
await page.screenshot(path=pre_resize_path, full_page=False, timeout=10000)
await page.screenshot(path=pre_resize_path, full_page=False, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
import os
pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
@@ -635,7 +643,7 @@ class ScreenshotService:
logger.info("[DIAGNOSTIC] No chart error states detected")
# 3. Take screenshot using CDP to bypass Playwright's font loading wait
# @UX_STATE: [Capturing] -> Executing CDP screenshot
# @UX_STATE [Capturing] -> Executing CDP screenshot
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
cdp = await page.context.new_cdp_session(page)
@@ -667,10 +675,10 @@ class ScreenshotService:
except Exception as e:
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
try:
await page.screenshot(path=output_path, full_page=True, timeout=10000)
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
except Exception as e2:
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
await page.screenshot(path=output_path, timeout=5000)
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
await browser.close()
return True
@@ -681,8 +689,8 @@ class ScreenshotService:
# @BRIEF Wrapper for LLM provider APIs.
class LLMClient:
# region LLMClient.__init__ [TYPE Function]
# @PURPOSE: Initializes the LLMClient with provider settings.
# @PRE: api_key, base_url, and default_model are non-empty strings.
# @PURPOSE Initializes the LLMClient with provider settings.
# @PRE api_key, base_url, and default_model are non-empty strings.
def __init__(self, provider_type: LLMProviderType, api_key: str, base_url: str, default_model: str):
self.provider_type = provider_type
normalized_key = (api_key or "").strip()
@@ -716,7 +724,7 @@ class LLMClient:
# It routes to upstream providers transparently, and the default Authorization header
# is sufficient. No additional headers like HTTP-Referer or X-API-Key are required.
http_client = httpx.AsyncClient(headers=default_headers, timeout=120.0)
http_client = httpx.AsyncClient(headers=default_headers, timeout=LLM_HTTP_TIMEOUT_S)
self.client = AsyncOpenAI(
api_key=self.api_key,
base_url=base_url,
@@ -726,9 +734,9 @@ class LLMClient:
# endregion LLMClient.__init__
# region LLMClient._supports_json_response_format [TYPE Function]
# @PURPOSE: Detect whether provider/model is likely compatible with response_format=json_object.
# @PRE: Client initialized with base_url and default_model.
# @POST: Returns False for known-incompatible combinations to avoid avoidable 400 errors.
# @PURPOSE Detect whether provider/model is likely compatible with response_format=json_object.
# @PRE Client initialized with base_url and default_model.
# @POST Returns False for known-incompatible combinations to avoid avoidable 400 errors.
def _supports_json_response_format(self) -> bool:
base = (self.base_url or "").lower()
model = (self.default_model or "").lower()
@@ -746,10 +754,10 @@ class LLMClient:
# endregion LLMClient._supports_json_response_format
# region LLMClient.get_json_completion [TYPE Function]
# @PURPOSE: Helper to handle LLM calls with JSON mode and fallback parsing.
# @PRE: messages is a list of valid message dictionaries.
# @POST: Returns a parsed JSON dictionary.
# @SIDE_EFFECT: Calls external LLM API.
# @PURPOSE Helper to handle LLM calls with JSON mode and fallback parsing.
# @PRE messages is a list of valid message dictionaries.
# @POST Returns a parsed JSON dictionary.
# @SIDE_EFFECT Calls external LLM API.
def _should_retry(exception: Exception) -> bool:
"""Custom retry predicate that excludes authentication errors."""
# Don't retry on authentication errors
@@ -868,10 +876,10 @@ class LLMClient:
# endregion LLMClient.get_json_completion
# region LLMClient.test_runtime_connection [TYPE Function]
# @PURPOSE: Validate provider credentials using the same chat completions transport as runtime analysis.
# @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.
# @PURPOSE Validate provider credentials using the same chat completions transport as runtime analysis.
# @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]:
with belief_scope("test_runtime_connection"):
messages = [
@@ -884,10 +892,10 @@ class LLMClient:
# endregion LLMClient.test_runtime_connection
# region LLMClient.fetch_models [TYPE Function]
# @PURPOSE: Fetch available models from the provider's API.
# @PRE: Client is initialized with provider credentials.
# @POST: Returns a list of model ID strings.
# @SIDE_EFFECT: Calls external LLM API /v1/models endpoint.
# @PURPOSE Fetch available models from the provider's API.
# @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]:
with belief_scope("LLMClient.fetch_models"):
try:
@@ -907,10 +915,10 @@ class LLMClient:
# endregion LLMClient.fetch_models
# region LLMClient.analyze_dashboard [TYPE Function]
# @PURPOSE: Sends dashboard data (screenshot + logs) to LLM for health analysis.
# @PRE: screenshot_path exists, logs is a list of strings.
# @POST: Returns a structured analysis dictionary (status, summary, issues).
# @SIDE_EFFECT: Reads screenshot file and calls external LLM API.
# @PURPOSE Sends dashboard data (screenshot + logs) to LLM for health analysis.
# @PRE screenshot_path exists, logs is a list of strings.
# @POST Returns a structured analysis dictionary (status, summary, issues).
# @SIDE_EFFECT Reads screenshot file and calls external LLM API.
async def analyze_dashboard(
self,
screenshot_path: str,
@@ -979,4 +987,16 @@ class LLMClient:
# endregion LLMClient.analyze_dashboard
# #endregion LLMClient
# #endregion LLMAnalysisService
return await self.get_json_completion(messages)
except Exception as e:
logger.error(f"[analyze_dashboard] Failed to get analysis: {e!s}")
return {
"status": "UNKNOWN",
"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
# #endregion LLMClient
# #endregion LLMAnalysisService