semantics: complete DEF-to-region migration, fix regressions
- Convert legacy [DEF🆔Type] anchors to #region/#endregion across 329 files
- Reinstate _normalize_timestamp_value in sql_generator.py
- Fix MarkerLogger→logger migration in events.py (molecular CoT markers)
- Fix dataset_review orchestrator dependencies (_build_execution_snapshot)
- Fix config_manager stale-record deletion (moved to save path only)
- Add 77 missing [/DEF:] closers in 5 unbalanced test files
- Update assistant_chat.integration.test.js for #region format
- Apply molecular-cot-logging markers (REASON/REFLECT/EXPLORE) via logger.* methods
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# #region backend/src/plugins/llm_analysis/__init__.py [TYPE Module]
|
||||
# [DEF:backend/src/plugins/llm_analysis/__init__.py:Module]
|
||||
|
||||
"""
|
||||
LLM Analysis Plugin for automated dashboard validation and dataset documentation.
|
||||
@@ -8,4 +8,4 @@ from .plugin import DashboardValidationPlugin, DocumentationPlugin
|
||||
|
||||
__all__ = ['DashboardValidationPlugin', 'DocumentationPlugin']
|
||||
|
||||
# #endregion backend/src/plugins/llm_analysis/__init__.py
|
||||
# [/DEF:backend/src/plugins/llm_analysis/__init__.py:Module]
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
# #region TestClientHeaders [C:3] [TYPE Module] [SEMANTICS tests, llm-client, openrouter, headers]
|
||||
# @BRIEF Verify OpenRouter client initialization includes provider-specific headers.
|
||||
# @RELATION BELONGS_TO -> [SrcRoot]
|
||||
# [DEF:TestClientHeaders:Module]
|
||||
# @RELATION: BELONGS_TO -> SrcRoot
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, llm-client, openrouter, headers
|
||||
# @PURPOSE: Verify OpenRouter client initialization includes provider-specific headers.
|
||||
|
||||
from src.plugins.llm_analysis.models import LLMProviderType
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
|
||||
# #region test_openrouter_client_includes_referer_and_title_headers [TYPE Function]
|
||||
# @BRIEF OpenRouter requests should carry site/app attribution headers for compatibility.
|
||||
# @PRE Client is initialized for OPENROUTER provider.
|
||||
# @POST Async client headers include Authorization, HTTP-Referer, and X-Title.
|
||||
# @RELATION BINDS_TO -> [TestClientHeaders]
|
||||
# [DEF:test_openrouter_client_includes_referer_and_title_headers:Function]
|
||||
# @RELATION: BINDS_TO -> TestClientHeaders
|
||||
# @PURPOSE: OpenRouter requests should carry site/app attribution headers for compatibility.
|
||||
# @PRE: Client is initialized for OPENROUTER provider.
|
||||
# @POST: Async client headers include Authorization, HTTP-Referer, and X-Title.
|
||||
def test_openrouter_client_includes_referer_and_title_headers(monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_SITE_URL", "http://localhost:8000")
|
||||
monkeypatch.setenv("OPENROUTER_APP_NAME", "ss-tools-test")
|
||||
@@ -26,5 +28,5 @@ def test_openrouter_client_includes_referer_and_title_headers(monkeypatch):
|
||||
assert headers["Authorization"] == "Bearer sk-test-provider-key-123456"
|
||||
assert headers["HTTP-Referer"] == "http://localhost:8000"
|
||||
assert headers["X-Title"] == "ss-tools-test"
|
||||
# #endregion test_openrouter_client_includes_referer_and_title_headers
|
||||
# #endregion TestClientHeaders
|
||||
# [/DEF:test_openrouter_client_includes_referer_and_title_headers:Function]
|
||||
# [/DEF:TestClientHeaders:Module]
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# #region TestScreenshotService [C:3] [TYPE Module] [SEMANTICS tests, screenshot-service, navigation, timeout-regression]
|
||||
# @BRIEF Protect dashboard screenshot navigation from brittle networkidle waits.
|
||||
# @RELATION VERIFIES -> [src.plugins.llm_analysis.service.ScreenshotService]
|
||||
# [DEF:TestScreenshotService:Module]
|
||||
# @RELATION: VERIFIES ->[src.plugins.llm_analysis.service.ScreenshotService]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, screenshot-service, navigation, timeout-regression
|
||||
# @PURPOSE: Protect dashboard screenshot navigation from brittle networkidle waits.
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
|
||||
# #region test_iter_login_roots_includes_child_frames [TYPE Function]
|
||||
# @BRIEF Login discovery must search embedded auth frames, not only the main page.
|
||||
# @PRE Page exposes child frames list.
|
||||
# @POST Returned roots include page plus child frames in order.
|
||||
# @RELATION BINDS_TO -> [TestScreenshotService]
|
||||
# [DEF:test_iter_login_roots_includes_child_frames:Function]
|
||||
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||
# @PURPOSE: Login discovery must search embedded auth frames, not only the main page.
|
||||
# @PRE: Page exposes child frames list.
|
||||
# @POST: Returned roots include page plus child frames in order.
|
||||
def test_iter_login_roots_includes_child_frames():
|
||||
frame_a = object()
|
||||
frame_b = object()
|
||||
@@ -23,14 +25,14 @@ def test_iter_login_roots_includes_child_frames():
|
||||
assert roots == [fake_page, frame_a, frame_b]
|
||||
|
||||
|
||||
# #endregion test_iter_login_roots_includes_child_frames
|
||||
# [/DEF:test_iter_login_roots_includes_child_frames:Function]
|
||||
|
||||
|
||||
# #region test_response_looks_like_login_page_detects_login_markup [TYPE Function]
|
||||
# @BRIEF Direct login fallback must reject responses that render the login screen again.
|
||||
# @PRE Response body contains stable login-page markers.
|
||||
# @POST Helper returns True so caller treats fallback as failed authentication.
|
||||
# @RELATION BINDS_TO -> [TestScreenshotService]
|
||||
# [DEF:test_response_looks_like_login_page_detects_login_markup:Function]
|
||||
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||
# @PURPOSE: Direct login fallback must reject responses that render the login screen again.
|
||||
# @PRE: Response body contains stable login-page markers.
|
||||
# @POST: Helper returns True so caller treats fallback as failed authentication.
|
||||
def test_response_looks_like_login_page_detects_login_markup():
|
||||
service = ScreenshotService(env=type("Env", (), {})())
|
||||
|
||||
@@ -50,14 +52,14 @@ def test_response_looks_like_login_page_detects_login_markup():
|
||||
assert result is True
|
||||
|
||||
|
||||
# #endregion test_response_looks_like_login_page_detects_login_markup
|
||||
# [/DEF:test_response_looks_like_login_page_detects_login_markup:Function]
|
||||
|
||||
|
||||
# #region test_find_first_visible_locator_skips_hidden_first_match [TYPE Function]
|
||||
# @BRIEF Locator helper must not reject a selector collection just because its first element is hidden.
|
||||
# @PRE First matched element is hidden and second matched element is visible.
|
||||
# @POST Helper returns the second visible candidate.
|
||||
# @RELATION BINDS_TO -> [TestScreenshotService]
|
||||
# [DEF:test_find_first_visible_locator_skips_hidden_first_match:Function]
|
||||
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||
# @PURPOSE: Locator helper must not reject a selector collection just because its first element is hidden.
|
||||
# @PRE: First matched element is hidden and second matched element is visible.
|
||||
# @POST: Helper returns the second visible candidate.
|
||||
@pytest.mark.anyio
|
||||
async def test_find_first_visible_locator_skips_hidden_first_match():
|
||||
class _FakeElement:
|
||||
@@ -91,14 +93,14 @@ async def test_find_first_visible_locator_skips_hidden_first_match():
|
||||
assert result.label == "visible"
|
||||
|
||||
|
||||
# #endregion test_find_first_visible_locator_skips_hidden_first_match
|
||||
# [/DEF:test_find_first_visible_locator_skips_hidden_first_match:Function]
|
||||
|
||||
|
||||
# #region test_submit_login_via_form_post_uses_browser_context_request [TYPE Function]
|
||||
# @BRIEF Fallback login must submit hidden fields and credentials through the context request cookie jar.
|
||||
# @PRE Login DOM exposes csrf hidden field and request context returns authenticated HTML.
|
||||
# @POST Helper returns True and request payload contains csrf_token plus credentials plus request options.
|
||||
# @RELATION BINDS_TO -> [TestScreenshotService]
|
||||
# [DEF:test_submit_login_via_form_post_uses_browser_context_request:Function]
|
||||
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||
# @PURPOSE: Fallback login must submit hidden fields and credentials through the context request cookie jar.
|
||||
# @PRE: Login DOM exposes csrf hidden field and request context returns authenticated HTML.
|
||||
# @POST: Helper returns True and request payload contains csrf_token plus credentials plus request options.
|
||||
@pytest.mark.anyio
|
||||
async def test_submit_login_via_form_post_uses_browser_context_request():
|
||||
class _FakeInput:
|
||||
@@ -202,14 +204,14 @@ async def test_submit_login_via_form_post_uses_browser_context_request():
|
||||
]
|
||||
|
||||
|
||||
# #endregion test_submit_login_via_form_post_uses_browser_context_request
|
||||
# [/DEF:test_submit_login_via_form_post_uses_browser_context_request:Function]
|
||||
|
||||
|
||||
# #region test_submit_login_via_form_post_accepts_authenticated_redirect [TYPE Function]
|
||||
# @BRIEF Fallback login must treat non-login 302 redirect as success without waiting for redirect target.
|
||||
# @PRE Request response is 302 with Location outside login path.
|
||||
# @POST Helper returns True.
|
||||
# @RELATION BINDS_TO -> [TestScreenshotService]
|
||||
# [DEF:test_submit_login_via_form_post_accepts_authenticated_redirect:Function]
|
||||
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||
# @PURPOSE: Fallback login must treat non-login 302 redirect as success without waiting for redirect target.
|
||||
# @PRE: Request response is 302 with Location outside login path.
|
||||
# @POST: Helper returns True.
|
||||
@pytest.mark.anyio
|
||||
async def test_submit_login_via_form_post_accepts_authenticated_redirect():
|
||||
class _FakeInput:
|
||||
@@ -277,14 +279,14 @@ async def test_submit_login_via_form_post_accepts_authenticated_redirect():
|
||||
assert result is True
|
||||
|
||||
|
||||
# #endregion test_submit_login_via_form_post_accepts_authenticated_redirect
|
||||
# [/DEF:test_submit_login_via_form_post_accepts_authenticated_redirect:Function]
|
||||
|
||||
|
||||
# #region test_submit_login_via_form_post_rejects_login_markup_response [TYPE Function]
|
||||
# @BRIEF Fallback login must fail when POST response still contains login form content.
|
||||
# @PRE Login DOM exposes csrf hidden field and request response renders login markup.
|
||||
# @POST Helper returns False.
|
||||
# @RELATION BINDS_TO -> [TestScreenshotService]
|
||||
# [DEF:test_submit_login_via_form_post_rejects_login_markup_response:Function]
|
||||
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||
# @PURPOSE: Fallback login must fail when POST response still contains login form content.
|
||||
# @PRE: Login DOM exposes csrf hidden field and request response renders login markup.
|
||||
# @POST: Helper returns False.
|
||||
@pytest.mark.anyio
|
||||
async def test_submit_login_via_form_post_rejects_login_markup_response():
|
||||
class _FakeInput:
|
||||
@@ -360,14 +362,14 @@ async def test_submit_login_via_form_post_rejects_login_markup_response():
|
||||
assert result is False
|
||||
|
||||
|
||||
# #endregion test_submit_login_via_form_post_rejects_login_markup_response
|
||||
# [/DEF:test_submit_login_via_form_post_rejects_login_markup_response:Function]
|
||||
|
||||
|
||||
# #region test_goto_resilient_falls_back_from_domcontentloaded_to_load [TYPE Function]
|
||||
# @BRIEF Pages with unstable primary wait must retry with fallback wait strategy.
|
||||
# @PRE First page.goto call raises; second succeeds.
|
||||
# @POST Helper returns second response and attempts both wait modes in order.
|
||||
# @RELATION BINDS_TO -> [TestScreenshotService]
|
||||
# [DEF:test_goto_resilient_falls_back_from_domcontentloaded_to_load:Function]
|
||||
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||
# @PURPOSE: Pages with unstable primary wait must retry with fallback wait strategy.
|
||||
# @PRE: First page.goto call raises; second succeeds.
|
||||
# @POST: Helper returns second response and attempts both wait modes in order.
|
||||
@pytest.mark.anyio
|
||||
async def test_goto_resilient_falls_back_from_domcontentloaded_to_load():
|
||||
class _FakePage:
|
||||
@@ -398,5 +400,5 @@ async def test_goto_resilient_falls_back_from_domcontentloaded_to_load():
|
||||
]
|
||||
|
||||
|
||||
# #endregion test_goto_resilient_falls_back_from_domcontentloaded_to_load
|
||||
# #endregion TestScreenshotService
|
||||
# [/DEF:test_goto_resilient_falls_back_from_domcontentloaded_to_load:Function]
|
||||
# [/DEF:TestScreenshotService:Module]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# #region TestService [C:3] [TYPE Module] [SEMANTICS tests, llm-analysis, fallback, provider-error, unknown-status]
|
||||
# @BRIEF Verify LLM analysis transport/provider failures do not masquerade as dashboard FAIL results.
|
||||
# @RELATION BELONGS_TO -> [SrcRoot]
|
||||
# [DEF:TestService:Module]
|
||||
# @RELATION: BELONGS_TO -> SrcRoot
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, llm-analysis, fallback, provider-error, unknown-status
|
||||
# @PURPOSE: Verify LLM analysis transport/provider failures do not masquerade as dashboard FAIL results.
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,11 +10,11 @@ from src.plugins.llm_analysis.models import LLMProviderType
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
|
||||
# #region test_test_runtime_connection_uses_json_completion_transport [TYPE Function]
|
||||
# @BRIEF Provider self-test must exercise the same chat completion transport as runtime analysis.
|
||||
# @PRE get_json_completion is available on initialized client.
|
||||
# @POST Self-test forwards a lightweight user message into get_json_completion and returns its payload.
|
||||
# @RELATION BINDS_TO -> [TestService]
|
||||
# [DEF:test_test_runtime_connection_uses_json_completion_transport:Function]
|
||||
# @RELATION: BINDS_TO -> TestService
|
||||
# @PURPOSE: Provider self-test must exercise the same chat completion transport as runtime analysis.
|
||||
# @PRE: get_json_completion is available on initialized client.
|
||||
# @POST: Self-test forwards a lightweight user message into get_json_completion and returns its payload.
|
||||
@pytest.mark.anyio
|
||||
async def test_test_runtime_connection_uses_json_completion_transport(monkeypatch):
|
||||
client = LLMClient(
|
||||
@@ -34,14 +36,14 @@ async def test_test_runtime_connection_uses_json_completion_transport(monkeypatc
|
||||
assert result == {"ok": True}
|
||||
assert recorded["messages"][0]["role"] == "user"
|
||||
assert "Return exactly this JSON object" in recorded["messages"][0]["content"]
|
||||
# #endregion test_test_runtime_connection_uses_json_completion_transport
|
||||
# [/DEF:test_test_runtime_connection_uses_json_completion_transport:Function]
|
||||
|
||||
|
||||
# #region test_analyze_dashboard_provider_error_maps_to_unknown [TYPE Function]
|
||||
# @BRIEF Infrastructure/provider failures must produce UNKNOWN analysis status rather than FAIL.
|
||||
# @PRE LLMClient.get_json_completion raises provider/auth exception.
|
||||
# @POST Returned payload uses status=UNKNOWN and issue severity UNKNOWN.
|
||||
# @RELATION BINDS_TO -> [TestService]
|
||||
# [DEF:test_analyze_dashboard_provider_error_maps_to_unknown:Function]
|
||||
# @RELATION: BINDS_TO -> TestService
|
||||
# @PURPOSE: Infrastructure/provider failures must produce UNKNOWN analysis status rather than FAIL.
|
||||
# @PRE: LLMClient.get_json_completion raises provider/auth exception.
|
||||
# @POST: Returned payload uses status=UNKNOWN and issue severity UNKNOWN.
|
||||
@pytest.mark.anyio
|
||||
async def test_analyze_dashboard_provider_error_maps_to_unknown(monkeypatch, tmp_path):
|
||||
screenshot_path = tmp_path / "shot.jpg"
|
||||
@@ -64,5 +66,5 @@ async def test_analyze_dashboard_provider_error_maps_to_unknown(monkeypatch, tmp
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert "Failed to get response from LLM" in result["summary"]
|
||||
assert result["issues"][0]["severity"] == "UNKNOWN"
|
||||
# #endregion test_analyze_dashboard_provider_error_maps_to_unknown
|
||||
# #endregion TestService
|
||||
# [/DEF:test_analyze_dashboard_provider_error_maps_to_unknown:Function]
|
||||
# [/DEF:TestService:Module]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region LLMAnalysisModels [C:3] [TYPE Module] [SEMANTICS pydantic, models, llm]
|
||||
# @BRIEF Define Pydantic models for LLM Analysis plugin.
|
||||
# @LAYER Domain
|
||||
# @RELATION DEPENDS_ON -> [pydantic]
|
||||
# @RELATION DEPENDS_on -> [pydantic]
|
||||
# @RELATION DEPENDs_on -> [pydantic]
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> pydantic
|
||||
# @RELATION DEPENDS_on -> pydantic
|
||||
# @RELATION DEPENDs_on -> pydantic
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -18,22 +18,6 @@ class LLMProviderType(str, Enum):
|
||||
KILO = "kilo"
|
||||
# #endregion LLMProviderType
|
||||
|
||||
# #region FetchModelsRequest [TYPE Class]
|
||||
# @BRIEF Request model to fetch available models from a provider.
|
||||
class FetchModelsRequest(BaseModel):
|
||||
base_url: str = Field(description="Provider base URL (e.g. https://api.openai.com/v1)")
|
||||
api_key: Optional[str] = Field(None, description="Optional API key for authenticated providers")
|
||||
provider_type: LLMProviderType = Field(LLMProviderType.OPENAI, description="Provider type for auth headers")
|
||||
provider_id: Optional[str] = Field(None, description="Existing provider ID — resolves api_key from DB if not provided directly")
|
||||
# #endregion FetchModelsRequest
|
||||
|
||||
# #region FetchModelsResponse [TYPE Class]
|
||||
# @BRIEF Response with available model IDs from a provider.
|
||||
class FetchModelsResponse(BaseModel):
|
||||
models: List[str] = Field(description="List of available model IDs")
|
||||
total: int = Field(description="Total number of models found")
|
||||
# #endregion FetchModelsResponse
|
||||
|
||||
# #region LLMProviderConfig [TYPE Class]
|
||||
# @BRIEF Configuration for an LLM provider.
|
||||
class LLMProviderConfig(BaseModel):
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# #region backend/src/plugins/llm_analysis/plugin.py [C:3] [TYPE Module] [SEMANTICS plugin, llm, analysis, documentation]
|
||||
# [DEF:backend/src/plugins/llm_analysis/plugin.py:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: plugin, llm, analysis, documentation
|
||||
# @BRIEF Implements DashboardValidationPlugin and DocumentationPlugin.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT All LLM interactions must be executed as asynchronous tasks.
|
||||
# @LAYER: Domain
|
||||
# @RELATION INHERITS -> [PluginBase]
|
||||
# @RELATION CALLS -> [ScreenshotService]
|
||||
# @RELATION CALLS -> [LLMClient]
|
||||
# @RELATION CALLS -> [LLMProviderService]
|
||||
# @RELATION USES -> [TaskContext]
|
||||
# @RELATION USES -> TaskContext
|
||||
# @INVARIANT: All LLM interactions must be executed as asynchronous tasks.
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
import os
|
||||
@@ -31,8 +33,8 @@ from ...services.llm_prompt_templates import (
|
||||
|
||||
# #region _is_masked_or_invalid_api_key [TYPE Function]
|
||||
# @BRIEF Guards against placeholder or malformed API keys in runtime.
|
||||
# @PRE value may be None.
|
||||
# @POST Returns True when value cannot be used for authenticated provider calls.
|
||||
# @PRE: value may be None.
|
||||
# @POST: Returns True when value cannot be used for authenticated provider calls.
|
||||
def _is_masked_or_invalid_api_key(value: Optional[str]) -> bool:
|
||||
key = (value or "").strip()
|
||||
if not key:
|
||||
@@ -45,8 +47,8 @@ def _is_masked_or_invalid_api_key(value: Optional[str]) -> bool:
|
||||
|
||||
# #region _json_safe_value [TYPE Function]
|
||||
# @BRIEF Recursively normalize payload values for JSON serialization.
|
||||
# @PRE value may be nested dict/list with datetime values.
|
||||
# @POST datetime values are converted to ISO strings.
|
||||
# @PRE: value may be nested dict/list with datetime values.
|
||||
# @POST: datetime values are converted to ISO strings.
|
||||
def _json_safe_value(value: Any):
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
@@ -59,7 +61,7 @@ def _json_safe_value(value: Any):
|
||||
|
||||
# #region DashboardValidationPlugin [TYPE Class]
|
||||
# @BRIEF Plugin for automated dashboard health analysis using LLMs.
|
||||
# @RELATION IMPLEMENTS -> [backend.src.core.plugin_base.PluginBase]
|
||||
# @RELATION IMPLEMENTS -> backend.src.core.plugin_base.PluginBase
|
||||
class DashboardValidationPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
@@ -88,8 +90,8 @@ class DashboardValidationPlugin(PluginBase):
|
||||
"required": ["dashboard_id", "environment_id", "provider_id"]
|
||||
}
|
||||
|
||||
# #region DashboardValidationPlugin.execute [TYPE Function]
|
||||
# @BRIEF Executes the dashboard validation task with TaskContext support.
|
||||
# [DEF:DashboardValidationPlugin.execute:Function]
|
||||
# @PURPOSE: Executes the dashboard validation task with TaskContext support.
|
||||
# @PARAM: params (Dict[str, Any]) - Validation parameters.
|
||||
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||
# @PRE: params contains dashboard_id, environment_id, and provider_id.
|
||||
@@ -317,12 +319,12 @@ class DashboardValidationPlugin(PluginBase):
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
# #endregion DashboardValidationPlugin.execute
|
||||
# [/DEF:DashboardValidationPlugin.execute:Function]
|
||||
# #endregion DashboardValidationPlugin
|
||||
|
||||
# #region DocumentationPlugin [TYPE Class]
|
||||
# @BRIEF Plugin for automated dataset documentation using LLMs.
|
||||
# @RELATION IMPLEMENTS -> [backend.src.core.plugin_base.PluginBase]
|
||||
# @RELATION IMPLEMENTS -> backend.src.core.plugin_base.PluginBase
|
||||
class DocumentationPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
@@ -351,8 +353,8 @@ class DocumentationPlugin(PluginBase):
|
||||
"required": ["dataset_id", "environment_id", "provider_id"]
|
||||
}
|
||||
|
||||
# #region DocumentationPlugin.execute [TYPE Function]
|
||||
# @BRIEF Executes the dataset documentation task with TaskContext support.
|
||||
# [DEF:DocumentationPlugin.execute:Function]
|
||||
# @PURPOSE: Executes the dataset documentation task with TaskContext support.
|
||||
# @PARAM: params (Dict[str, Any]) - Documentation parameters.
|
||||
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||
# @PRE: params contains dataset_id, environment_id, and provider_id.
|
||||
@@ -473,7 +475,7 @@ class DocumentationPlugin(PluginBase):
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
# #endregion DocumentationPlugin.execute
|
||||
# [/DEF:DocumentationPlugin.execute:Function]
|
||||
# #endregion DocumentationPlugin
|
||||
|
||||
# #endregion backend/src/plugins/llm_analysis/plugin.py
|
||||
# [/DEF:backend/src/plugins/llm_analysis/plugin.py:Module]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# #region backend/src/plugins/llm_analysis/scheduler.py [C:3] [TYPE Module] [SEMANTICS scheduler, task, automation]
|
||||
# [DEF:backend/src/plugins/llm_analysis/scheduler.py:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: scheduler, task, automation
|
||||
# @BRIEF Provides helper functions to schedule LLM-based validation tasks.
|
||||
# @LAYER Domain
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [SchedulerService]
|
||||
|
||||
from typing import Dict, Any
|
||||
@@ -9,9 +11,6 @@ from ...core.logger import belief_scope, logger
|
||||
|
||||
# #region schedule_dashboard_validation [TYPE Function]
|
||||
# @BRIEF Schedules a recurring dashboard validation task.
|
||||
# @PARAM: dashboard_id (str) - ID of the dashboard to validate.
|
||||
# @PARAM: cron_expression (str) - Standard cron expression for scheduling.
|
||||
# @PARAM: params (Dict[str, Any]) - Task parameters (environment_id, provider_id).
|
||||
# @SIDE_EFFECT: Adds a job to the scheduler service.
|
||||
def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, params: Dict[str, Any]):
|
||||
with belief_scope("schedule_dashboard_validation", f"dashboard_id={dashboard_id}"):
|
||||
@@ -41,8 +40,6 @@ def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, param
|
||||
|
||||
# #region _parse_cron [TYPE Function]
|
||||
# @BRIEF Basic cron parser placeholder.
|
||||
# @PARAM: cron (str) - Cron expression.
|
||||
# @RETURN: Dict[str, str] - Parsed cron parts.
|
||||
def _parse_cron(cron: str) -> Dict[str, str]:
|
||||
# Basic cron parser placeholder
|
||||
parts = cron.split()
|
||||
@@ -57,4 +54,4 @@ def _parse_cron(cron: str) -> Dict[str, str]:
|
||||
}
|
||||
# #endregion _parse_cron
|
||||
|
||||
# #endregion backend/src/plugins/llm_analysis/scheduler.py
|
||||
# [/DEF:backend/src/plugins/llm_analysis/scheduler.py:Module]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# #region backend/src/plugins/llm_analysis/service.py [C:3] [TYPE Module] [SEMANTICS service, llm, screenshot, playwright, openai]
|
||||
# [DEF:backend/src/plugins/llm_analysis/service.py:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: service, llm, screenshot, playwright, openai
|
||||
# @BRIEF Services for LLM interaction and dashboard screenshots.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Screenshots must be 1920px width and capture full page height.
|
||||
# @RELATION DEPENDS_ON -> [playwright]
|
||||
# @RELATION DEPENDS_ON -> [openai]
|
||||
# @RELATION DEPENDS_ON -> [tenacity]
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> playwright
|
||||
# @RELATION DEPENDS_ON -> openai
|
||||
# @RELATION DEPENDS_ON -> tenacity
|
||||
# @INVARIANT: Screenshots must be 1920px width and capture full page height.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
@@ -26,17 +28,17 @@ from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
|
||||
# #region ScreenshotService [TYPE Class]
|
||||
# @BRIEF Handles capturing screenshots of Superset dashboards.
|
||||
class ScreenshotService:
|
||||
# #region ScreenshotService.__init__ [TYPE Function]
|
||||
# @BRIEF Initializes the ScreenshotService with environment configuration.
|
||||
# @PRE env is a valid Environment object.
|
||||
# [DEF:ScreenshotService.__init__: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__
|
||||
# [/DEF:ScreenshotService.__init__:Function]
|
||||
|
||||
# #region ScreenshotService._find_first_visible_locator [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:ScreenshotService._find_first_visible_locator: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:
|
||||
@@ -48,12 +50,12 @@ class ScreenshotService:
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
# #endregion ScreenshotService._find_first_visible_locator
|
||||
# [/DEF:ScreenshotService._find_first_visible_locator:Function]
|
||||
|
||||
# #region ScreenshotService._iter_login_roots [TYPE Function]
|
||||
# @BRIEF 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:ScreenshotService._iter_login_roots: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", [])
|
||||
@@ -64,12 +66,12 @@ class ScreenshotService:
|
||||
except Exception:
|
||||
pass
|
||||
return roots
|
||||
# #endregion ScreenshotService._iter_login_roots
|
||||
# [/DEF:ScreenshotService._iter_login_roots:Function]
|
||||
|
||||
# #region ScreenshotService._extract_hidden_login_fields [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:ScreenshotService._extract_hidden_login_fields: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):
|
||||
@@ -85,21 +87,21 @@ class ScreenshotService:
|
||||
except Exception:
|
||||
continue
|
||||
return hidden_fields
|
||||
# #endregion ScreenshotService._extract_hidden_login_fields
|
||||
# [/DEF:ScreenshotService._extract_hidden_login_fields:Function]
|
||||
|
||||
# #region ScreenshotService._extract_csrf_token [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:ScreenshotService._extract_csrf_token: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
|
||||
# [/DEF:ScreenshotService._extract_csrf_token:Function]
|
||||
|
||||
# #region ScreenshotService._response_looks_like_login_page [TYPE Function]
|
||||
# @BRIEF 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:ScreenshotService._response_looks_like_login_page: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:
|
||||
@@ -113,23 +115,23 @@ class ScreenshotService:
|
||||
'name="csrf_token"',
|
||||
]
|
||||
return sum(marker in normalized for marker in markers) >= 3
|
||||
# #endregion ScreenshotService._response_looks_like_login_page
|
||||
# [/DEF:ScreenshotService._response_looks_like_login_page:Function]
|
||||
|
||||
# #region ScreenshotService._redirect_looks_authenticated [TYPE Function]
|
||||
# @BRIEF 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:ScreenshotService._redirect_looks_authenticated: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
|
||||
# [/DEF:ScreenshotService._redirect_looks_authenticated:Function]
|
||||
|
||||
# #region ScreenshotService._submit_login_via_form_post [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:ScreenshotService._submit_login_via_form_post: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()
|
||||
@@ -186,12 +188,12 @@ class ScreenshotService:
|
||||
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
|
||||
# [/DEF:ScreenshotService._submit_login_via_form_post:Function]
|
||||
|
||||
# #region ScreenshotService._find_login_field_locator [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:ScreenshotService._find_login_field_locator: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):
|
||||
@@ -226,12 +228,12 @@ class ScreenshotService:
|
||||
return locator
|
||||
|
||||
return None
|
||||
# #endregion ScreenshotService._find_login_field_locator
|
||||
# [/DEF:ScreenshotService._find_login_field_locator:Function]
|
||||
|
||||
# #region ScreenshotService._find_submit_locator [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:ScreenshotService._find_submit_locator: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),
|
||||
@@ -246,12 +248,12 @@ class ScreenshotService:
|
||||
if locator:
|
||||
return locator
|
||||
return None
|
||||
# #endregion ScreenshotService._find_submit_locator
|
||||
# [/DEF:ScreenshotService._find_submit_locator:Function]
|
||||
|
||||
# #region ScreenshotService._goto_resilient [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:ScreenshotService._goto_resilient: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,
|
||||
@@ -267,13 +269,13 @@ class ScreenshotService:
|
||||
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
|
||||
# [/DEF:ScreenshotService._goto_resilient:Function]
|
||||
|
||||
# #region ScreenshotService.capture_dashboard [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:ScreenshotService.capture_dashboard: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
|
||||
@@ -669,15 +671,15 @@ class ScreenshotService:
|
||||
|
||||
await browser.close()
|
||||
return True
|
||||
# #endregion ScreenshotService.capture_dashboard
|
||||
# [/DEF:ScreenshotService.capture_dashboard:Function]
|
||||
# #endregion ScreenshotService
|
||||
|
||||
# #region LLMClient [TYPE Class]
|
||||
# @BRIEF Wrapper for LLM provider APIs.
|
||||
class LLMClient:
|
||||
# #region LLMClient.__init__ [TYPE Function]
|
||||
# @BRIEF Initializes the LLMClient with provider settings.
|
||||
# @PRE api_key, base_url, and default_model are non-empty strings.
|
||||
# [DEF:LLMClient.__init__: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()
|
||||
@@ -715,12 +717,12 @@ class LLMClient:
|
||||
default_headers=default_headers,
|
||||
http_client=http_client,
|
||||
)
|
||||
# #endregion LLMClient.__init__
|
||||
# [/DEF:LLMClient.__init__:Function]
|
||||
|
||||
# #region LLMClient._supports_json_response_format [TYPE Function]
|
||||
# @BRIEF 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:LLMClient._supports_json_response_format: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()
|
||||
@@ -735,13 +737,13 @@ class LLMClient:
|
||||
if any(token in model for token in incompatible_tokens):
|
||||
return False
|
||||
return True
|
||||
# #endregion LLMClient._supports_json_response_format
|
||||
# [/DEF:LLMClient._supports_json_response_format:Function]
|
||||
|
||||
# #region LLMClient.get_json_completion [TYPE Function]
|
||||
# @BRIEF 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:LLMClient.get_json_completion: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
|
||||
@@ -857,13 +859,13 @@ class LLMClient:
|
||||
return json.loads(json_str)
|
||||
else:
|
||||
raise
|
||||
# #endregion LLMClient.get_json_completion
|
||||
# [/DEF:LLMClient.get_json_completion:Function]
|
||||
|
||||
# #region LLMClient.test_runtime_connection [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:LLMClient.test_runtime_connection: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 = [
|
||||
@@ -873,13 +875,13 @@ class LLMClient:
|
||||
}
|
||||
]
|
||||
return await self.get_json_completion(messages)
|
||||
# #endregion LLMClient.test_runtime_connection
|
||||
# [/DEF:LLMClient.test_runtime_connection:Function]
|
||||
|
||||
# #region LLMClient.analyze_dashboard [TYPE Function]
|
||||
# @BRIEF 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.
|
||||
# [DEF:LLMClient.analyze_dashboard: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.
|
||||
async def analyze_dashboard(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
@@ -945,7 +947,7 @@ class LLMClient:
|
||||
"summary": f"Failed to get response from LLM: {str(e)}",
|
||||
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
|
||||
}
|
||||
# #endregion LLMClient.analyze_dashboard
|
||||
# [/DEF:LLMClient.analyze_dashboard:Function]
|
||||
# #endregion LLMClient
|
||||
|
||||
# #endregion backend/src/plugins/llm_analysis/service.py
|
||||
# [/DEF:backend/src/plugins/llm_analysis/service.py:Module]
|
||||
|
||||
Reference in New Issue
Block a user