semantic
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user