Files
ss-tools/backend/src/plugins/llm_analysis/service.py
busya 7f7a85b2c5 refactor(validation): deduplicate image optimization, fix quality-reduction-before-chunking, unify chunk_count key
- Extract _optimize_images() helper to eliminate duplicate
  optimization code (was duplicated between initial pass and
  quality-reduction fallback)
- Move quality-reduction estimate to only apply when NOT chunking
  (each chunk fits the image limit by definition)
- Fix _merge_chunk_results to return 'chunk_count' instead of 'chunks'
  for consistency with plugin.py
- Simplify plugin.py: analysis.get('chunk_count', 1) instead of
  fallback chain
- Document that Kilo API gateway is incompatible with AsyncOpenAI
  image format (probe returns 0)
2026-05-31 22:57:58 +03:00

1671 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# #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 -> [EXT:Library: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
import io
import json
import os
import re
import ssl
from typing import Any
from urllib.parse import urlsplit
import httpx
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
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
# 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.
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.
async def _find_first_visible_locator(self, candidates) -> Any:
for locator in candidates:
try:
match_count = await locator.count()
for index in range(match_count):
candidate = locator.nth(index)
if await candidate.is_visible():
return candidate
except Exception:
continue
return None
# 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.
def _iter_login_roots(self, page) -> list[Any]:
roots = [page]
page_frames = getattr(page, "frames", [])
try:
for frame in page_frames:
if frame not in roots:
roots.append(frame)
except Exception:
pass
return roots
# 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.
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]")
count = await locator.count()
for index in range(count):
candidate = locator.nth(index)
field_name = str(await candidate.get_attribute("name") or "").strip()
if not field_name or field_name in hidden_fields:
continue
hidden_fields[field_name] = str(await candidate.input_value()).strip()
except Exception:
continue
return hidden_fields
# 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.
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.
def _response_looks_like_login_page(self, response_text: str) -> bool:
normalized = str(response_text or "").strip().lower()
if not normalized:
return False
markers = [
"enter your login and password below",
"username:",
"password:",
"sign in",
'name="csrf_token"',
]
return sum(marker in normalized for marker in markers) >= 3
# 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.
def _redirect_looks_authenticated(self, redirect_location: str) -> bool:
normalized = str(redirect_location or "").strip().lower()
if not normalized:
return True
return "/login" not in normalized
# 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.
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()
if not csrf_token:
logger.warning("[DEBUG] Direct form login fallback skipped: csrf_token not found")
return False
try:
request_context = page.context.request
except Exception as context_error:
logger.warning(f"[DEBUG] Direct form login fallback skipped: request context unavailable: {context_error}")
return False
parsed_url = urlsplit(login_url)
origin = f"{parsed_url.scheme}://{parsed_url.netloc}" if parsed_url.scheme and parsed_url.netloc else login_url
payload = dict(hidden_fields)
payload["username"] = self.env.username
payload["password"] = self.env.password
logger.info(
f"[DEBUG] Attempting direct form login fallback via browser context request with hidden fields: {sorted(hidden_fields.keys())}"
)
response = await request_context.post(
login_url,
form=payload,
headers={
"Origin": origin,
"Referer": login_url,
},
timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS,
fail_on_status_code=False,
max_redirects=0,
)
response_url = str(getattr(response, "url", "") or "")
response_status = int(getattr(response, "status", 0) or 0)
response_headers = dict(getattr(response, "headers", {}) or {})
redirect_location = str(
response_headers.get("location")
or response_headers.get("Location")
or ""
).strip()
redirect_statuses = {301, 302, 303, 307, 308}
if response_status in redirect_statuses:
redirect_authenticated = self._redirect_looks_authenticated(redirect_location)
logger.info(
f"[DEBUG] Direct form login fallback redirect response: status={response_status} url={response_url} location={redirect_location!r} authenticated={redirect_authenticated}"
)
return redirect_authenticated
response_text = await response.text()
text_snippet = " ".join(response_text.split())[:200]
looks_like_login_page = self._response_looks_like_login_page(response_text)
logger.info(
f"[DEBUG] Direct form login fallback response: status={response_status} url={response_url} login_markup={looks_like_login_page} snippet={text_snippet!r}"
)
return not looks_like_login_page
# 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.
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):
if normalized == "username":
input_candidates = [
root.get_by_label("Username", exact=False),
root.get_by_label("Login", exact=False),
root.locator("label:text-matches('Username|Login', 'i')").locator("xpath=following::input[1]"),
root.locator("text=/Username|Login/i").locator("xpath=following::input[1]"),
root.locator("input[name='username']"),
root.locator("input#username"),
root.locator("input[placeholder*='Username']"),
root.locator("input[type='text']"),
root.locator("input:not([type='password'])"),
]
locator = await self._find_first_visible_locator(input_candidates)
if locator:
return locator
if normalized == "password":
input_candidates = [
root.get_by_label("Password", exact=False),
root.locator("label:text-matches('Password', 'i')").locator("xpath=following::input[1]"),
root.locator("text=/Password/i").locator("xpath=following::input[1]"),
root.locator("input[name='password']"),
root.locator("input#password"),
root.locator("input[placeholder*='Password']"),
root.locator("input[type='password']"),
]
locator = await self._find_first_visible_locator(input_candidates)
if locator:
return locator
return None
# 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.
async def _find_submit_locator(self, page) -> Any:
selectors = [
lambda root: root.get_by_role("button", name="Sign in", exact=False),
lambda root: root.get_by_role("button", name="Login", exact=False),
lambda root: root.locator("button[type='submit']"),
lambda root: root.locator("button#submit"),
lambda root: root.locator(".btn-primary"),
lambda root: root.locator("input[type='submit']"),
]
for root in self._iter_login_roots(page):
locator = await self._find_first_visible_locator([factory(root) for factory in selectors])
if locator:
return locator
return None
# 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.
async def _goto_resilient(
self,
page,
url: str,
primary_wait_until: str = "domcontentloaded",
fallback_wait_until: str = "load",
timeout: int = HTTP_REQUEST_TIMEOUT_MS,
):
try:
return await page.goto(url, wait_until=primary_wait_until, timeout=timeout)
except Exception as primary_error:
logger.warning(
f"[ScreenshotService._goto_resilient] Primary navigation wait '{primary_wait_until}' failed for {url}: {primary_error}"
)
return await page.goto(url, wait_until=fallback_wait_until, timeout=timeout)
# endregion ScreenshotService._goto_resilient
# region ScreenshotService._wait_for_charts_stabilized [TYPE Function] [C:2]
# @BRIEF Wait until chart elements have non-zero dimensions, with polling.
# @PRE page is a valid Playwright page.
# @POST Waits for chart stabilization or raises on timeout (handled internally).
# @RATIONALE Polls for actual chart rendering dimensions rather than using a fixed delay — charts may load at different speeds depending on dashboard complexity and network conditions.
# @REJECTED Fixed sleep-based wait rejected — would either waste time (too long) or produce blank screenshots (too short); polling for actual canvas/svg dimensions is more reliable.
async def _wait_for_charts_stabilized(self, page, timeout_ms: int = 15000):
"""Wait until chart elements have non-zero dimensions, with polling."""
# Short initial delay for rendering pipeline to start
await asyncio.sleep(0.5)
try:
await page.wait_for_function("""() => {
const charts = document.querySelectorAll('.chart-container canvas, .slice_container svg, .grid-content canvas');
if (charts.length === 0) return true;
return Array.from(charts).some(c => {
if (c.tagName === 'CANVAS') return c.width > 10 && c.height > 10;
if (c.tagName === 'svg') {
const bbox = c.getBoundingClientRect();
return bbox.width > 10 && bbox.height > 10;
}
return false;
});
}""", timeout=timeout_ms)
except Exception:
logger.warning("[ScreenshotService] Chart stabilization wait timed out, proceeding anyway")
# endregion ScreenshotService._wait_for_charts_stabilized
# region ScreenshotService._wait_for_resize_rendered [TYPE Function] [C:2]
# @BRIEF Wait for charts to re-render after viewport resize.
# @PRE page is a valid Playwright page; chart_count_before contains pre-resize element counts.
# @POST Waits for chart content to return or timeout.
# @RATIONALE After viewport resize, Superset triggers lazy chart re-rendering — this function polls for chart elements to reappear before taking the screenshot.
# @REJECTED Single fixed wait after resize rejected — some dashboards re-render instantly while others take seconds; fixed wait is brittle across dashboard types.
async def _wait_for_resize_rendered(self, page, chart_count_before: dict, timeout_ms: int = 10000):
"""Wait for charts to re-render after viewport resize, with polling."""
try:
await page.wait_for_function("""(preCounts) => {
const currentCharts = document.querySelectorAll('.chart-container, .slice_container').length;
const currentCanvases = document.querySelectorAll('canvas').length;
const currentSvgs = document.querySelectorAll('.chart-container svg, .slice_container svg').length;
// At least one chart element must be present
return currentCharts > 0 && (currentCanvases > 0 || currentSvgs > 0);
}""", arg=chart_count_before, timeout=timeout_ms)
except Exception:
logger.warning("[ScreenshotService] Re-render wait timed out after viewport resize, proceeding anyway")
# endregion ScreenshotService._wait_for_resize_rendered
# region ScreenshotService._save_debug_screenshot [TYPE Function] [C:1]
# @BRIEF Save a debug screenshot to a temp directory for diagnostic purposes.
# @PRE debug_dir exists and is writable.
# @POST Returns the debug path or None on failure.
# @RATIONALE Uses tempfile.mkdtemp() to avoid accumulating .png files in production storage. Temp dir is cleaned up on success (rmtree) or preserved on failure for debugging.
# @REJECTED Old approach saved debug .png next to output path — accumulated _debug_failed_login.png, _preresize.png permanently in screenshots dir (F1). In-memory-only debug logging rejected — screenshot state is visual and cannot be captured in logs.
async def _save_debug_screenshot(self, page, debug_dir: str, suffix: str) -> str | None:
debug_path = os.path.join(debug_dir, suffix)
try:
await page.screenshot(path=debug_path)
return debug_path
except Exception:
return None
# endregion ScreenshotService._save_debug_screenshot
# region ScreenshotService._launch_and_login [TYPE Function] [C:4]
# @PURPOSE Launch browser, log in to Superset, navigate to dashboard URL.
# @PRE dashboard_id is valid, playwright instance is provided.
# @POST Returns (browser, context, page) tuple with active session.
# @SIDE_EFFECT Launches headless Chromium, performs UI login, navigates to dashboard.
# @RATIONALE Extracted from capture_dashboard to share login/navigation logic
# between capture_dashboard (backward compat) and capture_dashboard_chunks (multi-tab).
# @REJECTED Duplicating login logic rejected — any change to auth flow would require
# updating two code paths, leading to accidental drift.
async def _launch_and_login(
self,
playwright,
dashboard_id: str,
parsed_context: dict | None = None,
) -> tuple[Any, Any, Any]:
"""Launch browser, log in to Superset, and navigate to the dashboard.
Returns (browser, context, page).
"""
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
base_ui_url = self.env.url.rstrip("/")
if base_ui_url.endswith("/api/v1"):
base_ui_url = base_ui_url[: -len("/api/v1")]
browser = await playwright.chromium.launch(
headless=True,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-infobars",
"--no-sandbox",
],
)
context = await browser.new_context(
viewport={"width": 1280, "height": 720},
user_agent=user_agent,
extra_http_headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
},
)
page = await context.new_page()
await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")
# 1. Navigate to login page and authenticate
login_url = f"{base_ui_url.rstrip('/')}/login/"
await self._goto_resilient(
page,
login_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
await page.wait_for_load_state("domcontentloaded")
try:
used_direct_form_login = False
username_locator = await self._find_login_field_locator(page, "username")
if not username_locator:
used_direct_form_login = await self._submit_login_via_form_post(page, login_url)
if not used_direct_form_login:
raise RuntimeError("Could not find username input field on login page")
if username_locator is not None:
await username_locator.fill(self.env.username)
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:
await password_locator.fill(self.env.password)
submit_locator = (
await self._find_submit_locator(page)
if username_locator is not None
else None
)
if username_locator is not None and not submit_locator:
raise RuntimeError("Could not find submit button on login page")
if submit_locator is not None:
await submit_locator.click()
if not used_direct_form_login:
try:
await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
except Exception:
pass
if not used_direct_form_login and "/login" in page.url:
error_msg = (
await page.locator(".alert-danger, .error-message").text_content()
if await page.locator(".alert-danger, .error-message").count() > 0
else "Unknown error"
)
raise RuntimeError(f"Login failed: {error_msg}")
except Exception as e:
raise RuntimeError(f"Login failed: {e!s}")
# 2. Navigate to dashboard
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://")
if parsed_context:
native_filters = parsed_context.get("native_filters")
active_tabs = parsed_context.get("activeTabs")
if native_filters:
dashboard_url += f"&native_filters={native_filters}"
if active_tabs:
dashboard_url += f"&activeTabs={active_tabs}"
await self._goto_resilient(
page,
dashboard_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if "/login" in page.url:
raise RuntimeError("Dashboard navigation redirected to login page after authentication")
# 3. Wait for dashboard content
try:
await page.wait_for_selector(
'.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]',
timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS,
)
try:
await page.wait_for_selector(
".loading, .ant-skeleton, .spinner",
state="hidden",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
except Exception:
pass
try:
await page.wait_for_selector(
".chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
except Exception:
pass
await page.wait_for_function(
"""() => {
const charts = document.querySelectorAll('.chart-container, .slice_container');
if (charts.length === 0) return true;
return Array.from(charts).every(chart => {
const hasCanvas = chart.querySelector('canvas') !== null;
const hasSvg = chart.querySelector('svg') !== null;
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
return hasCanvas || hasSvg || hasContent;
});
}""",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
await page.evaluate("""async () => {
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < document.body.scrollHeight; i += 500) {
window.scrollTo(0, i);
await delay(100);
}
window.scrollTo(0, 0);
await delay(500);
}""")
except Exception:
pass
await self._wait_for_charts_stabilized(page)
logger.info(f"[_launch_and_login] Login + navigation successful for dashboard {dashboard_id}")
return browser, context, page
# endregion ScreenshotService._launch_and_login
# region ScreenshotService.capture_dashboard_chunks [TYPE Function] [C:4]
# @PURPOSE Capture per-tab screenshots: login → navigate → switch tabs → per-tab CDP screenshots.
# @PRE dashboard_id is valid, browser available.
# @POST Returns list of {tab_name, path} dicts — one per tab.
# @SIDE_EFFECT Launches browser, logs in, switches tabs, captures screenshots.
# @RATIONALE Multi-chunk: one screenshot per tab instead of one full-page.
# All screenshots written to output_dir; CDP fallback to Playwright full_page.
# @REJECTED Single full-page screenshot rejected for v2 — per-tab captures give LLM
# better visibility into individual tab content, especially for dashboards with
# many tabs where the full-page capture may miss lazy-loaded tab content.
async def capture_dashboard_chunks(
self,
dashboard_id: str,
output_dir: str,
parsed_context: dict | None = None,
) -> list[dict]:
"""Capture per-tab screenshots instead of one full-page.
Args:
dashboard_id: Superset dashboard ID
output_dir: Directory to save screenshots
parsed_context: Optional parsed context with activeTabs, native_filters from URL parse
Returns:
list of {tab_name, path} — one per tab
"""
import time as _time
timestamp = int(_time.time())
os.makedirs(output_dir, exist_ok=True)
async with async_playwright() as p:
browser, context, page = await self._launch_and_login(p, dashboard_id, parsed_context)
try:
results: list[dict] = []
processed_tabs: set[str] = set()
async def _capture_tabs(depth: int = 0) -> None:
if depth > 3:
return
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 not found_tabs:
return
logger.info(f"[capture_dashboard_chunks] 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 not await tab.is_visible():
continue
processed_tabs.add(tab_id)
logger.info(f"[capture_dashboard_chunks] Switching to tab: {tab_text}")
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
if not is_active:
await tab.click()
try:
await page.wait_for_function(
"""() => {
const activeTab = document.querySelector('.ant-tabs-tab-active');
if (!activeTab) return true;
const tabPane = activeTab.closest('.ant-tabs')?.querySelector('.ant-tabs-content-holder');
if (!tabPane) return true;
const charts = tabPane.querySelectorAll('canvas, svg');
return charts.length > 0;
}""",
timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS,
)
except Exception:
logger.warning(
f"[capture_dashboard_chunks] Content verification timed out for tab: {tab_text}"
)
# Wait for charts to stabilize
await self._wait_for_charts_stabilized(page)
# Resize viewport to 1920x1200 for consistent screenshots
await page.set_viewport_size({"width": 1920, "height": 1200})
await self._wait_for_resize_rendered(page, {})
# CDP screenshot with fallback
safe_tab = re.sub(r"[^\w\-_ ]", "", tab_text).strip().replace(" ", "_")[:40]
if not safe_tab:
safe_tab = f"tab_{depth}_{i}"
tab_filename = f"{dashboard_id}_{safe_tab}_{timestamp}.png"
tab_path = os.path.join(output_dir, tab_filename)
try:
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(tab_path, "wb") as f:
f.write(image_data)
except Exception as cdp_err:
logger.warning(
f"[capture_dashboard_chunks] CDP failed for tab {tab_text}: {cdp_err}. "
"Falling back to Playwright full_page."
)
await page.screenshot(path=tab_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
logger.info(f"[capture_dashboard_chunks] Saved screenshot: {tab_path}")
results.append({"tab_name": tab_text, "path": tab_path})
# Recurse into nested tabs
await _capture_tabs(depth + 1)
except Exception as tab_e:
logger.warning(
f"[capture_dashboard_chunks] Failed to process tab {i} at depth {depth}: {tab_e}"
)
# Return to first tab
try:
first_tab = found_tabs[0]
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
await first_tab.click()
except Exception:
pass
await _capture_tabs()
# If no tabs found, capture the whole page as a single chunk
if not results:
logger.info("[capture_dashboard_chunks] No tabs found, capturing full-page as single chunk")
await self._wait_for_charts_stabilized(page)
await page.set_viewport_size({"width": 1920, "height": 1200})
tab_path = os.path.join(output_dir, f"{dashboard_id}_full_{timestamp}.png")
try:
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(tab_path, "wb") as f:
f.write(image_data)
except Exception as cdp_err:
logger.warning(f"[capture_dashboard_chunks] CDP full fallback failed: {cdp_err}")
await page.screenshot(path=tab_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
results.append({"tab_name": "full", "path": tab_path})
return results
finally:
await browser.close()
# endregion ScreenshotService.capture_dashboard_chunks
# region ScreenshotService.capture_dashboard [TYPE Function] [C:4]
# @PURPOSE Captures multi-chunk screenshots, converts for LLM, archives to WebP.
# @PRE dashboard_id is a valid string, output_path is a writable path.
# @POST Returns list of {original, webp_path} dicts from WebP archive.
# Empty list on complete failure.
# @SIDE_EFFECT Launches browser, performs UI login, writes PNG/JPEG/WebP files;
# deletes intermediate PNG and JPEG files after conversion.
# @RATIONALE Refactored v2: delegates to capture_dashboard_chunks for per-tab PNGs,
# then converts for LLM (JPEG) and archive (WebP). Temp intermediates are cleaned up.
# @REJECTED Returning bool rejected for v2 — callers need access to archived WebP paths
# for persistence and LLM pipeline.
async def capture_dashboard(self, dashboard_id: str, output_path: str) -> tuple[list[str], list[dict]]:
"""Capture dashboard screenshots (multi-chunk), convert for LLM, archive to WebP.
Returns (jpeg_paths, archive_results) tuple.
jpeg_paths — list of JPEG paths ready for LLM analysis (caller must clean up).
archive_results — list of {original, webp_path} dicts from WebP archive.
"""
output_dir = os.path.dirname(output_path) or "."
os.makedirs(output_dir, exist_ok=True)
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
logger.info(f"Capturing screenshots for dashboard {dashboard_id}")
# 1. Capture per-tab screenshots
chunks = await self.capture_dashboard_chunks(dashboard_id, output_dir)
png_paths = [c["path"] for c in chunks if c.get("path")]
if not png_paths:
logger.warning(f"[capture_dashboard] No screenshots captured for dashboard {dashboard_id}")
return [], []
# 2. Convert PNGs to JPEGs for LLM
jpeg_paths = self._convert_screenshots_for_llm(png_paths, output_dir)
logger.info(
f"[capture_dashboard] Converted {len(jpeg_paths)}/{len(png_paths)} PNGs to JPEGs for LLM"
)
# 3. Archive to WebP (deletes PNGs on success)
archive_results = self._archive_screenshots_as_webp(png_paths, output_dir)
logger.info(
f"[capture_dashboard] Archived {sum(1 for r in archive_results if r.get('webp_path'))} "
f"of {len(archive_results)} screenshots to WebP"
)
# 4. Return JPEGs for LLM — caller cleans up after analysis
return jpeg_paths, archive_results
# endregion ScreenshotService.capture_dashboard
# region ScreenshotService._convert_screenshots_for_llm [TYPE Function] [C:2]
# @BRIEF Convert PNG screenshots to JPEG for LLM transmission.
# @PRE png_paths is a list of existing PNG file paths.
# @POST Returns list of JPEG paths. JPEG files should be deleted after LLM call.
@staticmethod
def _convert_screenshots_for_llm(png_paths: list[str], output_dir: str) -> list[str]:
"""Convert PNG screenshots to JPEG for LLM transmission.
PNG → Pillow → JPEG quality=60, max 1024px width.
Returns list of JPEG paths.
"""
jpeg_paths: list[str] = []
for png_path in png_paths:
try:
img = Image.open(png_path)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
max_width = 1024
if img.width > max_width:
scale = max_width / img.width
new_w = int(img.width * scale)
new_h = int(img.height * scale)
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
base = os.path.splitext(os.path.basename(png_path))[0]
jpeg_path = os.path.join(output_dir, f"{base}_llm.jpg")
img.save(jpeg_path, format="JPEG", quality=60, optimize=True)
jpeg_paths.append(jpeg_path)
except Exception as e:
logger.warning(f"[convert_for_llm] Failed to convert {png_path}: {e}")
return jpeg_paths
# endregion ScreenshotService._convert_screenshots_for_llm
# region ScreenshotService._archive_screenshots_as_webp [TYPE Function] [C:2]
# @BRIEF Convert PNG screenshots to WebP for archive.
# @PRE png_paths is a list of existing PNG file paths.
# @POST Returns list of {original, webp_path} dicts. PNG deleted after successful WebP save.
@staticmethod
def _archive_screenshots_as_webp(png_paths: list[str], archive_dir: str) -> list[dict]:
"""Convert PNG screenshots to WebP for archive.
PNG → Pillow → WebP lossy quality=80.
Deletes PNG after WebP saved.
Returns list of {original, webp_path} dicts.
"""
results: list[dict] = []
for png_path in png_paths:
try:
img = Image.open(png_path)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
base = os.path.splitext(os.path.basename(png_path))[0]
webp_path = os.path.join(archive_dir, f"{base}.webp")
img.save(webp_path, format="WEBP", quality=80, lossless=False)
# Delete PNG after successful WebP save
os.remove(png_path)
results.append({"original": png_path, "webp_path": webp_path})
except Exception as e:
logger.warning(f"[archive_webp] Failed to convert {png_path}: {e}. Keeping PNG.")
results.append({"original": png_path, "webp_path": None})
return results
# endregion ScreenshotService._archive_screenshots_as_webp
# region ScreenshotService._cleanup_temp_files [TYPE Function] [C:1]
# @BRIEF Delete temporary files (PNG, JPEG intermediates).
@staticmethod
def _cleanup_temp_files(paths: list[str]) -> None:
"""Delete temporary files (PNG, JPEG intermediates)."""
for path in paths:
try:
if os.path.exists(path):
os.remove(path)
except Exception as e:
logger.warning(f"[cleanup] Failed to delete {path}: {e}")
# endregion ScreenshotService._cleanup_temp_files
# #endregion ScreenshotService
# #region LLMClient [TYPE Class]
# @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.
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()
if normalized_key.lower().startswith("bearer "):
normalized_key = normalized_key[7:].strip()
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}")
logger.info(f"[LLMClient.__init__] Base URL: {base_url}")
logger.info(f"[LLMClient.__init__] Default Model: {default_model}")
logger.info(f"[LLMClient.__init__] API Key (first 8 chars): {self.api_key[:8] if self.api_key and len(self.api_key) > 8 else 'EMPTY_OR_NONE'}...")
logger.info(f"[LLMClient.__init__] API Key Length: {len(self.api_key) if self.api_key else 0}")
# Some OpenAI-compatible gateways are strict about auth header naming.
default_headers = {"Authorization": f"Bearer {self.api_key}"}
if self.provider_type == LLMProviderType.OPENROUTER:
default_headers["HTTP-Referer"] = (
os.getenv("OPENROUTER_SITE_URL", "").strip()
or os.getenv("APP_BASE_URL", "").strip()
or "http://localhost:8000"
)
default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or "ss-tools"
if self.provider_type == LLMProviderType.KILO:
default_headers["Authentication"] = f"Bearer {self.api_key}"
default_headers["X-API-Key"] = self.api_key
# LiteLLM proxy uses standard OpenAI-compatible Bearer auth — no special headers needed.
# 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.
ssl_verify = self._get_ssl_verify()
logger.info(f"[LLMClient.__init__] SSL Verify: {ssl_verify} (set LLM_SSL_VERIFY=false to disable)")
http_client = httpx.AsyncClient(
headers=default_headers,
timeout=LLM_HTTP_TIMEOUT_S,
verify=ssl_verify,
)
self.client = AsyncOpenAI(
api_key=self.api_key,
base_url=base_url,
default_headers=default_headers,
http_client=http_client,
)
# endregion LLMClient.__init__
# region LLMClient._get_ssl_verify [TYPE Function]
# @PURPOSE Resolve SSL verification flag from environment.
# @POST Returns SSLContext with system CA dir when enabled,
# False when LLM_SSL_VERIFY env var is "false"/"0"/"no"/"off".
# @RATIONALE Используем capath=/etc/ssl/certs/ вместо cafile, потому что
# OpenSSL 3.x не использует intermediate CA сертификаты из cafile для
# построения цепочки (verify code 20). capath с хеш-симлинками работает
# корректно (verify code 0). Оба пути — cafile и capath — указывают на
# один и тот же набор сертификатов, но capath правильно обрабатывает
# цепочку Root → Policy → Issuing.
# @REJECTED verify=<string> отвергнут — httpx 0.28.x депрекейтит строковый
# путь в verify=, требует SSLContext.
# @REJECTED cafile отвергнут — OpenSSL 3.x не использует intermediate CA
# из единого bundle-файла. Только capath с хеш-симлинками даёт code 0.
@staticmethod
def _get_ssl_verify() -> ssl.SSLContext | bool:
raw = os.getenv("LLM_SSL_VERIFY", "true").strip().lower()
if raw in ("false", "0", "no", "off"):
return False
ca_dir = "/etc/ssl/certs"
if os.path.isdir(ca_dir):
return ssl.create_default_context(capath=ca_dir)
# fallback: если директории нет (редко), используем дефолтный
return ssl.create_default_context()
# endregion LLMClient._get_ssl_verify
# region LLMClient._format_connection_error [TYPE Function]
# @PURPOSE Format exception chain for diagnostics, extracting httpx cause details.
# @POST Returns a human-readable string with the full error chain.
@staticmethod
def _format_connection_error(exc: Exception) -> str:
parts = [f"{type(exc).__name__}: {exc!s}"]
cause = exc.__cause__ or exc.__context__
while cause:
parts.append(f" └─ {type(cause).__name__}: {cause!s}")
cause = cause.__cause__ or cause.__context__
return "\n".join(parts)
# endregion LLMClient._format_connection_error
# 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.
def _supports_json_response_format(self) -> bool:
base = (self.base_url or "").lower()
model = (self.default_model or "").lower()
# OpenRouter routes to many upstream providers; some models reject json_object mode.
if "openrouter.ai" in base:
incompatible_tokens = (
"stepfun/",
"step-",
":free",
)
if any(token in model for token in incompatible_tokens):
return False
return True
# 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.
def _should_retry(exception: Exception) -> bool:
"""Custom retry predicate that excludes authentication errors."""
# Don't retry on authentication errors
if isinstance(exception, OpenAIAuthenticationError):
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]:
with belief_scope("get_json_completion"):
response = None
try:
use_json_mode = self._supports_json_response_format()
try:
logger.info(
f"[get_json_completion] Attempting LLM call for model: {self.default_model} "
f"(json_mode={'on' if use_json_mode else 'off'})"
)
logger.info(f"[get_json_completion] Base URL being used: {self.base_url}")
logger.info(f"[get_json_completion] Number of messages: {len(messages)}")
logger.info(f"[get_json_completion] API Key present: {bool(self.api_key and len(self.api_key) > 0)}")
if use_json_mode:
response = await self.client.chat.completions.create(
model=self.default_model,
messages=messages,
response_format={"type": "json_object"}
)
else:
response = await self.client.chat.completions.create(
model=self.default_model,
messages=messages
)
except Exception as e:
if use_json_mode and (
"JSON mode is not enabled" in str(e)
or "json_object is not supported" in str(e).lower()
or "response_format" in str(e).lower()
or "400" in str(e)
):
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: {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: {e!s}")
# Extract retry_delay from error metadata if available
retry_delay = 5.0 # Default fallback
try:
# Based on logs, the raw response is in e.body or e.response.json()
# 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)
if match:
retry_delay = float(match.group(1))
else:
# Try to parse from response if it's a standard OpenAI-like error with body
if hasattr(e, 'body') and isinstance(e.body, dict):
# Some providers put it in details
details = e.body.get('error', {}).get('details', [])
for detail in details:
if detail.get('@type') == 'type.googleapis.com/google.rpc.RetryInfo':
delay_str = detail.get('retryDelay', '5s')
retry_delay = float(delay_str.rstrip('s'))
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.\n"
f"{self._format_connection_error(e)}"
)
raise
if not response or not hasattr(response, 'choices') or not response.choices:
raise RuntimeError(f"Invalid LLM response: {response}")
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:
logger.warning("[get_json_completion] Failed to parse JSON directly, attempting to extract from code blocks")
if "```json" in content:
json_str = content.split("```json")[1].split("```")[0].strip()
return json.loads(json_str)
elif "```" in content:
json_str = content.split("```")[1].split("```")[0].strip()
return json.loads(json_str)
else:
raise
# 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.
async def test_runtime_connection(self) -> dict[str, Any]:
with belief_scope("test_runtime_connection"):
messages = [
{
"role": "user",
"content": 'Return exactly this JSON object and nothing else: {"ok": true}',
}
]
return await self.get_json_completion(messages)
# 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.
async def fetch_models(self) -> list[str]:
with belief_scope("LLMClient.fetch_models"):
try:
response = await self.client.models.list()
model_ids = [m.id for m in response.data]
model_ids.sort()
logger.reason(
f"[LLMClient.fetch_models] Fetched {len(model_ids)} models from {self.base_url}",
extra={"src": "LLMClient.fetch_models"},
)
return model_ids
except Exception as e:
logger.warning(
f"[LLMClient.fetch_models] Failed to fetch models.\n"
f"{self._format_connection_error(e)}",
)
raise
# 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.
# @RATIONALE Delegates to analyze_dashboard_multimodal for single-screenshot
# backward compatibility. Keeps the same contract for v1 consumers.
async def analyze_dashboard(
self,
screenshot_path: str,
logs: list[str],
prompt_template: str = DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
) -> dict[str, Any]:
# Delegate to multimodal variant for backward compat with v1 consumers.
return await self.analyze_dashboard_multimodal(
screenshot_paths=[screenshot_path],
logs=logs,
prompt_template=prompt_template,
)
# endregion LLMClient.analyze_dashboard
# region LLMClient._reduce_image_quality [TYPE Function] [C:2]
# @PURPOSE Open, resize, and compress a screenshot image for LLM consumption.
# @PRE path points to an existing image file.
# @POST Returns (base64_str, byte_size) tuple.
@staticmethod
def _reduce_image_quality(
path: str,
max_width: int = 1024,
image_quality: int = 60,
) -> tuple[str, int]:
"""
Open, resize, compress, and base64-encode an image.
Returns (base64_str, byte_size).
"""
with Image.open(path) as img:
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
if img.width > max_width or img.height > 2048:
scale = min(max_width / img.width, 2048 / img.height)
if scale < 1.0:
new_width = int(img.width * scale)
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=image_quality, optimize=True)
raw = buffer.getvalue()
return base64.b64encode(raw).decode("utf-8"), len(raw)
# endregion LLMClient._reduce_image_quality
# region LLMClient._estimate_payload_size [TYPE Function] [C:2]
# @PURPOSE Estimate LLM payload size in tokens before sending.
# @POST Returns {estimated_tokens, exceeds_limit, pct_of_limit} dict.
# @RATIONALE FR-056: if >80% of model context window, trigger quality reduction.
@staticmethod
def _estimate_payload_size(
image_paths: list[str],
text_length: int,
model_context: int = 128000,
) -> dict[str, Any]:
"""
Estimate token usage for multimodal payload.
Rough heuristic: 1 image token ~ 258 tokens (GPT-4o), text ~4 chars/token.
Returns {estimated_tokens, exceeds_limit, pct_of_limit}
"""
image_tokens = len(image_paths) * 258 * 5 # rough upper bound for compressed images
text_tokens = text_length // 4
total_tokens = image_tokens + text_tokens
exceeds_limit = total_tokens > (model_context * 0.8)
return {
"estimated_tokens": total_tokens,
"exceeds_limit": exceeds_limit,
"pct_of_limit": round(total_tokens / model_context * 100, 1),
}
# endregion LLMClient._estimate_payload_size
# region LLMClient._deduplicate_issues [TYPE Function] [C:2]
# @PURPOSE Deduplicate issues by (severity, message, location) while preserving order.
def _deduplicate_issues(self, issues: list[dict]) -> list[dict]:
seen: set[tuple[str, str, str]] = set()
result: list[dict] = []
for issue in issues:
key = (issue.get("severity", ""), issue.get("message", ""), issue.get("location", "") or "")
if key not in seen:
seen.add(key)
result.append(issue)
return result
# endregion LLMClient._deduplicate_issues
# region LLMClient._optimize_images [TYPE Function] [C:2]
# @PURPOSE Convert screenshot paths to base64 at given quality, with fallback to raw read.
def _optimize_images(self, paths: list[str], max_width: int, quality: int) -> list[str]:
encoded: list[str] = []
for path in paths:
try:
b64, _ = self._reduce_image_quality(path, max_width, quality)
encoded.append(b64)
except Exception as e:
logger.warning(f"[_optimize_images] Optimization failed for {path}: {e}")
with open(path, "rb") as f:
raw = f.read()
b64 = base64.b64encode(raw).decode("utf-8")
encoded.append(b64)
return encoded
# endregion LLMClient._optimize_images
# region LLMClient._merge_chunk_results [TYPE Function] [C:2]
# @PURPOSE Merge multiple chunk analyses into one. Takes the worst status,
# concatenates summaries, and deduplicates issues.
# @PRE chunks is a non-empty list of {status, summary, issues} dicts.
# @POST Returns a single merged dict with chunk_count.
def _merge_chunk_results(self, chunks: list[dict[str, Any]]) -> dict[str, Any]:
STATUS_ORDER = {"FAIL": 0, "WARN": 1, "PASS": 2, "UNKNOWN": 3}
worst_status = "UNKNOWN"
all_summaries: list[str] = []
all_issues: list[dict] = []
for i, chunk in enumerate(chunks):
s = chunk.get("status", "UNKNOWN")
if STATUS_ORDER.get(s, 3) < STATUS_ORDER.get(worst_status, 3):
worst_status = s
all_summaries.append(f"[Chunk {i + 1}/{len(chunks)}] {chunk.get('summary', 'No summary')}")
all_issues.extend(chunk.get("issues", []))
merged: dict[str, Any] = {
"status": worst_status,
"summary": " | ".join(all_summaries),
"issues": self._deduplicate_issues(all_issues),
"chunk_count": len(chunks),
}
return merged
# endregion LLMClient._merge_chunk_results
# region LLMClient._call_llm_for_images [TYPE Function] [C:2]
# @PURPOSE Send a single chunk of images to the LLM and return parsed result.
async def _call_llm_for_images(
self, encoded_images: list[str], prompt: str
) -> dict[str, Any]:
content: list[dict] = [{"type": "text", "text": prompt}]
for b64_img in encoded_images:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64_img}"},
})
messages = [{"role": "user", "content": content}]
return await self.get_json_completion(messages)
# endregion LLMClient._call_llm_for_images
# region LLMClient.analyze_dashboard_multimodal [TYPE Function] [C:3]
# @PURPOSE Path A: send screenshots + logs to multimodal LLM, with chunking support.
# @PRE screenshot_paths is a non-empty list of paths.
# @POST Returns dict {status, summary, issues} with optional chunk_count.
# @SIDE_EFFECT Compresses images, calls external LLM API (possibly multiple times for chunks).
# @RATIONALE Screenshots are split into chunks of max_images to respect provider image limits.
# Quality reduction is skipped when chunking — each chunk fits the limit by definition.
# Results are merged via _merge_chunk_results.
async def analyze_dashboard_multimodal(
self,
screenshot_paths: list[str],
logs: list[str],
prompt_template: str = DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
max_width: int = 1024,
image_quality: int = 60,
max_images: int | None = None,
) -> dict[str, Any]:
with belief_scope("analyze_dashboard_multimodal"):
if not screenshot_paths:
raise ValueError("screenshot_paths must be a non-empty list")
# 1. Optimize all images at requested quality
encoded_images = self._optimize_images(screenshot_paths, max_width, image_quality)
log_text = "\n".join(logs)
prompt = render_prompt(prompt_template, {"logs": log_text})
# 2. Determine chunking
n_total = len(encoded_images)
chunk_size = max_images if (max_images and max_images > 0 and max_images < n_total) else n_total
is_chunking = chunk_size < n_total
if is_chunking:
logger.reason(
f"[analyze_dashboard_multimodal] Chunking {n_total} images into "
f"{(n_total + chunk_size - 1) // chunk_size} chunks of {chunk_size}",
extra={"src": "analyze_dashboard_multimodal", "total": n_total, "chunk_size": chunk_size},
)
# Skip quality reduction: each chunk has ≤ max_images images,
# well within the context window at normal quality.
else:
# Single batch: estimate payload and reduce quality if needed
estimate = self._estimate_payload_size(
screenshot_paths, len(prompt) + len(log_text)
)
if estimate["exceeds_limit"] and image_quality > 30:
logger.info(
f"[analyze_dashboard_multimodal] Payload estimated at {estimate['pct_of_limit']}% "
f"of context window. Reducing image quality to 30."
)
encoded_images = self._optimize_images(screenshot_paths, max_width, image_quality=30)
# 3. Split into chunks
chunks: list[list[str]] = [
encoded_images[i:i + chunk_size]
for i in range(0, n_total, chunk_size)
]
# 4. Call LLM — parallel for multiple chunks, single for one
try:
if len(chunks) == 1:
result = await self._call_llm_for_images(chunks[0], prompt)
else:
tasks = [self._call_llm_for_images(chunk, prompt) for chunk in chunks]
chunk_results = await asyncio.gather(*tasks, return_exceptions=True)
valid_results: list[dict] = []
for i, cr in enumerate(chunk_results):
if isinstance(cr, Exception):
logger.error(f"[analyze_dashboard_multimodal] Chunk {i + 1}/{len(chunks)} failed: {cr!s}")
valid_results.append({
"status": "UNKNOWN",
"summary": f"Chunk {i + 1} failed: {cr!s}",
"issues": [],
})
else:
valid_results.append(cr)
result = self._merge_chunk_results(valid_results)
except Exception as e:
logger.error(f"[analyze_dashboard_multimodal] 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"}],
}
return result
# endregion LLMClient.analyze_dashboard_multimodal
# region LLMClient.analyze_dashboard_text_batch [TYPE Function] [C:3]
# @PURPOSE Path B batch: multiple dashboards in a single text-only LLM call.
# @PRE payloads is a non-empty list of {dashboard_id, topology, dataset_health, log_text} dicts.
# @POST Returns dict {dashboards: [{dashboard_id, status, summary, issues}]}.
# Missing/parse-error dashboard_id -> marked UNKNOWN individually.
# @RATIONALE Text-only batch avoids image token costs. Uses per-dashboard sections
# with explicit JSON response contract. Fallback ensures partial results survive
# single-dashboard parse failures.
@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 analyze_dashboard_text_batch(
self,
payloads: list[dict],
prompt_template: str,
) -> dict[str, Any]:
"""
Batch analyze multiple dashboards in one LLM call.
payloads: list of dicts with keys:
- dashboard_id (str)
- topology (str) — dashboard structure
- dataset_health (str) — health results
- log_text (str) — execution logs
Returns dict like {dashboards: [{dashboard_id, status, summary, issues}]}
"""
if not payloads:
return {"dashboards": []}
# 1. Build per-dashboard sections
sections = []
for i, p in enumerate(payloads):
did = p.get("dashboard_id", "UNKNOWN")
top = p.get("topology", "")
first_line = top.split("\n")[0] if top else "(no topology)"
section = (
f'─── Dashboard {i + 1}: "{first_line}" (id: {did}) ───\n'
f"{top}\n\n"
f"Dataset health:\n{p.get('dataset_health', '')}\n\n"
f"Logs:\n{p.get('log_text', '')}"
)
sections.append(section)
full_prompt = prompt_template.replace("{total_dashboards}", str(len(payloads)))
full_prompt += (
"\n\nRespond with a JSON object containing EACH dashboard's results:\n"
'{"dashboards": [{"dashboard_id": "...", "status": "...", "summary": "...", "issues": [...]}]}\n\n'
)
full_prompt += "\n---\n".join(sections)
messages = [{"role": "user", "content": full_prompt}]
return await self.get_json_completion(messages)
# endregion LLMClient.analyze_dashboard_text_batch
# #endregion LLMClient
# #region DatasetHealthChecker [C:3] [TYPE Class]
# @BRIEF Checks dataset accessibility and KXD connectivity via Superset API.
# @LAYER Service
# @RELATION CALLS -> [SupersetClient]
# @INVARIANT Every unique dataset referenced by dashboard charts is checked.
# @RATIONALE Without dataset health checking, silent KXD errors (connection refused, timeout)
# are invisible to the LLM validation. Screenshot captures visual state but doesn't
# verify that data actually arrived (vs. cache).
class DatasetHealthChecker:
# region DatasetHealthChecker.__init__ [TYPE Function]
# @PURPOSE Initialize with a SupersetClient-compatible instance.
# @PRE client is a SupersetClient (sync, wrapped via asyncio.to_thread) or AsyncSupersetClient.
# @POST self.client is ready for health checks.
def __init__(self, client: Any):
self.client = client
# endregion DatasetHealthChecker.__init__
# region DatasetHealthChecker._call_sync [TYPE Function]
# @PURPOSE Wrap a sync client method call in asyncio.to_thread for async compat.
# @PRE method is a callable on self.client.
# @POST Returns the result of method(*args, **kwargs) executed in a thread.
@staticmethod
async def _call_sync(method, *args: Any, **kwargs: Any) -> Any:
"""Call a potentially sync method in a thread, or await if already async."""
if asyncio.iscoroutinefunction(method):
return await method(*args, **kwargs)
return await asyncio.to_thread(method, *args, **kwargs)
# endregion DatasetHealthChecker._call_sync
# region DatasetHealthChecker.check_dataset_health [TYPE Function]
# @PURPOSE Fetch dataset metadata and verify level 1-2 accessibility.
# @PRE dataset_id is a valid Superset dataset ID.
# @POST Returns dict with level 1-2 health fields.
# @SIDE_EFFECT Calls GET /api/v1/dataset/{id} via client.get_dataset
async def check_dataset_health(self, dataset_id: int) -> dict:
"""
Check a single dataset's accessibility (levels 1-2 per FR-044).
Level 1: metadata_accessible — HTTP 200 from GET /api/v1/dataset/{id}
Level 2: datasource_resolvable — database info available
Returns dict with:
dataset_id, dataset_name, database_name, backend, kind,
metadata_accessible (bool), error (str|None)
"""
try:
dataset = await self._call_sync(self.client.get_dataset, dataset_id)
# The response from Superset may have a 'result' wrapper or be flat
result_data = dataset.get("result", dataset) if isinstance(dataset, dict) else {}
# Extract database info
database = result_data.get("database", {}) or {}
result = {
"dataset_id": dataset_id,
"dataset_name": result_data.get("table_name", f"dataset_{dataset_id}"),
"database_name": database.get("database_name", "unknown"),
"backend": database.get("backend", "unknown"),
"kind": result_data.get("kind", "physical"),
"metadata_accessible": True,
"error": None,
}
return result
except Exception as e:
return {
"dataset_id": dataset_id,
"dataset_name": f"dataset_{dataset_id}",
"database_name": "unknown",
"backend": "unknown",
"kind": "unknown",
"metadata_accessible": False,
"error": str(e),
}
# endregion DatasetHealthChecker.check_dataset_health
# region DatasetHealthChecker.check_chart_data [TYPE Function]
# @PURPOSE Execute chart data query (level 3-4 per FR-044).
# @PRE chart_id is valid, form_data is constructed from chart params.
# @POST Returns dict with execution result.
# @SIDE_EFFECT Calls POST /api/v1/chart/data via client.network.request
async def check_chart_data(self, chart_id: int, form_data: dict) -> dict:
"""
Execute a chart query to verify data returns.
Level 3: query_executable — POST /api/v1/chart/data succeeds
Level 4: data_returned — row_count > 0 or no error
Returns dict with:
chart_id, executed (bool), duration_ms (int|None),
row_count (int|None), error (str|None)
"""
import time
start = time.time()
try:
# Use the client's network layer for the chart data POST.
# For sync SupersetClient: network.request(...) is synchronous.
# We wrap it via asyncio.to_thread if it's a sync method.
payload = json.dumps(form_data)
headers = {"Content-Type": "application/json"}
network_request = self.client.network.request
result = await self._call_sync(
network_request,
"POST", "/chart/data",
data=payload,
headers=headers,
)
duration_ms = int((time.time() - start) * 1000)
# Normalize response — may have 'result' wrapper
rows = []
if isinstance(result, dict):
rows = result.get("result", []) or []
elif isinstance(result, list):
rows = result
return {
"chart_id": chart_id,
"executed": True,
"duration_ms": duration_ms,
"row_count": len(rows),
"error": None,
}
except Exception as e:
duration_ms = int((time.time() - start) * 1000)
return {
"chart_id": chart_id,
"executed": False,
"duration_ms": duration_ms,
"row_count": None,
"error": str(e),
}
# endregion DatasetHealthChecker.check_chart_data
# region DatasetHealthChecker.check_dashboard_datasets [TYPE Function]
# @PURPOSE For every unique dataset in dashboard charts, check health.
# @PRE chart_list has chart dicts with slice_id and datasource_id.
# @POST Returns dict with datasets and optional chart_data lists.
async def check_dashboard_datasets(
self,
chart_list: list[dict],
execute_chart_data: bool = False,
) -> dict:
"""
Check all unique datasets referenced by dashboard charts.
Args:
chart_list: list of chart dicts with at least
{'slice_id', 'datasource_id', 'viz_type', 'params'}
execute_chart_data: if True, also execute chart queries (level 3-4)
Returns:
{datasets: [...], chart_data: [...]}
"""
# Collect unique datasource_ids
unique_ds_ids: set[int] = set()
for chart in chart_list:
ds_id = chart.get("datasource_id")
if ds_id is not None:
unique_ds_ids.add(int(ds_id))
# Check each dataset
dataset_results: list[dict] = []
for ds_id in sorted(unique_ds_ids):
result = await self.check_dataset_health(ds_id)
# Map affected charts
affected_charts = [
{"chart_id": c.get("slice_id"), "chart_name": c.get("slice_name", f"chart_{c.get('slice_id')}")}
for c in chart_list
if c.get("datasource_id") == ds_id
]
result["affected_charts"] = affected_charts
dataset_results.append(result)
# Optionally execute chart data
chart_data_results: list[dict] = []
if execute_chart_data:
for chart in chart_list:
chart_id = chart.get("slice_id")
params = chart.get("params", {})
if isinstance(params, str):
params = json.loads(params)
form_data = {
"slice_id": chart_id,
"viz_type": chart.get("viz_type", "table"),
"datasource_id": chart.get("datasource_id"),
"datasource_type": chart.get("datasource_type", "table"),
"granularity_sqla": params.get("granularity_sqla"),
"time_range": params.get("time_range", "Last 30 days"),
"metrics": params.get("metrics", []),
"groupby": params.get("groupby", []),
"adhoc_filters": params.get("adhoc_filters", []),
}
result = await self.check_chart_data(chart_id, form_data)
chart_data_results.append(result)
return {
"datasets": dataset_results,
"chart_data": chart_data_results,
}
# endregion DatasetHealthChecker.check_dashboard_datasets
# #endregion DatasetHealthChecker
# #region RedactionService [C:2] [TYPE Module]
# @BRIEF Redacts PII, credentials, and sensitive data from logs and LLM responses.
# @LAYER Service
# @RATIONALE FR-029: sensitive data must be filtered BEFORE external LLM send and BEFORE persistence.
class RedactionService:
"""Redacts PII, credentials, and sensitive data."""
# Common patterns to redact
PATTERNS = [
(r"password[=:]\s*\S+", "password=***"),
(r"secret[=:]\s*\S+", "secret=***"),
(r"token[=:]\s*\S+", "token=***"),
(r"api_key[=:]\s*\S+", "api_key=***"),
(r"apikey[=:]\s*\S+", "apikey=***"),
(r"Authorization:\s*\S+", "Authorization: ***"),
(r"Bearer\s+\S+\.\S+\.\S+", "Bearer ***"),
(r"[A-Za-z0-9+/=]{40,}", "***"), # base64 or long tokens
(r"[\w.+-]+@[\w-]+\.[\w.-]+", "***@***"), # emails
]
# region RedactionService.redact_logs [TYPE Function] [C:2]
# @BRIEF Redact PII/credentials from log lines.
# @PRE logs is a list of strings.
# @POST Returns redacted list preserving structure.
@staticmethod
def redact_logs(logs: list[str]) -> list[str]:
"""Redact PII/credentials from log lines."""
redacted = []
for line in logs:
for pattern, replacement in RedactionService.PATTERNS:
line = re.sub(pattern, replacement, line, flags=re.IGNORECASE)
redacted.append(line)
return redacted
# endregion RedactionService.redact_logs
# region RedactionService.redact_raw_response [TYPE Function] [C:2]
# @BRIEF Redact sensitive data from LLM raw response.
# @PRE raw is a string.
# @POST Returns redacted string.
@staticmethod
def redact_raw_response(raw: str) -> str:
"""Redact sensitive data from LLM raw response."""
for pattern, replacement in RedactionService.PATTERNS:
raw = re.sub(pattern, replacement, raw, flags=re.IGNORECASE)
return raw
# endregion RedactionService.redact_raw_response
# #endregion RedactionService
# #endregion LLMAnalysisService